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/README.md b/README.md index 2d31ce5..4c72eed 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # 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`. +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 +`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.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. @@ -23,26 +23,34 @@ 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 with its existing -restoration handover. 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 +The header identifies Beta and provides a direct Legacy shortcut that keeps the +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. @@ -69,6 +77,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, Original fallback, 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 @@ -99,7 +110,11 @@ 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, 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 → System & help** shows checks and operator instructions; it does not execute commands. The visitor guide also operates no devices. A two-second stationary @@ -115,7 +130,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 @@ -144,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. @@ -166,17 +181,19 @@ 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. 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 d59b428..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. @@ -24,12 +24,14 @@ 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. +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. @@ -38,32 +40,53 @@ 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 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 -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. 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 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. Blank screen displays black pixels. A stationary two-second press returns to the previous view; movement beyond 40 pixels cancels. The backlight remains powered. @@ -80,8 +103,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. @@ -165,12 +189,22 @@ 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`. -Legacy and the light adapter use independent interpreter settings. The launcher +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) +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. +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 @@ -187,10 +221,14 @@ 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 checks a fresh baseline. If restoration is uncertain, inspect the receipt -before changing lights. Signal shutdown still stops the switcher. +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. 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 +278,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 +342,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. @@ -333,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. @@ -388,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 @@ -407,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. @@ -417,5 +456,11 @@ 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 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 +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/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/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_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 d573c28..f3cc703 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,9 @@ # 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", "scene_buttons", @@ -74,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": "Restores the starting lighting, then shows the original room controls.", + "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.", @@ -193,6 +198,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"]) @@ -216,7 +227,11 @@ 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") == "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: return "off" if response["baseline"] == "Off" else "on" values = response.get("words") @@ -235,6 +250,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..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": (), @@ -47,6 +48,8 @@ def command(value): "cct": ("cct", "intensity"), "preset": ("name",), "channels": ("values",), + "gradient": (), + "programme": ("name",), "theme": ("value",), "clock": ("value",), "temperature": ("value",), @@ -79,6 +82,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 +93,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_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 afa638c..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 after restoring the starting lighting scene. Use Home in the Legacy bar to return.\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 eaf81f6..323f8e6 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 @@ -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"]) @@ -168,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 3cb28ac..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.0" +VERSION = "0.2.3" def build_commit(): diff --git a/app/panel_views.py b/app/panel_views.py index e7f5318..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,27 +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.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( - 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/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/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 675f64f..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( @@ -109,6 +110,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" @@ -134,6 +139,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 +319,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 @@ -542,7 +548,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 +623,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 @@ -642,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) @@ -655,8 +668,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 +699,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,9 +748,10 @@ def build_dock(self): compound="left", ) home.pack(fill="both", expand=True) + self.navigation_buttons.append(home) mode = tk.Label( navigation, - text="Legacy view", + text="Original fallback", font=(FONT, 20), bg=self.control, fg=self.ink, @@ -760,8 +778,9 @@ 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 + 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): @@ -774,6 +793,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,21 +825,35 @@ 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 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): + 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"): + 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() + elif self.light_state not in ("ready", "applying", "connecting"): if not self.worker.stdin.closed: 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,8 +880,16 @@ 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.", + "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.", @@ -860,8 +902,23 @@ 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" + 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(): + 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 +926,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 +995,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,19 +1004,24 @@ 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",) + elif self.mode == "classic": + key = ("classic",) elif self.mode == "room" or self.idle_active: key = ( "idle" if self.idle_active else "room", 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 @@ -1003,7 +1066,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 @@ -1022,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() @@ -1042,7 +1106,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, ) @@ -1053,8 +1121,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): @@ -1066,6 +1164,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 ( @@ -1162,6 +1268,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])) @@ -1181,13 +1290,13 @@ 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 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)) @@ -1223,10 +1332,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] @@ -1241,17 +1351,20 @@ 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() def update_light_buttons(self): + self.update_navigation_buttons() for button in self.light_buttons: if button.winfo_exists(): button.config( 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) @@ -1260,10 +1373,20 @@ 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(): 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") @@ -1606,12 +1729,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..." @@ -1627,8 +1755,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") @@ -1638,7 +1777,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() @@ -1754,24 +1893,38 @@ 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") + 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: + 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. Check System & help.") + self.lighting_feedback("Lighting unavailable; helper could not start.") + return self.light_state = "connecting" self.confirmed_light = "Not checked" self.confirmed_at = None @@ -1780,6 +1933,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 ( @@ -1798,6 +1953,11 @@ def request(self, action, arguments=None): "sent_monotonic_s": monotonic(), } self.emit("light_requested", request=item) + 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": + self.release_confirmed = False try: self.worker.stdin.write((json.dumps(item) + "\n").encode("utf-8")) self.worker.stdin.flush() @@ -1836,6 +1996,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 @@ -1850,6 +2025,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. 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.") if ( worker.poll() is None and not worker.stdin.closed @@ -1872,26 +2058,40 @@ 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) + ) 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 if ( response.get("version") == 1 and response.get("state") == "initialized" @@ -1899,6 +2099,7 @@ def poll_worker(self): and self.light_state == "connecting" ): self.worker_ready = True + self.adapter_metadata(response) self.request( "release" if self.release_target or self.closing else "acquire" ) @@ -1908,40 +2109,33 @@ 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"), + "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.") continue completed_request, self.pending = self.pending, None action = completed_request["action"] self.emit("light_result", result=response) state = response["state"] + self.adapter_metadata(response) self.light_state = { "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. 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"] ) @@ -1949,9 +2143,15 @@ 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." - 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) @@ -1963,24 +2163,30 @@ 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") 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() @@ -2001,7 +2207,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() @@ -2063,6 +2278,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: @@ -2097,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.") @@ -2112,7 +2328,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: @@ -2147,18 +2367,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() @@ -2183,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", ) @@ -2279,6 +2502,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 c8b1adb..6a955b1 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 @@ -36,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): @@ -67,11 +126,28 @@ 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, + 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) @@ -89,14 +165,132 @@ 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: + 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 {"state": "ready", "baseline": name, "fixtures": 10} + 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"] @@ -121,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. @@ -138,21 +329,32 @@ 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" ) + 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 +403,13 @@ 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)", + ) + 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 @@ -252,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 ( @@ -259,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) @@ -267,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): @@ -324,7 +548,23 @@ def can_write(): ): raise ValueError("Panel lease ended before dispatch; no command sent") - lights = Lights(call, can_write) + 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()) @@ -337,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() @@ -344,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]: @@ -370,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) @@ -381,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..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' || { @@ -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/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/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/index.html b/browser/index.html index 287f4d2..de5450d 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1 +1 @@ -

