diff --git a/README.md b/README.md index ab20aeb..f082c4d 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,61 @@ -# SCR room touch panel - -Touch controls for the LESA Smart Conference Room. The original application and -the newer Beta client have separate entrypoints and share the existing room -services. Beta does not yet cover every original feature. - -## Choose the version - -| Version | Source and setup | -|---|---| -| Original application | [main.py](main.py); requires the installed custom ROS clients and room environment | -| Earlier Beta baseline | [app/scr_touch_panel.py](app/scr_touch_panel.py) on `main`; installed launcher expects Ubuntu/Python 2.7 | -| Fresh Pi OS and current Beta | [Branch README](https://github.com/LESA-RPI/scr.touch_panel/blob/experiment/fresh-pi-setup-20261013/README.md); Python 3 setup and newer interface under review in [PR #1](https://github.com/LESA-RPI/scr.touch_panel/pull/1) | - -The newer interface calls the original application **Legacy**. This baseline -uses **Original**. Check the cabinet's installed release before maintenance; -the default branch does not identify what is running on a device. - -## Try the baseline without room hardware - -Use desktop Python 3.11 or newer with Tk available. `python -m tkinter` checks -Tk. On Linux, install distribution Tk and virtual-environment packages if needed. - -```sh -git clone https://github.com/LESA-RPI/scr.touch_panel.git -cd scr.touch_panel -python3 -m venv .venv -. .venv/bin/activate -python -m pip install -r app/requirements.txt -python -B app/scr_touch_panel.py --windowed --duration 600 --theme light -``` - -On Windows, use `python` in place of `python3` and activate with -`.venv\Scripts\Activate.ps1`. This preview moves no room devices and starts no -recording. Desktop requirements are separate from the installed Ubuntu stack. -For fresh Raspberry Pi OS, use the branch guide above rather than this launcher. - -## Operate the baseline - -In this version, Settings returns to Original controls and its touch strip -returns to Beta. Credits are in Settings. Lighting sliders send automatically; -confirmation means controller readback, not measured physical brightness. -Opening Original or exiting waits for restoration of Beta's starting light scene. -If the result is uncertain, stop changing lights and coordinate with the room host. - -Practice annotations have `recording: false`; they do not start a sensor recorder. -System reports panel readings and advertised services, not physical sensor health. -A stationary two-second press leaves the black screen; its backlight stays on. -The updated branch has different navigation and additional features. - -## Install, maintain or restore - -| Need | Reference | -|---|---| -| Connect lighting and establish ownership | [Connection checks](WORKFLOWS.md#before-connected-control) | -| Switch modes or recover an uncertain result | [Switching and recovery](WORKFLOWS.md#switching-and-recovery) | -| Set up autostart or roll back | [Startup and rollback](WORKFLOWS.md#startup-and-rollback) | -| Restore hardware or provision a replacement | [Backup and rebuild](WORKFLOWS.md#backup-and-rebuild) | -| Add a profile or experiment adapter | [Interface contracts](docs/interface.md) | - -The installed launcher expects the older Python 2.7/Tk/Pillow environment and -custom ROS workspace. Desktop Python 3 preview support does not migrate that -launcher. Existing room servers remain separate dependencies; upgrading the -panel does not require replacing them. Coordinate ownership before live control. - -Git preserves source, not a complete system. Obtain private configuration and -recovery bundles from the room custodian. Check manifests, scope and read-error -records: an incomplete image is not an exact clone. Keep captures, credentials, -participant data and host settings outside this public repository. - -Run `python -m pytest tests` with pytest installed. Baseline checks cover profiles, -lighting restoration and uncertain results. Earlier bounded cabinet trials do not -qualify another release, replacement hardware or prolonged reliability. Preserve -release hashes and original contributors when cleaning public documentation. - -General panel code belongs here; Project-X owns its optional recorder integration. -Browser/tablet clients are planned, not implemented. - -Beta: **Diyanko Bhowmik**, with Codex assistance. Original contributors remain in -Git history. The supplied LESA logo is retained unchanged. +# SCR room browser interface + +A browser companion to the touch panel, with the same Home controls, one Lighting +workspace (White light, Scenes, Programmes and Channels), instruction workflow, +preferences, guide and help. The panel owns +all commands. Diagnostics remain observation-only. Legacy opens the original +application on the cabinet; its original actuator controls stay there. + +Browser control is opt-in: pass `--panel-control-socket` to `browser/serve.py`, or +set `SCR_WEB_CONTROL_SOCKET` for `browser/start.sh`, pointing to the panel’s +private socket. Enable its matching `--control-socket` / `SCR_CONTROL_SOCKET`. +Both run as the same user. Keep the explicit bind address and client-subnet +allowlist. Everyone on that configured subnet can use controls; there is no +separate staff login. Without a bridge, controls disable and guide/help remain +available. No browser hardware driver is imported. + +Commands use the existing panel queue, require a fresh revision and are never +automatically retried. Confirmed readback alone selects Lights on/off. Pending, +uncertain or disconnected states clear that selection. Running instructions block +lighting from both interfaces. All three original blind actions are visible; +movement has a saved Locked/Unlocked setting, locked by default, and requires a connected +controller. None is installed in the new interface. Gradient, Sun and Circ remain visible with +disconnected Apply/Start controls until their adapters are integrated. Empty Legacy +placeholders and the redundant Enable Sliders gate are omitted. Experiment mode does not +start a recorder. Service-start/restart references are monospace text with Copy, +never executable buttons. Each documented step explains the computer, purpose +and expected result, with its own Copy button and a Copy all option for the topic. +Dark backgrounds stay neutral gray; controls, weather and status use restrained +teal, warm and green/amber accents shared with the panel. +Weather readings use aligned labels and values, with dashes for missing readings. +Earlier forecasts are dated and hide current-day details until fresh data returns. +Screen & controls can preview the touch panel's screen saver. The idle timer and +preview open the same screen; its first touch returns to Home without activating +a control. Date & weather contains clock-format and temperature preferences. +System & help can restart or shut down the touch panel when its power controls +are available. Each request requires a confirmation and an acknowledgement that +recordings and experiments have stopped. The panel validates ownership, restores +lighting and saves its state before the operating-system action. These controls +do not stop other room computers or recorders. A restart or shutdown disconnects +this website; unknown requests are never retried automatically. +Desktop layouts place white-light controls side by side. Landscape tablets keep +large controls; phones put lights first with expandable blinds and a compact +Room tools section selector. Numeric light +values can be typed or adjusted with sliders. Enter or leaving a valid field +applies the selection; Escape cancels. Empty or out-of-range values never dispatch. +Polling preserves an edit, and the panel still validates its original revision. +The website bundles the same Cantarell family used by the panel, so its layout +does not depend on a visitor's installed fonts. Unmodified font files retain +their [SIL Open Font License](browser/fonts/LICENSE). + +Run `python -m pytest tests`. HTTP tests use loopback mock services and cover Host, +Origin, nonce, subnet, bounded command bodies, static allowlists and read-only +diagnostics. Browser interaction and native Pi qualification are separate checks. +Do not merge the draft experiment branch without researcher approval. + +Panel-owned guide/help, design roles and Lucide paths are exported by +`app/export_room_content.py` in the panel branch to `browser/room-content.json`. +Live controller state supplies the same content and preferences. Preserve source +provenance when rebuilding browser assets. The artwork notices remain in +[browser/icons/LICENSE](browser/icons/LICENSE). Original contributors are retained +in repository history. The interface is maintained by Diyanko Bhowmik. diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 3c6b44c..b396bd0 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -114,6 +114,121 @@ Theme times use the panel process's configured timezone. Auto changes wait for appearance, date/time, schedule and screen rows. Font and study defaults are launch configuration; no separate settings service is needed. +## Read-only browser status + +The browser status server is independent of Tk, ROS client bindings and Project-X. +It uses Python 3.11's standard library and the existing LESA logo. It imports no +control adapter and exposes no actuator, recorder or maintenance command route. + +```sh +# Local-only review; no room queries. +python -B browser/serve.py + +# Room host: substitute verified private interface/master addresses. +python -B browser/serve.py --bind PANEL_PRIVATE_IP --allow-clients ROOM_SUBNET_CIDR --port 8768 \ + --label "Cabinet Pi" --master http://ROOM_PRIVATE_IP:11311 \ + --weather-latitude 42.7284 --weather-longitude -73.6918 +``` + +The server binds one explicit private IPv4 address and accepts socket peers only +from the explicit trusted client subnet. Non-loopback hosting requires that subnet; +forwarding headers do not grant access. Open the +printed literal-IP URL; Host/Origin checks intentionally refuse other hostnames. +No router forwarding, firewall changes or public hosting are part of this setup. +There is no sign-in: allowed subnet clients can view the limited status payload. +The touch-panel Pi hosts this trial, accessible from the room's SCR_AP LAN. Keep +it on that trusted network. A subnet cannot identify a Wi-Fi SSID: other devices +on the same wired LAN also have access. Router guest isolation remains the network +owner's responsibility. This does not secure existing ROS services. + +One sampler refreshes host resources and the ROS master's `getSystemState` graph +every 30 seconds, independent of visitor count. It registers no node, starts no +producer and calls no device services. Omit `--master` for host-only operation. +The graph response is capped at 256 KiB; its subprocess ends after four seconds. +HTTP requests have a four-second absolute deadline and a two-second idle timeout. +Requests serve only an in-memory snapshot and explicitly listed static assets. +No directory listing, arbitrary file access, CORS or write endpoints are provided. + +Overview has no technical status; `/diagnostics` shows host resources and room +registrations, and `/guide` explains use and failures. They share fixed navigation, +the same cached API and a white surface. There are no appearance switches or storage +cards. A diagnostic view is an audience choice, not a staff permission boundary. +Host readings describe the server computer, not every room computer. Diagnostics +names that source explicitly; a host-only preview is labelled as such. +The supplied main logo is unchanged; its blank margins are trimmed only by layout. +The clock +shows Troy's America/New_York time using the viewing device's clock. Service cards +show advertised/partly advertised/not advertised registrations, not physical health +or recording state. After failure, previous observations retain their original +age. Samples older than 90 seconds are labelled stale. Visible pages poll every +30 seconds; hidden tabs pause requests. Snapshot data includes no raw recordings, +participant information, service-provider names, paths or private master address. + +### Independent website startup + +Home matches the cabinet's everyday layout, with stacked lights/blinds labels +beside time and weather. All four website action buttons are disabled and have +no handlers. Advanced · diagnostics opens the existing read-only diagnostics; +Demo & guide opens the room guide. There are no new actuator endpoints or roles. + +Retain each published release under `~/scr-browser-status/releases/COMMIT` and +point `~/scr-browser-status/current` to the selected release. This is separate from +`~/scr-panel/current`. Stop an earlier standalone website only after checking its +exact command; never kill the panel or room processes to free the website port. + +Create `~/.config/scr-browser-status/deployment.env` with mode 0600 and verified +values. Keep deployment addresses outside this public repository: + +```ini +SCR_WEB_BIND=PANEL_PRIVATE_IP +SCR_WEB_CLIENTS=ROOM_SUBNET_CIDR +SCR_WEB_PORT=8768 +SCR_WEB_LABEL="Cabinet Pi" +SCR_WEB_MASTER=http://ROOM_PRIVATE_IP:11311 +SCR_WEB_LATITUDE=42.7284 +SCR_WEB_LONGITUDE=-73.6918 +``` + +Install the included unit and enable this user to run services before login: + +```sh +mkdir -p ~/.config/systemd/user +cp ~/scr-browser-status/current/browser/scr-browser-status.service ~/.config/systemd/user/ +loginctl enable-linger "$USER" +systemctl --user daemon-reload +systemctl --user enable --now scr-browser-status.service +systemctl --user status scr-browser-status.service +``` + +Enabling linger may require administrator approval on another installation. +Confirm `loginctl show-user "$USER" -p Linger` reports `yes`. The existing panel +desktop autostart stays intact. The website retries after 15 seconds if its bind +address is not ready during boot; missing source data is reported as unavailable. +Initial weather/graph sampling can delay HTTP readiness by about 14 seconds. +If DHCP changes the Pi address, update the private environment and restart this +website service. Do not change room network settings for this application. + +Use `systemctl --user restart scr-browser-status.service` for a website-only reload +and `journalctl --user -u scr-browser-status.service -n 30` for errors. Roll back with +`systemctl --user disable --now scr-browser-status.service`; an earlier release can +be selected by repointing only the website's `current` symlink. Disable linger only +if this installation enabled it and no other user services need it. No lighting +restoration is needed because the website owns no lights. Verify a service restart +first; qualify a physical boot later when someone is in the room. + +Outdoor weather reuses `app/weather.py`, the panel's validated Open-Meteo helper. +The sampler refreshes at most once per 15 minutes, with an eight-second subprocess +deadline and 32 KiB response limit. Failures retain the last successful values +and their observation age; old or failed data is labelled and its daylight bar +hidden. Coordinates above describe Troy, not indoor room conditions. No generated +values are substituted when weather is unavailable. It is independent of room ROS. + +Keep presentation dependent on `docs/interface.md`, not on ROS service paths. The +server's host, graph and weather functions are the current source boundary; the +browser uses the normalized versioned JSON and freshness fields. A future backend +can replace a source behind that boundary without rewriting each view. Add an +adapter only for a real new source; a plugin framework is not needed for this trial. + ## Backup and rebuild The Git repository preserves panel source. It does not preserve the installed diff --git a/app/weather.py b/app/weather.py new file mode 100644 index 0000000..e3a08d8 --- /dev/null +++ b/app/weather.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +"""Optional Open-Meteo model weather. Called as a bounded background helper.""" +from __future__ import print_function +import argparse +import datetime +import json +import math +try: + from urllib.request import urlopen + from urllib.parse import urlencode +except ImportError: + from urllib2 import urlopen + from urllib import urlencode + + +def summary(data): + current, daily = data['current'], data['daily'] + code = current['weather_code'] + descriptions = {0: 'Clear', 1: 'Mostly clear', 2: 'Partly cloudy', 3: 'Overcast', + 45: 'Fog', 48: 'Freezing fog'} + condition = descriptions.get(code) + if condition is None: + if code in (51, 53, 55, 56, 57): + condition = 'Drizzle' + elif code in (61, 63, 65, 66, 67, 80, 81, 82): + condition = 'Rain' + elif code in (71, 73, 75, 77, 85, 86): + condition = 'Snow' + elif code in (95, 96, 99): + condition = 'Thunderstorms' + else: + condition = 'Conditions unavailable' + value = {'temperature_f': current['temperature_2m'], 'condition': condition, + 'code': code, 'sunrise': daily['sunrise'][0], 'sunset': daily['sunset'][0], + 'model_time': current['time']} + if current.get('apparent_temperature') is not None: + value['feels_like_f'] = current['apparent_temperature'] + for source, key in [('temperature_2m_max', 'high_f'), ('temperature_2m_min', 'low_f'), + ('precipitation_probability_max', 'rain_chance')]: + if daily.get(source) and daily[source][0] is not None: + value[key] = daily[source][0] + return validate(value) + + +def local_time(text): + return datetime.datetime.strptime(text[:16], '%Y-%m-%dT%H:%M') + + +def validate(value): + if not isinstance(value, dict): + raise ValueError('Invalid weather reply') + temperature = float(value['temperature_f']) + if math.isnan(temperature) or math.isinf(temperature): + raise ValueError('Invalid weather temperature') + text_types = (str,) if str is type(u'') else (basestring,) + for key in ('condition', 'sunrise', 'sunset', 'model_time'): + if not isinstance(value[key], text_types) or not 1 <= len(value[key]) <= 100: + raise ValueError('Invalid weather text') + for key in ('sunrise', 'sunset', 'model_time'): + local_time(value[key]) + if local_time(value['sunrise']) >= local_time(value['sunset']): + raise ValueError('Invalid daylight interval') + code = int(value['code']) + result = {'temperature_f': temperature, 'temperature_c': (temperature - 32) * 5.0 / 9, + 'condition': value['condition'], 'code': code, 'sunrise': value['sunrise'], + 'sunset': value['sunset'], 'model_time': value['model_time']} + for key in ('feels_like_f', 'high_f', 'low_f', 'rain_chance'): + if value.get(key) is None: + continue + extra = float(value[key]) + if math.isnan(extra) or math.isinf(extra) or isinstance(value[key], bool): + raise ValueError('Invalid weather detail') + if key == 'rain_chance' and not 0 <= extra <= 100: + raise ValueError('Invalid precipitation probability') + result[key] = extra + if key.endswith('_f'): + result[key[:-1] + 'c'] = (extra - 32) * 5.0 / 9 + if 'high_f' in result and 'low_f' in result and result['low_f'] > result['high_f']: + raise ValueError('Invalid daily temperature range') + return result + + +def details(value, unit='f', earlier=False): + """Compact optional forecast details; never present old daily values as today's.""" + if earlier: + return 'Earlier weather: ' + value['model_time'].replace('T', ' ') + lines = [] + if 'feels_like_' + unit in value: + lines.append(u'Feels like %.0f° %s' % (value['feels_like_' + unit], unit.upper())) + daily = [] + for label, key in [('High', 'high_'), ('Low', 'low_')]: + if key + unit in value: + daily.append(label + (u' %.0f°' % value[key + unit])) + if 'rain_chance' in value: + daily.append('Rain today %.0f%%' % value['rain_chance']) + if daily: + lines.append(u' · '.join(daily)) + return '\n'.join(lines) + + +def daylight(value, now): + rise, setting = local_time(value['sunrise']), local_time(value['sunset']) + if now.date() != rise.date(): + return None + fraction = max(0, min(1, (now - rise).total_seconds() / (setting - rise).total_seconds())) + if now >= setting: + label = 'After sunset · night' + else: + target, event = (rise, 'rises') if now < rise else (setting, 'sets') + minutes = int(math.ceil((target - now).total_seconds() / 60)) + hours, minutes = divmod(minutes, 60) + label = 'Sun %s in %s' % (event, ('%d h %02d min' % (hours, minutes)) if hours else '%d min' % minutes) + return {'fraction': fraction, 'label': label, 'now': now.strftime('%H:%M'), + 'sunrise': rise.strftime('%H:%M'), 'sunset': setting.strftime('%H:%M')} + + +def fetch(latitude, longitude, timezone): + url = 'https://api.open-meteo.com/v1/forecast?' + urlencode({ + 'latitude': latitude, 'longitude': longitude, 'timezone': timezone, + 'current': 'temperature_2m,weather_code,apparent_temperature', + 'daily': 'sunrise,sunset,temperature_2m_max,temperature_2m_min,precipitation_probability_max', + 'temperature_unit': 'fahrenheit', 'forecast_days': 1}) + response = urlopen(url, timeout=5) + try: + body = response.read(32769) + finally: + response.close() + if len(body) > 32768: + raise ValueError('Weather response too large') + return summary(json.loads(body.decode('utf-8'))) + + +if __name__ == '__main__': + 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() + try: + print(json.dumps(fetch(args.latitude, args.longitude, args.timezone))) + except Exception: + print(json.dumps({'error': 'Weather unavailable; the room controls still work.'})) diff --git a/browser/controller.py b/browser/controller.py new file mode 100644 index 0000000..af4ee19 --- /dev/null +++ b/browser/controller.py @@ -0,0 +1,21 @@ +"""Trusted-network gateway to the panel's private socket, never a second driver.""" +import json +import socket + + +class Controller: + def __init__(self,path): self.path=path + + def exchange(self,value): + raw=json.dumps(value,allow_nan=False).encode()+b'\n' + if len(raw)>4096:raise ValueError('Command too large') + with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as client: + client.settimeout(2.8) + client.connect(self.path);client.sendall(raw) + with client.makefile('rb') as stream:reply=stream.readline(65537) + if len(reply)>65536 or not reply.endswith(b'\n'):raise ValueError('Invalid panel reply') + return json.loads(reply) + + def read(self): + try:return self.exchange({'op':'read'}) + except (OSError,ValueError):return {'available':False,'detail':'Panel controller unavailable. Controls are disabled.'} diff --git a/browser/design.js b/browser/design.js new file mode 100644 index 0000000..32f7d55 --- /dev/null +++ b/browser/design.js @@ -0,0 +1,8 @@ +/* Role names and values are supplied by the panel's design_tokens.py. */ +window.SCRDesign={apply(palette,theme){ + if(!palette)return; + for(const [key,value] of Object.entries(palette))document.documentElement.style.setProperty('--'+key.replaceAll('_','-'),value); + for(const [alias,key] of [['soft','control'],['line','border']])document.documentElement.style.setProperty('--'+alias,palette[key]); + document.documentElement.style.colorScheme=theme==='dark'?'dark':'light'; + document.documentElement.dataset.theme=theme; +}}; diff --git a/browser/diagnostics.html b/browser/diagnostics.html new file mode 100644 index 0000000..3414c43 --- /dev/null +++ b/browser/diagnostics.html @@ -0,0 +1,19 @@ + +Smart Conference Room · Diagnostics + +
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Beta

Diagnostics for room staff

Read-only diagnostics
+ +

What is connected, and what is known?

These readings help separate a page problem, a room connection problem and a host warning. They do not establish physical device health.

+

Checking the data source…

+

Browser → status host

Checking…

Waiting for a dated snapshot.

Status host → room computer

Not checked

Service registrations only.

+
+

Lighting

Not checked

Required lighting interfaces

+

Blinds

Not checked

Required blind interfaces

+

Environment

Not checked

Temperature and humidity interfaces

+

Time-of-flight

Not checked

Distance sensing interfaces

+

Status host

—

Actual resources on the computer hosting this website. No generated demonstration readings.

+

Memory available

—

Not checked

+

Processor temperature

—

Host processor only

+

Power

Not checked

Uptime unavailable

+

No commands run from this page. Coordinate with the room owner before recovery or maintenance.

+
diff --git a/browser/fonts.css b/browser/fonts.css new file mode 100644 index 0000000..b637a47 --- /dev/null +++ b/browser/fonts.css @@ -0,0 +1,3 @@ +/* Unmodified Cantarell, SIL Open Font License 1.1; see fonts/LICENSE. */ +@font-face{font-family:Cantarell;src:url('/fonts/Cantarell-Regular.otf') format('opentype');font-style:normal;font-weight:400 500;font-display:swap} +@font-face{font-family:Cantarell;src:url('/fonts/Cantarell-Bold.otf') format('opentype');font-style:normal;font-weight:600 900;font-display:swap} diff --git a/browser/fonts/Cantarell-Bold.otf b/browser/fonts/Cantarell-Bold.otf new file mode 100644 index 0000000..cde740a Binary files /dev/null and b/browser/fonts/Cantarell-Bold.otf differ diff --git a/browser/fonts/Cantarell-Regular.otf b/browser/fonts/Cantarell-Regular.otf new file mode 100644 index 0000000..3451736 Binary files /dev/null and b/browser/fonts/Cantarell-Regular.otf differ diff --git a/browser/fonts/LICENSE b/browser/fonts/LICENSE new file mode 100644 index 0000000..8de41e9 --- /dev/null +++ b/browser/fonts/LICENSE @@ -0,0 +1,124 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: Cantarell +Source: https://gitlab.gnome.org/GNOME/cantarell-fonts + +Files: * +Copyright: + Copyright © 2009-2011, Understanding Limited + Copyright © 2010-2011, Jakub Steiner +License: OFL-1.1 + +Files: debian/* +Copyright: + © 2011, Nicolas Spalinger + © 2011-2014, Christian Perrier + © 2012-2015, Fabian Greffrath +License: OFL-1.1 + +Files: appstream/* +Copyright: 2018 Nikolaus Waxweiler +License: CC0-1.0 + +License: OFL-1.1 + This Font Software is licensed under the SIL Open Font License, Version 1.1. + This license is copied below, and is also available with a FAQ at: + http://scripts.sil.org/OFL + . + ----------------------------------------------------------- + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + ----------------------------------------------------------- + . + PREAMBLE + The goals of the Open Font License (OFL) are to stimulate worldwide + development of collaborative font projects, to support the font creation + efforts of academic and linguistic communities, and to provide a free and + open framework in which fonts may be shared and improved in partnership + with others. + . + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. The + fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply + to any document created using the fonts or their derivatives. + . + DEFINITIONS + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. This may + include source files, build scripts and documentation. + . + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + . + "Original Version" refers to the collection of Font Software components as + distributed by the Copyright Holder(s). + . + "Modified Version" refers to any derivative made by adding to, deleting, + or substituting -- in part or in whole -- any of the components of the + Original Version, by changing formats or by porting the Font Software to a + new environment. + . + "Author" refers to any designer, engineer, programmer, technical + writer or other person who contributed to the Font Software. + . + PERMISSION & CONDITIONS + Permission is hereby granted, free of charge, to any person obtaining + a copy of the Font Software, to use, study, copy, merge, embed, modify, + redistribute, and sell modified and unmodified copies of the Font + Software, subject to the following conditions: + . + 1) Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + . + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + . + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + . + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + . + 5) The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + . + TERMINATION + This license becomes null and void if any of the above conditions are + not met. + . + DISCLAIMER + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. + +License: CC0-1.0 + To the extent possible under law, the author(s) have dedicated all copyright + and related and neighboring rights to this software to the public domain + worldwide. This software is distributed without any warranty. + . + You should have received a copy of the CC0 Public Domain Dedication along with + this software. If not, see . + . + On Debian systems, the complete text of the CC0 1.0 Universal license can be + found in "/usr/share/common-licenses/CC0-1.0". + diff --git a/browser/guide.html b/browser/guide.html new file mode 100644 index 0000000..bfee5d1 --- /dev/null +++ b/browser/guide.html @@ -0,0 +1 @@ +Smart Conference Room
LESA

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/browser/icons/LICENSE b/browser/icons/LICENSE new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/browser/icons/LICENSE @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/browser/icons/book-open.svg b/browser/icons/book-open.svg new file mode 100644 index 0000000..25dbd7a --- /dev/null +++ b/browser/icons/book-open.svg @@ -0,0 +1,14 @@ + + + + diff --git a/browser/icons/circle-question-mark-light.png b/browser/icons/circle-question-mark-light.png new file mode 100644 index 0000000..d9dc143 Binary files /dev/null and b/browser/icons/circle-question-mark-light.png differ diff --git a/browser/icons/cloud-fog-light.png b/browser/icons/cloud-fog-light.png new file mode 100644 index 0000000..e2a035c Binary files /dev/null and b/browser/icons/cloud-fog-light.png differ diff --git a/browser/icons/cloud-light.png b/browser/icons/cloud-light.png new file mode 100644 index 0000000..4964b2e Binary files /dev/null and b/browser/icons/cloud-light.png differ diff --git a/browser/icons/cloud-lightning-light.png b/browser/icons/cloud-lightning-light.png new file mode 100644 index 0000000..83e16f6 Binary files /dev/null and b/browser/icons/cloud-lightning-light.png differ diff --git a/browser/icons/cloud-moon-light.png b/browser/icons/cloud-moon-light.png new file mode 100644 index 0000000..e3db8cb Binary files /dev/null and b/browser/icons/cloud-moon-light.png differ diff --git a/browser/icons/cloud-rain-light.png b/browser/icons/cloud-rain-light.png new file mode 100644 index 0000000..561db4f Binary files /dev/null and b/browser/icons/cloud-rain-light.png differ diff --git a/browser/icons/cloud-snow-light.png b/browser/icons/cloud-snow-light.png new file mode 100644 index 0000000..02df3bd Binary files /dev/null and b/browser/icons/cloud-snow-light.png differ diff --git a/browser/icons/cloud-sun-light.png b/browser/icons/cloud-sun-light.png new file mode 100644 index 0000000..24f468e Binary files /dev/null and b/browser/icons/cloud-sun-light.png differ diff --git a/browser/icons/lightbulb-off.svg b/browser/icons/lightbulb-off.svg new file mode 100644 index 0000000..58ed144 --- /dev/null +++ b/browser/icons/lightbulb-off.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/browser/icons/lightbulb.svg b/browser/icons/lightbulb.svg new file mode 100644 index 0000000..fcb3c40 --- /dev/null +++ b/browser/icons/lightbulb.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/browser/icons/moon-light.png b/browser/icons/moon-light.png new file mode 100644 index 0000000..54c2ca8 Binary files /dev/null and b/browser/icons/moon-light.png differ diff --git a/browser/icons/sliders-horizontal.svg b/browser/icons/sliders-horizontal.svg new file mode 100644 index 0000000..51b938d --- /dev/null +++ b/browser/icons/sliders-horizontal.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/browser/icons/source.json b/browser/icons/source.json new file mode 100644 index 0000000..2e19032 --- /dev/null +++ b/browser/icons/source.json @@ -0,0 +1,48 @@ +{ + "project": "Lucide", + "revision": "7b0196e249ca09ca27c584c497aca9a8cab8a7f5", + "source": "https://github.com/lucide-icons/lucide", + "license": "ISC; includes Feather notices", + "icons": [ + "house", + "lightbulb", + "lightbulb-off", + "blinds", + "sliders-horizontal", + "flask-conical", + "activity", + "clock", + "circle-check", + "circle-slash", + "layers", + "book-open", + "info", + "sun-moon", + "calendar-days", + "monitor", + "arrow-left", + "external-link", + "chevron-left", + "chevron-right", + "sun", + "moon", + "cloud", + "cloud-sun", + "cloud-moon", + "cloud-rain", + "cloud-snow", + "cloud-lightning", + "cloud-fog", + "sunrise", + "sunset", + "droplets", + "thermometer", + "play", + "pause", + "monitor-off", + "terminal", + "eye-off", + "circle-question-mark" + ], + "derivatives": "96px alpha masks for theme-coloured controls; 288px weather artwork uses Lucide paths with a thin outline and restrained day/night colours. The cloud-moon crescent is repositioned to meet the foreground cloud. No gradients or halos." +} diff --git a/browser/icons/sun-light.png b/browser/icons/sun-light.png new file mode 100644 index 0000000..5084ea4 Binary files /dev/null and b/browser/icons/sun-light.png differ diff --git a/browser/index.html b/browser/index.html new file mode 100644 index 0000000..bfee5d1 --- /dev/null +++ b/browser/index.html @@ -0,0 +1 @@ +Smart Conference Room
LESA

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/browser/lesa-logo.jpeg b/browser/lesa-logo.jpeg new file mode 100644 index 0000000..cbb4e83 Binary files /dev/null and b/browser/lesa-logo.jpeg differ diff --git a/browser/room-content.json b/browser/room-content.json new file mode 100644 index 0000000..8d74a27 --- /dev/null +++ b/browser/room-content.json @@ -0,0 +1,1410 @@ +{ + "version": 1, + "app_version": "0.2.0", + "icons": { + "tools": [ + { + "tag": "path", + "attributes": { + "d": "M10 5H3" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 19H3" + } + }, + { + "tag": "path", + "attributes": { + "d": "M14 3v4" + } + }, + { + "tag": "path", + "attributes": { + "d": "M16 17v4" + } + }, + { + "tag": "path", + "attributes": { + "d": "M21 12h-9" + } + }, + { + "tag": "path", + "attributes": { + "d": "M21 19h-5" + } + }, + { + "tag": "path", + "attributes": { + "d": "M21 5h-7" + } + }, + { + "tag": "path", + "attributes": { + "d": "M8 10v4" + } + }, + { + "tag": "path", + "attributes": { + "d": "M8 12H3" + } + } + ], + "home": [ + { + "tag": "path", + "attributes": { + "d": "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" + } + }, + { + "tag": "path", + "attributes": { + "d": "M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" + } + } + ], + "lighting": [ + { + "tag": "path", + "attributes": { + "d": "M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" + } + }, + { + "tag": "path", + "attributes": { + "d": "M9 18h6" + } + }, + { + "tag": "path", + "attributes": { + "d": "M10 22h4" + } + } + ], + "white": [ + { + "tag": "path", + "attributes": { + "d": "M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" + } + }, + { + "tag": "path", + "attributes": { + "d": "M9 18h6" + } + }, + { + "tag": "path", + "attributes": { + "d": "M10 22h4" + } + } + ], + "off": [ + { + "tag": "path", + "attributes": { + "d": "M16.8 11.2c.8-.9 1.2-2 1.2-3.2a6 6 0 0 0-9.3-5" + } + }, + { + "tag": "path", + "attributes": { + "d": "m2 2 20 20" + } + }, + { + "tag": "path", + "attributes": { + "d": "M6.3 6.3a4.67 4.67 0 0 0 1.2 5.2c.7.7 1.3 1.5 1.5 2.5" + } + }, + { + "tag": "path", + "attributes": { + "d": "M9 18h6" + } + }, + { + "tag": "path", + "attributes": { + "d": "M10 22h4" + } + } + ], + "scenes": [ + { + "tag": "path", + "attributes": { + "d": "M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" + } + }, + { + "tag": "path", + "attributes": { + "d": "M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" + } + }, + { + "tag": "path", + "attributes": { + "d": "M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" + } + } + ], + "experiment": [ + { + "tag": "path", + "attributes": { + "d": "M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2" + } + }, + { + "tag": "path", + "attributes": { + "d": "M6.453 15h11.094" + } + }, + { + "tag": "path", + "attributes": { + "d": "M8.5 2h7" + } + } + ], + "appearance": [ + { + "tag": "path", + "attributes": { + "d": "M12 2v2" + } + }, + { + "tag": "path", + "attributes": { + "d": "M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715" + } + }, + { + "tag": "path", + "attributes": { + "d": "M16 12a4 4 0 0 0-4-4" + } + }, + { + "tag": "path", + "attributes": { + "d": "m19 5-1.256 1.256" + } + }, + { + "tag": "path", + "attributes": { + "d": "M20 12h2" + } + } + ], + "clock": [ + { + "tag": "path", + "attributes": { + "d": "M8 2v3" + } + }, + { + "tag": "path", + "attributes": { + "d": "M16 2v3" + } + }, + { + "tag": "rect", + "attributes": { + "x": "3", + "y": "3", + "width": "18", + "height": "18", + "rx": "2" + } + }, + { + "tag": "path", + "attributes": { + "d": "M3 9h18" + } + }, + { + "tag": "path", + "attributes": { + "d": "M8 13h.01" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 13h.01" + } + }, + { + "tag": "path", + "attributes": { + "d": "M16 13h.01" + } + }, + { + "tag": "path", + "attributes": { + "d": "M8 17h.01" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 17h.01" + } + }, + { + "tag": "path", + "attributes": { + "d": "M16 17h.01" + } + } + ], + "screen": [ + { + "tag": "rect", + "attributes": { + "width": "20", + "height": "14", + "x": "2", + "y": "3", + "rx": "2" + } + }, + { + "tag": "line", + "attributes": { + "x1": "8", + "x2": "16", + "y1": "21", + "y2": "21" + } + }, + { + "tag": "line", + "attributes": { + "x1": "12", + "x2": "12", + "y1": "17", + "y2": "21" + } + } + ], + "system": [ + { + "tag": "path", + "attributes": { + "d": "M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" + } + } + ], + "about": [ + { + "tag": "circle", + "attributes": { + "cx": "12", + "cy": "12", + "r": "10" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 16v-4" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 8h.01" + } + } + ], + "guide": [ + { + "tag": "path", + "attributes": { + "d": "M12 5v16" + } + }, + { + "tag": "path", + "attributes": { + "d": "M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z" + } + } + ], + "legacy": [ + { + "tag": "path", + "attributes": { + "d": "M15 3h6v6" + } + }, + { + "tag": "path", + "attributes": { + "d": "M10 14 21 3" + } + }, + { + "tag": "path", + "attributes": { + "d": "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" + } + } + ], + "weather-sun": [ + { + "tag": "circle", + "attributes": { + "cx": "12", + "cy": "12", + "r": "4" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 2v2" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 20v2" + } + }, + { + "tag": "path", + "attributes": { + "d": "m4.93 4.93 1.41 1.41" + } + }, + { + "tag": "path", + "attributes": { + "d": "m17.66 17.66 1.41 1.41" + } + }, + { + "tag": "path", + "attributes": { + "d": "M2 12h2" + } + }, + { + "tag": "path", + "attributes": { + "d": "M20 12h2" + } + }, + { + "tag": "path", + "attributes": { + "d": "m6.34 17.66-1.41 1.41" + } + }, + { + "tag": "path", + "attributes": { + "d": "m19.07 4.93-1.41 1.41" + } + } + ], + "weather-moon": [ + { + "tag": "path", + "attributes": { + "d": "M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" + } + } + ], + "weather-cloud-sun": [ + { + "tag": "path", + "attributes": { + "d": "M12 2v2" + } + }, + { + "tag": "path", + "attributes": { + "d": "m4.93 4.93 1.41 1.41" + } + }, + { + "tag": "path", + "attributes": { + "d": "M20 12h2" + } + }, + { + "tag": "path", + "attributes": { + "d": "m19.07 4.93-1.41 1.41" + } + }, + { + "tag": "path", + "attributes": { + "d": "M15.947 12.65a4 4 0 0 0-5.925-4.128" + } + }, + { + "tag": "path", + "attributes": { + "d": "M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z" + } + } + ], + "weather-cloud-moon": [ + { + "tag": "path", + "attributes": { + "d": "M13 16a3 3 0 0 1 0 6H7a5 5 0 1 1 4.9-6z" + } + }, + { + "tag": "path", + "attributes": { + "transform": "translate(-2.4 3.5)", + "d": "M18.376 14.512a6 6 0 0 0 3.461-4.127c.148-.625-.659-.97-1.248-.714a4 4 0 0 1-5.259-5.26c.255-.589-.09-1.395-.716-1.248a6 6 0 0 0-4.594 5.36" + } + } + ], + "weather-cloud": [ + { + "tag": "path", + "attributes": { + "d": "M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" + } + } + ], + "weather-cloud-rain": [ + { + "tag": "path", + "attributes": { + "d": "M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" + } + }, + { + "tag": "path", + "attributes": { + "d": "M16 14v6" + } + }, + { + "tag": "path", + "attributes": { + "d": "M8 14v6" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 16v6" + } + } + ], + "weather-cloud-snow": [ + { + "tag": "path", + "attributes": { + "d": "M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" + } + }, + { + "tag": "path", + "attributes": { + "d": "M8 15h.01" + } + }, + { + "tag": "path", + "attributes": { + "d": "M8 19h.01" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 17h.01" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 21h.01" + } + }, + { + "tag": "path", + "attributes": { + "d": "M16 15h.01" + } + }, + { + "tag": "path", + "attributes": { + "d": "M16 19h.01" + } + } + ], + "weather-cloud-lightning": [ + { + "tag": "path", + "attributes": { + "d": "M6 16.326A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 .5 8.973" + } + }, + { + "tag": "path", + "attributes": { + "d": "m13 12-3 5h4l-3 5" + } + } + ], + "weather-cloud-fog": [ + { + "tag": "path", + "attributes": { + "d": "M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" + } + }, + { + "tag": "path", + "attributes": { + "d": "M16 17H7" + } + }, + { + "tag": "path", + "attributes": { + "d": "M17 21H9" + } + } + ], + "weather-circle-question-mark": [ + { + "tag": "circle", + "attributes": { + "cx": "12", + "cy": "12", + "r": "10" + } + }, + { + "tag": "path", + "attributes": { + "d": "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + } + }, + { + "tag": "path", + "attributes": { + "d": "M12 17h.01" + } + } + ] + }, + "themes": { + "light": { + "bg": "#ffffff", + "surface": "#ffffff", + "ink": "#12384b", + "muted": "#476373", + "control": "#edf1f4", + "border": "#cbd6df", + "accent": "#176b72", + "selected_ink": "#ffffff", + "accent_pressed": "#10575e", + "pressed": "#dde8e8", + "icon_colour": "#386a73", + "good": "#23794c", + "warning": "#875709", + "weather_warm": "#a16a24", + "weather_cool": "#3b7590" + }, + "dark": { + "bg": "#171717", + "surface": "#171717", + "ink": "#f2f2f2", + "muted": "#bdbdbd", + "control": "#292929", + "border": "#454545", + "accent": "#76cfc5", + "selected_ink": "#172624", + "accent_pressed": "#a2dfd8", + "pressed": "#414141", + "icon_colour": "#8ccfc8", + "good": "#89caa2", + "warning": "#e8b86a", + "weather_warm": "#e5b874", + "weather_cool": "#8cb8db" + } + }, + "weather_accents": { + "sun": "weather_warm", + "moon": "weather_warm", + "cloud-sun": "weather_warm", + "cloud-moon": "weather_warm", + "cloud-rain": "weather_cool", + "cloud-snow": "weather_cool", + "cloud-lightning": "weather_cool" + }, + "lighting_tabs": [ + [ + "White light", + "white" + ], + [ + "Scenes", + "scenes" + ], + [ + "Programmes", + "programmes" + ], + [ + "Channels", + "channels" + ] + ], + "scenes": [ + { + "id": "Fid", + "label": "Fid", + "detail": "Original eight-channel room mixture." + }, + { + "id": "Sat", + "label": "Sat", + "detail": "Original eight-channel room mixture." + }, + { + "id": "Dul", + "label": "Dul", + "detail": "Original eight-channel room mixture." + }, + { + "id": "Gradient", + "label": "Gradient", + "detail": "A static colour pattern across individual fixtures." + } + ], + "programmes": [ + { + "id": "Sun", + "label": "Sun", + "subtitle": "Travelling light", + "detail": "Colour temperature and light level move through fixture groups in sequence." + }, + { + "id": "Circ", + "label": "Circ", + "subtitle": "Whole-room cycle", + "detail": "All fixtures follow the same colour-temperature and light-level cycle together." + } + ], + "blind_actions": [ + [ + "lift", + "Lift blinds" + ], + [ + "open", + "Open slats" + ], + [ + "close", + "Close & lower" + ] + ], + "guide": [ + { + "title": "Lighting", + "icon": "on", + "text": "Use Lights on and Lights off on Home for everyday lighting.\n\nRoom tools → Lighting brings White light, Scenes, Programmes and Channels together. Opening a tab does not change the lights.\n\nA highlighted button shows the last confirmed setting. If a result is uncertain, stop and ask the room host." + }, + { + "title": "Room controls", + "icon": "home", + "text": "Home always returns to everyday lighting controls. Opening a page does not change the lights.\n\nLegacy opens the original interface after restoring the starting lighting scene. Use Home in the Legacy bar to return.\n\nLift blinds, Open slats and Close & lower are on Home. Room tools → Screen & controls saves whether blind movement is enabled and whether the controls are shown. Movement is disabled by default and also requires a connected controller." + }, + { + "title": "Sensors and experiments", + "icon": "experiment", + "text": "Experiment mode guides a repeatable sequence of instructions and saves panel annotations.\n\nIt does not start a recorder. Arrange recording separately and coordinate the study with the room host.\n\nRoom tools → System & help shows room service availability. Availability alone does not prove usable sensor data." + }, + { + "title": "How it connects", + "icon": "health", + "text": "The touch panel and trusted-network website share the same room controls. Legacy’s original controls remain on the touch panel.\n\nThe lighting badge shows this panel’s connection: Ready, Sending, Offline or Uncertain. Tap it for System & help.\n\nWeather is an outdoor model estimate, separate from indoor measurements. Source details are under Room tools → Date & weather." + } + ], + "help": [ + { + "title": "Lighting", + "text": "Ready means the lighting adapter is available. Sending means a request is awaiting readback.\n\nIf Uncertain appears, stop changing lights and ask the room host. A failed response can leave the result unknown.\n\nIf Offline appears, check the panel network cable and the room computer. Legacy also needs the room services.", + "commands": "", + "command_tokens": [], + "reference": { + "machine": "", + "effect": "", + "documentation": null, + "steps": [] + } + }, + { + "title": "Power", + "text": "Low supply voltage can slow the Pi and affect storage reliability. Check its supply, cable and connector; a separately powered LCD does not rule out a Pi power problem.\n\nOpen the reference to distinguish current warnings from earlier ones. Do not hide warnings or repeatedly power-cycle. Shut down normally before changing power.", + "commands": "vcgencmd get_throttled", + "command_tokens": [ + { + "text": "vcgencmd", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "get_throttled", + "kind": "text" + } + ], + "reference": { + "machine": "Raspberry Pi", + "effect": "Read only", + "documentation": { + "label": "Raspberry Pi power flags", + "url": "https://github.com/raspberrypi/documentation/blob/master/documentation/asciidoc/computers/os/graphics-utilities.adoc#get_throttled" + }, + "steps": [ + { + "title": "Check power warnings", + "commands": "vcgencmd get_throttled", + "why": "Read the Pi firmware flags when the panel is slow or reports a power warning.", + "expect": "0x0 means no flags. Nonzero can include earlier warnings; it does not always mean a fault is active now.", + "command_tokens": [ + { + "text": "vcgencmd", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "get_throttled", + "kind": "text" + } + ] + } + ] + } + }, + { + "title": "Ubuntu checks", + "text": "Use a Terminal on the Ubuntu room computer. These commands inspect state; they do not start devices.\n\nROS owns room controls. Status owns the TV display and its clients. Session presence alone does not prove health.", + "commands": "hostname\n\nuptime\n\nfree -m\n\ndf -h /\n\ntmux list-sessions\n\nsource /opt/ros/indigo/setup.bash\n\nsource ~/catkin_ws/devel/setup.bash\n\ntimeout 10 rosservice list", + "command_tokens": [ + { + "text": "hostname", + "kind": "command" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "uptime", + "kind": "command" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "free", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "-m", + "kind": "option" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "df", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "-h", + "kind": "option" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "/", + "kind": "path" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "tmux", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "list-sessions", + "kind": "text" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "source", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "/opt/ros/indigo/setup.bash", + "kind": "path" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "source", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "~/catkin_ws/devel/setup.bash", + "kind": "path" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "timeout", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "10", + "kind": "number" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "rosservice", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "list", + "kind": "text" + } + ], + "reference": { + "machine": "Ubuntu room computer", + "effect": "Check in this order", + "documentation": { + "label": "How the time limit works", + "url": "https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html" + }, + "steps": [ + { + "title": "Confirm the computer", + "commands": "hostname", + "why": "Check which computer this Terminal is connected to.", + "expect": "A host name. Confirm it is the room computer before continuing.", + "command_tokens": [ + { + "text": "hostname", + "kind": "command" + } + ] + }, + { + "title": "Check running time and load", + "commands": "uptime", + "why": "See time since boot and recent system load.", + "expect": "Uptime and load averages. Load is not a CPU percentage.", + "command_tokens": [ + { + "text": "uptime", + "kind": "command" + } + ] + }, + { + "title": "Check memory", + "commands": "free -m", + "why": "Inspect memory in MiB when the room computer feels slow.", + "expect": "Memory totals and available/free space. Cached memory is not automatically a problem.", + "command_tokens": [ + { + "text": "free", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "-m", + "kind": "option" + } + ] + }, + { + "title": "Check storage", + "commands": "df -h /", + "why": "Check space on the root filesystem before collecting more data.", + "expect": "Filesystem size, used space, available space and use percentage.", + "command_tokens": [ + { + "text": "df", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "-h", + "kind": "option" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "/", + "kind": "path" + } + ] + }, + { + "title": "Inspect existing sessions", + "commands": "tmux list-sessions", + "why": "Look for existing sessions before considering a service start.", + "expect": "Session names, or no server. A session name alone does not prove the service is healthy.", + "command_tokens": [ + { + "text": "tmux", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "list-sessions", + "kind": "text" + } + ] + }, + { + "title": "Load the ROS environment", + "commands": "source /opt/ros/indigo/setup.bash", + "why": "Make the installed ROS tools available in this Terminal. This does not start ROS.", + "expect": "Usually no output. If the file is missing, stop and check the installed setup with staff.", + "command_tokens": [ + { + "text": "source", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "/opt/ros/indigo/setup.bash", + "kind": "path" + } + ] + }, + { + "title": "Load the room workspace", + "commands": "source ~/catkin_ws/devel/setup.bash", + "why": "Add the installed room packages to this Terminal after loading ROS.", + "expect": "Usually no output. Keep using the same Terminal for the next check.", + "command_tokens": [ + { + "text": "source", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "~/catkin_ws/devel/setup.bash", + "kind": "path" + } + ] + }, + { + "title": "List advertised services", + "commands": "timeout 10 rosservice list", + "why": "Ask the configured ROS master for service names, with a ten-second time limit.", + "expect": "Service names, an error or a timeout. Names show registration, not working sensors or devices.", + "command_tokens": [ + { + "text": "timeout", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "10", + "kind": "number" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "rosservice", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "list", + "kind": "text" + } + ] + } + ] + } + }, + { + "title": "Start services", + "text": "Room host only: stop experiments and check tmux sessions first. startROS.py replaces the ROS session; startStatus.py replaces Status. Do not use them as a quick connection fix.\n\nInstalled paths below are instructions only, not automatic recovery.", + "commands": "cd ~/catkin_ws/src/scr_control && python startROS.py\n\ncd ~/scr.status_display && python startStatus.py", + "command_tokens": [ + { + "text": "cd", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "~/catkin_ws/src/scr_control", + "kind": "path" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "&&", + "kind": "operator" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "python", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "startROS.py", + "kind": "path" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "cd", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "~/scr.status_display", + "kind": "path" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "&&", + "kind": "operator" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "python", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "startStatus.py", + "kind": "path" + } + ], + "reference": { + "machine": "Ubuntu room computer", + "effect": "Room host only · replaces sessions", + "documentation": { + "label": "Room operating guide", + "url": "https://github.com/LESA-RPI/scr.touch_panel/blob/experiment/fresh-pi-setup-20261013/WORKFLOWS.md#before-connected-control" + }, + "steps": [ + { + "title": "Start the ROS session", + "commands": "cd ~/catkin_ws/src/scr_control && python startROS.py", + "why": "Only after studies are stopped, ownership is checked and the existing sessions are understood.", + "expect": "The launcher replaces the ROS session. Inspect its output and check services before using controls.", + "command_tokens": [ + { + "text": "cd", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "~/catkin_ws/src/scr_control", + "kind": "path" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "&&", + "kind": "operator" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "python", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "startROS.py", + "kind": "path" + } + ] + }, + { + "title": "Start the Status session", + "commands": "cd ~/scr.status_display && python startStatus.py", + "why": "Use only when the room host has decided the shared TV/Status session must be started.", + "expect": "The launcher replaces Status. Check the TV and required feeds; a session name is not a health check.", + "command_tokens": [ + { + "text": "cd", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "~/scr.status_display", + "kind": "path" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "&&", + "kind": "operator" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "python", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "startStatus.py", + "kind": "path" + } + ] + } + ] + } + }, + { + "title": "Restart computer", + "text": "Room host only: stop recordings and coordinate with other room users. Restarting interrupts shared controls and feeds.\n\nUse the Ubuntu Restart action or its Terminal command below. After the desktop and network return, check ROS and Status; service autostart is not verified.", + "commands": "sudo reboot", + "command_tokens": [ + { + "text": "sudo", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "reboot", + "kind": "command" + } + ], + "reference": { + "machine": "Ubuntu room computer", + "effect": "Room host only · interrupts shared work", + "documentation": { + "label": "Room recovery guide", + "url": "https://github.com/LESA-RPI/scr.touch_panel/blob/experiment/fresh-pi-setup-20261013/WORKFLOWS.md#switching-and-recovery" + }, + "steps": [ + { + "title": "Restart the room computer", + "commands": "sudo reboot", + "why": "Use after recordings have stopped and room users have coordinated the interruption.", + "expect": "The connection ends. Wait for the desktop/network, then check ROS and Status; autostart is not assured.", + "command_tokens": [ + { + "text": "sudo", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "reboot", + "kind": "command" + } + ] + } + ] + } + } + ] +} diff --git a/browser/room.css b/browser/room.css new file mode 100644 index 0000000..f2e8993 --- /dev/null +++ b/browser/room.css @@ -0,0 +1,50 @@ +:root{--bg:#fff;--surface:#fff;--ink:#12384b;--muted:#476373;--control:#edf1f4;--border:#cbd6df;--accent:#176b72;--selected-ink:#fff;--icon-colour:#386a73;--accent-pressed:#10575e;color-scheme:light} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:18px/1.5 Cantarell,system-ui,sans-serif}button,input,select{font:inherit}button,a,select,input{touch-action:manipulation}button,a,select{min-height:48px}button{border:1px solid var(--border);border-radius:3px;background:var(--control);color:var(--ink);padding:12px 18px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:12px}button:hover:not(:disabled){background:var(--pressed,#dde8e8)}button:active:not(:disabled){transform:translateY(1px)}button.selected,.primary{background:var(--accent);border-color:var(--accent);color:var(--selected-ink)}button.selected:hover:not(:disabled),.primary:hover:not(:disabled){background:var(--accent-pressed)}button:disabled{cursor:default;color:var(--muted)}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:4px}a{color:var(--ink);text-underline-offset:4px}h1,h2,h3,p{margin:0}h1{font-size:clamp(22px,2.5vw,36px);font-weight:500;line-height:1.2}h2{font-size:29px;font-weight:500;margin-bottom:12px}h3{font-size:23px;font-weight:500}p+p{margin-top:14px}small,.muted,.eyebrow{color:var(--muted)}.eyebrow{font-size:15px;letter-spacing:.04em}.shell{max-width:1360px;margin:auto;padding:20px 28px}.header{display:flex;align-items:flex-end;gap:20px}.brand{width:116px;height:66px;object-fit:cover;object-position:top;background:white;flex-shrink:0}.identity{display:flex;align-items:flex-end;gap:12px;padding-bottom:2px}.beta{font-size:16px;background:var(--control);color:var(--muted);padding:4px 10px;white-space:nowrap}.header nav{display:flex;gap:12px;margin-left:auto;align-items:center}.header nav a{display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border:1px solid var(--border);text-decoration:none}.header nav a[aria-current=page]{background:var(--control)}.status{font-size:15px;max-width:280px}.room-layout{display:grid;grid-template-columns:1fr 1fr;gap:44px}.essential-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.essential-grid.single{grid-template-columns:1fr}.essential-group h2{margin-bottom:0}.essential-group>p{margin:0 0 20px}.essential-group button{width:100%;min-height:104px;font-size:24px;margin-bottom:12px}.disabled-group button{background:var(--bg)}.feedback,.card{border:1px solid var(--border);padding:20px 22px;background:var(--surface)}.feedback{margin-top:16px;font-size:16px}.feedback p+p{margin-top:6px}.clock{font-size:clamp(48px,6vw,86px);line-height:1.2;font-variant-numeric:tabular-nums}.date{font-size:23px;margin:8px 0 22px}.weather-reading{display:flex;justify-content:space-between;align-items:center}.temperature{font-size:68px;line-height:1.2}.weather-art{width:112px;height:112px;object-fit:contain}.weather-art.gray{filter:grayscale(1)}.condition{font-size:27px}.weather-stats{font-size:17px;margin-top:10px;color:var(--muted)}.daylight{margin-top:18px}.daylight p{font-size:23px}.track{height:6px;border-radius:5px;background:var(--control);margin:14px 5px}.track>div{height:6px;max-width:100%;border-radius:5px;background:var(--icon-colour);position:relative}.track>div:after{content:'';position:absolute;right:-5px;top:-3px;width:12px;height:12px;border:2px solid var(--bg);border-radius:50%;background:var(--icon-colour)}.daylight-times{display:flex;justify-content:space-between;font-size:15px;color:var(--muted)}.footer-tools{display:flex;gap:12px;justify-content:flex-end;margin-top:28px;padding-top:16px;border-top:1px solid var(--border)}.footer-tools a{padding:12px 22px;text-decoration:none;background:var(--control);border:1px solid var(--border)}.tools-layout{display:grid;grid-template-columns:280px minmax(0,1fr);gap:28px;min-height:570px}.sidebar{display:flex;flex-direction:column;gap:10px}.sidebar a{padding:15px 14px;display:flex;align-items:center;gap:12px;text-decoration:none;background:var(--control);border:1px solid var(--border);flex:1;min-height:54px}.sidebar a[aria-current=page]{background:var(--accent);color:var(--selected-ink);border-color:var(--accent)}.row{display:flex;gap:12px;flex-wrap:wrap;margin:14px 0}.row>*{flex:1}.range-card{margin:14px 0;padding:16px 22px;border:1px solid var(--border)}.range-value{display:flex;align-items:center;gap:12px;font-size:38px}.range-value small{font-size:17px}.range-value .steps{margin-left:auto;display:flex;gap:8px}.steps button{min-width:60px;font-size:24px}.limits{display:flex;justify-content:space-between;font-size:14px;color:var(--muted)}input[type=range]{width:100%;height:48px;accent-color:var(--accent);cursor:pointer}.channel-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.channel-grid label{padding:12px;border:1px solid var(--border);display:block}.channel-grid output{display:block;font-size:25px}.topics{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:20px}.topic{min-height:180px;flex-direction:column;background:var(--bg);font-size:23px;text-align:center;text-decoration:none;border:1px solid var(--border);padding:24px;display:flex;align-items:center;justify-content:center;gap:10px}.topic small{font-size:17px}.topic-body p{font-size:22px;margin:18px 0}.topic-head{display:flex;align-items:center;gap:14px}.topic-head svg{width:32px;height:32px}.help-select{display:flex;align-items:center;gap:20px;margin:18px 0}select{background:var(--control);color:var(--ink);border:1px solid var(--border);padding:10px 40px 10px 16px;min-width:220px}.command{margin:16px 0;border:1px solid var(--border);background:var(--control)}.command-head{padding:8px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:14px;justify-content:space-between}.command-head button{min-height:44px;padding:6px 14px}pre{font:15px/1.7 ui-monospace,'DejaVu Sans Mono',Consolas,monospace;margin:0;padding:16px;white-space:pre;overflow:auto;tab-size:4;color:var(--ink)}code{font-family:inherit}.notice{border:1px solid var(--border);padding:12px 16px;font-size:16px;margin-bottom:18px}.notice[hidden]{display:none}.legacy-preview{border:1px solid var(--border);padding:24px;margin:18px 0}.legacy-header{display:flex;align-items:center;gap:16px;padding:12px;background:var(--control);margin-bottom:18px}.legacy-header button{margin-left:auto}.session-stage{font-size:30px;margin:16px 0}.timer{font-size:36px;font-variant-numeric:tabular-nums}svg{width:24px;height:24px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}.topic svg{width:32px;height:32px}.subtle{font-size:15px;color:var(--muted);margin-top:18px}.demo-label{background:var(--control);padding:12px 28px;border-bottom:1px solid var(--border);font-weight:600}details{margin-top:14px}summary{cursor:pointer;min-height:44px;color:var(--muted);padding:8px 0}.visually-hidden{position:absolute;left:-10000px}.wide{width:100%}.health-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:14px 0}.badge{font-size:16px;padding:6px 12px;border:1px solid var(--border);white-space:nowrap} +.header{flex-wrap:wrap}.header nav{width:100%;margin-left:0}.status{margin-left:auto} +.range-value{flex-wrap:wrap}.channel-grid .range-card{min-width:0;padding:10px;margin:0}.channel-grid .range-value{font-size:27px}.channel-grid h3{font-size:17px}input[type=range]{min-width:0}.tools-layout>section{min-width:0}p,a{overflow-wrap:anywhere}.command{min-width:0} +.brand{display:block;overflow:hidden}.brand img{display:block;width:124.14%;height:auto;max-width:none;margin-left:-7.59%;margin-top:-22.07%}.weather-art{display:block;color:var(--icon-colour)}.weather-art svg{width:100%;height:100%;stroke-width:.9}.footer-tools a{display:inline-flex;align-items:center;justify-content:center;gap:12px;min-width:224px} +@media(max-width:1000px){.room-layout{gap:24px}.tools-layout{grid-template-columns:240px minmax(0,1fr)}.channel-grid{grid-template-columns:1fr 1fr}} +@media(max-width:650px){body{font-size:16px}.shell{padding:16px}.header{gap:12px}.brand{width:88px;height:50px}.identity{flex:1;flex-wrap:wrap;gap:6px}h1{font-size:23px}.header nav{gap:8px;flex-wrap:wrap}.header nav a{padding:8px 12px}.status{max-width:none;width:100%}.room-layout,.tools-layout,.topics,.health-grid{grid-template-columns:1fr}.sidebar{display:grid;grid-template-columns:1fr 1fr;gap:8px}.sidebar h2{grid-column:1/-1}.sidebar a{font-size:15px;padding:10px 8px;gap:8px}.essential-group button{font-size:20px}.clock{font-size:60px}.temperature{font-size:60px}.range-card{padding:12px}.range-value{font-size:30px}.topic-body p{font-size:19px}.row>*{min-width:100px}.footer-tools{justify-content:stretch}.footer-tools a{flex:1;text-align:center}.legacy-header{flex-wrap:wrap}.legacy-header button{margin-left:0}} +@media(max-width:650px){.footer-tools a{min-width:0;padding:12px;gap:10px}} +/* Shell references share the panel's semantic tokens and neutral dark palette. */ +pre code{font-family:ui-monospace,'DejaVu Sans Mono',Consolas,monospace} +.syntax-command,.syntax-operator{color:var(--ink);font-weight:700} +.syntax-option,.syntax-number{color:var(--muted)} +.syntax-path,.syntax-string{color:var(--accent)} +.syntax-comment{color:var(--muted);font-style:italic} +button.selected:disabled,button.primary:disabled{color:var(--selected-ink)} +.lighting-tabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:12px 0 16px} +.lighting-tabs a{display:flex;align-items:center;justify-content:center;min-height:54px;padding:10px 8px;text-decoration:none;background:var(--control);border:1px solid var(--border)} +.lighting-tabs a[aria-current=page]{background:var(--accent);color:var(--selected-ink);border-color:var(--accent)} +.scene-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0} +.scene-grid .card{display:flex;flex-direction:column;gap:8px;padding:16px} +.scene-grid button{margin-top:auto;width:100%} +#blind-group button{min-height:65px;font-size:20px} +.essential-group button{font-size:22px} +@media(max-width:1000px){.lighting-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.scene-grid{grid-template-columns:1fr}} +.reference-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:20px 0 12px} +.command-step{background:var(--surface);margin:16px 0 24px} +.command-step .command-head{padding:12px 20px;gap:16px;background:var(--surface)} +.command-head h3{font-size:20px} +.command-step pre{background:var(--control);padding:18px 20px} +.command-why{padding:16px 20px} +.command-expect{padding:16px 20px;color:var(--muted);font-size:16px} +.command-expect strong{display:block;font-weight:500;color:var(--ink);margin-bottom:6px} +.documentation-link{display:inline-flex;align-items:center;min-height:48px;padding:8px 0} +.reference-warning{color:var(--warning)} +.tools-layout{align-items:start} +.sidebar{position:sticky;top:20px;align-self:start;height:540px;max-height:calc(100dvh - 40px);overflow-y:auto} +.health-grid p+p{margin-top:8px}.health-grid .card>h3+div{margin-top:12px}.system-warning{color:var(--warning)} +.card>h3+p{margin-top:12px}.scene-grid .card{padding:20px;gap:12px}.scene-grid .card>h3+p{margin-top:0}.scene-grid button{margin-top:auto} +@media(max-width:650px){.sidebar{position:static;height:auto;max-height:none;overflow:visible}.reference-head,.command-head{flex-wrap:wrap}.reference-head button{flex-shrink:0}.command-step .command-head,.command-why,.command-expect{padding:14px 16px}.command-step pre{padding:16px}.command-head h3{flex:1;min-width:160px}.room-layout{gap:32px}} +/* A consistent inset keeps large values and touch controls clear of card edges. */ +.range-card{padding:22px 24px;margin:16px 0}.range-value{margin-top:14px;row-gap:12px}.steps button{min-height:52px;min-width:56px;padding:8px 14px}.numeric-value{width:138px;min-width:0;height:56px;padding:4px 8px;background:var(--control);border:1px solid var(--border);border-radius:3px;color:var(--ink);font-size:32px;font-variant-numeric:tabular-nums}.numeric-value:disabled{color:var(--muted)}.channel-grid .numeric-value{width:90px;font-size:25px;height:48px}.limits{gap:16px;margin-top:4px}.limits span:last-child{text-align:right}.channel-grid .steps:empty{display:none}.channel-grid .range-value{gap:8px}.white-grid{min-width:0} +#blind-group{margin-top:0}#blind-group>summary{list-style:none;padding:0;margin-bottom:20px;color:var(--ink);line-height:1.5}#blind-group>summary::-webkit-details-marker{display:none}#blind-group>summary span{display:block}#blind-group>summary h2{display:flex;align-items:center;justify-content:space-between}#blind-group>summary h2:after{content:'⌄';font-size:24px;font-weight:400;color:var(--muted)}#blind-group[open]>summary h2:after{transform:rotate(180deg)} +#programme-progress{width:100%;height:12px;accent-color:var(--accent);margin-top:16px}.message-shell{padding-block:0}.demo-label{font-size:15px;font-weight:400}.demo-label select{min-width:0;max-width:100%;margin:8px}.demo-label button{margin:8px 8px 8px 0}.demo-label label{display:inline-flex;align-items:center;flex-wrap:wrap} +@media(min-width:1400px){.shell{max-width:1600px}.tools-layout{grid-template-columns:260px minmax(0,1fr)}.white-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.white-grid .range-value small{flex-basis:100%;order:3}.white-grid .range-value .steps{margin-left:auto}} +@media(max-width:650px){.essential-grid{grid-template-columns:1fr}.essential-group button{min-height:88px}#blind-group{margin-top:12px;border-top:1px solid var(--border);padding-top:18px}#blind-group>summary{margin-bottom:0;min-height:64px}#blind-group[open]>summary{margin-bottom:16px}.range-card{padding:20px}.range-value .steps{margin-left:0}.range-value small{flex:1}.numeric-value{width:130px}.help-select{flex-wrap:wrap;gap:8px}.help-select select{width:100%;min-width:0}.clock{font-size:clamp(44px,14vw,60px)}.channel-grid{gap:12px}.channel-grid .range-card{padding:12px}.channel-grid .numeric-value{width:100%;font-size:25px}.channel-grid .range-value small{flex-basis:100%}.sidebar a{font-size:16px}.topic{padding:20px}.header nav a{padding-inline:10px}.status{font-size:14px}} +.tools-mobile{display:none}.numeric-value::-webkit-inner-spin-button,.numeric-value::-webkit-outer-spin-button{appearance:none;margin:0}.numeric-value{appearance:textfield} +.power-controls{margin:18px 0}.power-status{margin-top:14px;color:var(--muted)}.power-confirmation{margin-top:18px;padding:18px 20px;border:1px solid var(--border);background:var(--control)}.power-confirmation[hidden]{display:none}.power-confirmation h3+p{margin-top:10px}.power-ack{display:flex;align-items:flex-start;gap:12px;min-height:48px;margin-top:18px;padding:8px 0}.power-ack input{width:24px;height:24px;flex-shrink:0;margin:2px 0;accent-color:var(--accent)}.power-submit{border-color:var(--warning);color:var(--warning)}.power-confirmation .row{margin-bottom:0} +.weather-stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 32px;margin:16px 0 0;padding:14px 0 0;border-top:1px solid var(--border)}.weather-stats[hidden]{display:none}.weather-metric{display:flex;justify-content:space-between;align-items:baseline;gap:12px;min-width:0}.weather-metric dt{font-size:16px;color:var(--muted)}.weather-metric dd{margin:0;font-size:18px;color:var(--ink);font-variant-numeric:tabular-nums;white-space:nowrap}.weather-notice{margin-top:14px;color:var(--muted);font-size:16px} +@media(max-width:650px){.weather-stats{column-gap:24px}.weather-metric{gap:8px}.weather-metric dt{font-size:15px}.weather-metric dd{font-size:17px}} +@media(max-width:650px){.tools-layout>.sidebar{display:none}.tools-mobile{display:flex;flex-direction:column;gap:8px;color:var(--muted)}.tools-mobile select{width:100%;min-width:0}.header nav{display:grid;grid-template-columns:1fr 1fr;width:100%}.header nav a{justify-content:center}.white-grid .range-value{display:grid;grid-template-columns:minmax(0,1fr) auto}.white-grid .numeric-value{width:100%;max-width:138px;font-size:30px}.white-grid .range-value small{grid-row:2;grid-column:1/-1}.white-grid .steps{grid-column:2;grid-row:1}} diff --git a/browser/room.js b/browser/room.js new file mode 100644 index 0000000..b5b91fe --- /dev/null +++ b/browser/room.js @@ -0,0 +1,275 @@ +'use strict'; +// Shared state and commands come from the existing panel owner. No device driver lives here. +const $=id=>document.getElementById(id); +let state=null,content={guide:[],help:[]},posting=false,unknown=null,view='',dragging=false,rangeRevision=null,chosenHelp=0; +let powerChoice=null,powerRequest=null; +const paths={home:'M3 10 12 3l9 7v11h-6v-7H9v7H3Z',white:'M9 18h6m-5 3h4M8 13a6 6 0 1 1 8 0l-1 3H9Z',scenes:'m12 3 10 5-10 5L2 8Zm-10 9 10 5 10-5M2 16l10 5 10-5',experiment:'M9 3h6m-5 0v7L4 20h16l-6-10V3',appearance:'M12 3v2m0 14v2M3 12h2m14 0h2M6 6l1 1m10 10 1 1M6 18l1-1M17 7l1-1M16 12a4 4 0 1 1-4-4',clock:'M8 2v4m8-4v4M3 9h18M3 4h18v18H3Z',screen:'M3 4h18v13H3Zm9 13v4m-5 0h10',system:'M2 12h5l3-8 4 16 3-8h5',about:'M12 11v6m0-10v1M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0',guide:'M12 5v16M3 3l9 2 9-2v16l-9 2-9-2Z',legacy:'M14 3h7v7m0-7L10 14M9 5H3v16h16v-6',off:'M9 18h6m-5 3h4M8 13a6 6 0 0 1-2-5m3-4a6 6 0 0 1 7 9l-1 3H9M3 3l18 18'}; +function el(tag,text,cls){const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n;} +function icon(name){const n=document.createElementNS('http://www.w3.org/2000/svg','svg');n.setAttribute('viewBox','0 0 24 24');n.setAttribute('aria-hidden','true');for(const shape of content.icons?.[name]||[{tag:'path',attributes:{d:paths[name]||paths.about}}]){const child=document.createElementNS(n.namespaceURI,shape.tag);for(const [key,value] of Object.entries(shape.attributes))child.setAttribute(key,value);n.append(child);}return n;} +function asset(path){return window.SCR_ASSETS?.[path]||path;} +function button(text,fn,opts={}){const n=el('button',text,opts.primary?'primary':'');n.type='button';if(opts.icon)n.prepend(icon(opts.icon));n.onclick=fn;if(opts.action)n.dataset.action=opts.action;if(opts.choice){n.dataset.choice=opts.choice[0];n.dataset.value=String(opts.choice[1]);}return n;} +function link(text,hash,kind){const a=el('a',text);a.href='#'+hash;if(kind)a.prepend(icon(kind));return a;} +function command(action,args={},revision=state?.revision){ + if(posting||unknown||!state?.available)return Promise.resolve(false); + const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join(''); + if(action==='power')powerRequest={operation:args.operation,session:state.session,id,accepted:false}; + posting=true;sync(); + return fetch('/api/command',{method:'POST',headers:{'Content-Type':'application/json','X-SCR-Token':state.csrf}, + body:JSON.stringify({id,revision,action,arguments:args}),signal:AbortSignal.timeout(5000)}).then(async response=>{ + const reply=await response.json().catch(()=>null); + if(!reply&&[400,403,405].includes(response.status)){if(action==='power')powerRequest=null;message('Command was rejected. Refresh the page before trying a new action.');return false;} + if(!reply){unknown=id;message(action==='power'?'Power request status is unknown. Check the touch panel before trying again.':'Command receipt unknown. Check the panel; do not repeat the action.');return false;} + if(!response.ok||!reply.ok){if(action==='power')powerRequest=null;message(reply.detail||'Command was not accepted.');return false;} + if(action==='power'){powerRequest.accepted=true;powerChoice=null;if($('power-confirmation'))$('power-confirmation').hidden=true;message(powerRequest.operation==='reboot'?'Restart requested. The panel is preparing to restart.':'Shutdown requested. The panel is preparing to shut down.');return true;} + message(action==='on'||action==='off'||['cct','channels','preset'].includes(action)?'Sent. Waiting for controller confirmation.':'Accepted by the panel.');return true; + }).catch(()=>{unknown=id;message(action==='power'?'Power request status is unknown. Check the touch panel before trying again.':'Command receipt unknown. Check the panel; do not repeat the action.');return false;}) + .finally(async()=>{posting=false;dragging=false;rangeRevision=null;await refresh();sync();}); +} +function message(text){$('message').textContent=text;$('message').hidden=!text;} +function route(){return (location.hash.slice(1)|| (location.pathname==='/guide'?'guide':location.pathname==='/tools'?'tools-lighting':'home'));} +function layout(){ + const next=route();if(next===view)return;view=next;dragging=false;rangeRevision=null;powerChoice=null; + const root=$('app');root.replaceChildren(); + for(const a of document.querySelectorAll('.header a'))a.setAttribute('aria-current',a.hash.slice(1)===view||(a.hash==='#tools-lighting'&&view.startsWith('tools-'))?'page':'false'); + if(view.startsWith('tools-'))tools(root,view.slice(6)); + else if(view.startsWith('guide'))guide(root,view==='guide'?-1:Number(view.split('-')[1])); + else if(view==='legacy')legacy(root);else home(root); + sync(); +} +function feedback(parent){const box=el('div',undefined,'feedback');const current=el('p');current.id='light-message';const recent=el('p',undefined,'muted');recent.id='light-recent';box.append(current,recent);parent.append(box);} +function home(root){ + const row=el('div',undefined,'room-layout'),left=el('section'),right=el('section'); + const groups=el('div',undefined,'essential-grid');groups.id='essentials'; + for(const [title,caption,isBlind] of [['Lights','Whole room',false],['Blinds','Whole room',true]]){ + const group=el(isBlind?'details':'div',undefined,'essential-group');group.id=isBlind?'blind-group':'light-group'; + let actions=group; + if(isBlind){group.open=!matchMedia('(max-width:650px)').matches;const summary=el('summary');summary.append(el('h2',title));const note=el('span','Disabled','muted');note.id='blind-status';summary.append(note);group.append(summary);actions=el('div',undefined,'blind-actions');group.append(actions);} + else group.append(el('h2',title),el('p',caption,'muted')); + for(const [name,label] of isBlind?content.blind_actions||[['lift','Lift blinds'],['open','Open slats'],['close','Close & lower']]:[['on','Lights on'],['off','Lights off']]){ + const action=isBlind?'blind':name,b=button(label,()=>command(action,isBlind?{name}:{}),{icon:isBlind?null:action==='on'?'white':'off',action}); + actions.append(b); + }if(!isBlind)group.append(el('small','Standard room lighting'));groups.append(group); + }left.append(groups);feedback(left); + right.append(el('p','Troy, NY','eyebrow'));const clock=el('p',undefined,'clock');clock.id='clock';const date=el('p',undefined,'date');date.id='date';right.append(clock,date); + right.append(el('p','OUTDOOR WEATHER','eyebrow')); + const reading=el('div',undefined,'weather-reading'),temp=el('span','—','temperature'),art=el('span');temp.id='temperature';art.id='weather-art';art.className='weather-art';art.setAttribute('aria-hidden','true');reading.append(temp,art);right.append(reading); + const condition=el('p',undefined,'condition');condition.id='condition';right.append(condition); + const stats=el('dl',undefined,'weather-stats');stats.id='weather-stats';for(const [key,label] of [['feels','Feels like'],['rain','Rain today'],['high','High'],['low','Low']]){const item=el('div',undefined,'weather-metric'),value=el('dd','—');value.id='weather-'+key;item.append(el('dt',label),value);stats.append(item);}right.append(stats); + const notice=el('p',undefined,'weather-notice');notice.id='weather-notice';notice.hidden=true;right.append(notice); + const day=el('div',undefined,'daylight');day.id='daylight';const label=el('p');label.id='daylight-label';const track=el('div',undefined,'track'),fill=el('div');fill.id='daylight-fill';track.append(fill);const times=el('div',undefined,'daylight-times'); + const rise=el('span'),set=el('span');rise.id='sunrise';set.id='sunset';times.append(rise,set);day.append(label,track,times);right.append(day); + const details=el('details'),summary=el('summary','Weather details'),source=el('p',undefined,'subtle');source.id='weather-source';details.append(summary,source);right.append(details); + row.append(left,right);root.append(row);const nav=el('nav',undefined,'footer-tools');nav.append(link('Room tools','tools-lighting','tools'),link('Demo & guide','guide','guide'));root.append(nav); +} +const pages=[['Lighting','lighting'],['Experiment mode','experiment'],['Appearance','appearance'],['Date & weather','clock'],['Screen & controls','screen'],['System & help','system'],['About','about']]; +function choices(parent,items){const row=el('div',undefined,'row');for(const [label,action,value] of items)row.append(button(label,()=>command(action,{value}),{action,choice:[action,value]}));parent.append(row);} +function tools(root,page){ + let tab=page.startsWith('lighting-')?page.slice(9):page==='scenes'?'channels':'white'; + if(!['white','scenes','programmes','channels'].includes(tab))tab='white'; + if(page.startsWith('lighting-')||['white','scenes'].includes(page))page='lighting'; + if(!pages.some(p=>p[1]===page))page='lighting'; + const shell=el('div',undefined,'tools-layout'),nav=el('nav',undefined,'sidebar'),detail=el('section');nav.setAttribute('aria-label','Room tools');nav.append(el('h2','Room tools')); + for(const [label,key] of pages){const a=link(label,'tools-'+key,key);if(key===page)a.setAttribute('aria-current','page');nav.append(a);} + const mobile=el('label',undefined,'tools-mobile');mobile.append(el('span','Room tools'));const picker=el('select');picker.id='tool-picker';for(const [label,key] of pages){const option=el('option',label);option.value=key;picker.append(option);}picker.value=page;picker.onchange=()=>{location.hash='tools-'+picker.value;};mobile.append(picker); + shell.append(nav,mobile,detail);root.append(shell);detail.append(el('h2',pages.find(p=>p[1]===page)[0])); + if(page==='lighting')lighting(detail,tab); + else if(page==='experiment'){ + detail.append(el('p','Recorder not connected · instructions and panel annotations only','muted')); + const card=el('div',undefined,'card'),title=el('h3'),stage=el('p',undefined,'session-stage'),timer=el('p',undefined,'timer'),status=el('p',undefined,'muted');title.id='profile-title';stage.id='instruction';timer.id='elapsed';status.id='session-state';card.append(title,stage,timer,status);detail.append(card); + const row=el('div',undefined,'row');row.append(button('Start instructions',()=>command('start'),{action:'start',primary:true}),button('Pause',()=>command('pause'),{action:'pause'}));detail.append(row); + const steps=el('div',undefined,'row');steps.append(button('Previous instruction',()=>command('previous'),{action:'previous'}),button('Next instruction',()=>command('next'),{action:'next'}));detail.append(steps); + const marks=el('div',undefined,'row');marks.id='marks';detail.append(marks);const note=el('p',undefined,'subtle');note.id='annotation';detail.append(note); + }else if(page==='appearance'){ + choices(detail,[['Light','theme','light'],['Dark','theme','dark'],['Auto','theme','auto']]);detail.append(el('h3','Automatic schedule')); + const note=el('p');note.id='schedule';detail.append(note); + for(const [title,which] of [['Light starts','light_at'],['Dark starts','dark_at']]){ + const row=el('div',undefined,'row');row.append(button(title+' −30 min',()=>command('schedule',{which,delta:-30}),{action:'schedule'}),button(title+' +30 min',()=>command('schedule',{which,delta:30}),{action:'schedule'}));detail.append(row); + }detail.append(el('p','Automatic theme changes wait while the panel is in use or instructions are running.','muted')); + detail.append(el('h3','Touch explanations'));choices(detail,[['Tips on','tips',true],['Tips off','tips',false]]); + }else if(page==='clock'){ + detail.append(el('p','America/New_York','muted'));choices(detail,[['12-hour','clock','12'],['24-hour','clock','24']]);detail.append(el('h3','Weather temperature'));choices(detail,[['Celsius (°C)','temperature','c'],['Fahrenheit (°F)','temperature','f']]); + const source=el('p',undefined,'subtle');source.id='weather-source';detail.append(source); + }else if(page==='screen'){ + detail.append(el('h3','Screen saver'));choices(detail,[['Off','idle',0],['1 min','idle',60],['5 min','idle',300],['15 min','idle',900]]); + detail.append(el('p','A first touch on the screen saver opens Home without activating a control. The preview uses the same screen as the idle timer.','muted'));detail.append(el('h3','Touch panel display')); + const row=el('div',undefined,'row');row.append(button('Preview screen saver',()=>command('clock_view'),{action:'clock_view'}),button('Blank touch panel',()=>command('blank'),{action:'blank'}),button('Wake to Home',()=>command('home'),{action:'home'}));detail.append(row);detail.append(el('p','Blank shows black pixels; the backlight stays on. Hold one spot on the panel for two seconds to return.','muted')); + detail.append(el('h3','Blind controls'));choices(detail,[['Locked','blinds_enabled',false],['Unlocked','blinds_enabled',true]]); + const reason=el('p',undefined,'muted');reason.id='blind-reason';detail.append(reason); + detail.append(el('p','Changing the lock does not move the blinds.','subtle')); + detail.append(el('h3','Blinds on Home'));choices(detail,[['Show','blinds_display','disabled'],['Hide','blinds_display','hidden']]); + }else if(page==='system')system(detail); + else{const version=el('p',undefined,'muted'),build=el('p',undefined,'subtle');version.id='app-version';build.id='app-build';build.hidden=true;detail.append(el('h3','Smart Conference Room'),el('p','Touch panel and browser','muted'),version,build,el('p','Built by Diyanko Bhowmik'),el('p','Original contributors are credited in the repository history.','muted'));const a=el('a','github.com/LESA-RPI/scr.touch_panel');a.href='https://github.com/LESA-RPI/scr.touch_panel';detail.append(a,el('p','Icons: Lucide · lucide.dev','subtle'));} +} +let selectedProgramme='Sun'; +function lighting(detail,tab){ + const tabs=el('nav',undefined,'lighting-tabs');tabs.setAttribute('aria-label','Lighting'); + for(const [label,key] of content.lighting_tabs||[['White light','white'],['Scenes','scenes'],['Programmes','programmes'],['Channels','channels']]){ + const a=link(label,'tools-lighting-'+key);if(key===tab)a.setAttribute('aria-current','page');tabs.append(a); + }detail.append(tabs); + if(tab==='scenes'){ + detail.append(el('p','Original scene names and mixtures from Legacy.','muted')); + const grid=el('div',undefined,'scene-grid'); + for(const scene of content.scenes||[]){ + const card=el('section',undefined,'card'),b=scene.id==='Gradient'?button('Apply scene',()=>window.SCR_PREVIEW?.applyGradient()):button('Apply scene',()=>command('preset',{name:scene.id}),{action:'preset'}); + if(scene.id==='Gradient'){b.id='gradient-apply';b.disabled=true;}else b.dataset.preset=scene.id; + card.append(el('h3',scene.label),el('p',scene.detail,'muted'));if(scene.id==='Gradient')card.append(el('small','Fixture control not connected','muted'));card.append(b);grid.append(card); + }detail.append(grid);feedback(detail); + }else if(tab==='programmes'){ + detail.append(el('p','Choose a finite light cycle from Legacy.','muted')); + const row=el('div',undefined,'row'),items=content.programmes||[]; + for(const item of items){const b=button(item.label+' · '+item.subtitle,()=>{selectedProgramme=item.id;view='';layout();});b.setAttribute('aria-pressed',String(item.id===selectedProgramme));b.classList.toggle('selected',item.id===selectedProgramme);row.append(b);}detail.append(row); + const item=items.find(p=>p.id===selectedProgramme);if(item){const card=el('section',undefined,'card');card.append(el('h3',item.subtitle),el('p',item.detail),el('p','These are light demonstrations, independent of the clock and weather.','muted'));detail.append(card);} + const status=el('p','Programme control not connected','subtle');status.id='programme-status';detail.append(status); + const progress=el('progress');progress.id='programme-progress';progress.max=100;progress.value=0;progress.setAttribute('aria-label','Programme progress');progress.hidden=true;detail.append(progress); + const actions=el('div',undefined,'row');for(const [id,label,action] of [['programme-start','Start programme',()=>window.SCR_PREVIEW?.startProgramme(selectedProgramme)],['programme-stop','Stop programme',()=>window.SCR_PREVIEW?.stopProgramme()]]){const b=button(label,action);b.id=id;b.disabled=true;actions.append(b);}detail.append(actions); + detail.append(el('p',window.SCR_PREVIEW?'Offline preview uses an accelerated simulation.':'Programme controls become available when a compatible controller is connected.','subtle')); + }else if(tab==='channels'){ + detail.append(el('p','Adjust the eight room-wide channel percentages.','muted')); + const grid=el('div',undefined,'channel-grid');for(const [i,label] of ['Blue 1','Blue 2','Blue 3','Lime','Amber','Orange','Red 1','Red 2'].entries())range(grid,label,'channel-'+i,0,100,1,'%');detail.append(grid);feedback(detail); + }else{ + detail.append(el('p','Selected values apply after your touch settles.','muted')); + const grid=el('div',undefined,'white-grid');range(grid,'Colour temperature','cct',1600,10000,100,'K');range(grid,'Light level','intensity',0,1900,25,'controller units');detail.append(grid);feedback(detail); + } +} +function range(parent,title,key,low,high,step,unit){ + const box=el('section',undefined,'range-card'),heading=el('h3',title);const row=el('div',undefined,'range-value'),output=el('input');output.type='number';output.required=true;output.id='value-'+key;output.className='numeric-value';output.min=low;output.max=high;output.step=step;output.inputMode='numeric';output.setAttribute('aria-label',title+' value');const units=el('small',unit),steps=el('div',undefined,'steps'); + const input=el('input');input.type='range';input.id=key;input.min=low;input.max=high;input.step=step;input.dataset.range=key;input.setAttribute('aria-label',title); + function selected(){dragging=true;rangeRevision??=state?.revision;output.value=input.value;} + input.oninput=selected;input.onchange=()=>{selected();commitRange(key);}; + output.onfocus=()=>{dragging=true;rangeRevision??=state?.revision;}; + let cancelEdit=false; + output.onchange=()=>{if(cancelEdit)return;if(!output.checkValidity()){output.reportValidity();return;}input.value=output.value;selected();commitRange(key);}; + output.onblur=()=>{if(!posting){dragging=false;rangeRevision=null;sync();}}; + output.onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();output.blur();}else if(event.key==='Escape'){cancelEdit=true;output.value=input.value;dragging=false;rangeRevision=null;output.blur();cancelEdit=false;sync();}}; + for(const [label,delta] of (key.startsWith('channel')?[]:[['−',-step],['+',step]])){const b=button(label,()=>{input.value=Math.max(low,Math.min(high,Number(input.value)+delta));selected();commitRange(key);},{action:key.startsWith('channel')?'channels':'cct'});b.setAttribute('aria-label',(delta<0?'Decrease ':'Increase ')+title.toLowerCase());steps.append(b);} + row.append(output,units,steps);const limits=el('div',undefined,'limits');limits.append(el('span','Min '+low.toLocaleString()+' '+unit),el('span','Max '+high.toLocaleString()+' '+unit));box.append(heading,row,input,limits);parent.append(box); +} +function commitRange(key){if(key.startsWith('channel'))command('channels',{values:Array.from({length:8},(_,i)=>Number($('channel-'+i).value))},rangeRevision);else command('cct',{cct:Number($('cct').value),intensity:Number($('intensity').value)},rangeRevision);} +function guide(root,index){ + const topics=(state?.guide||content.guide);root.append(el('h2','Demo & guide')); + if(index<0||!topics[index]){root.append(el('p','Choose a topic. Exploring this guide does not operate equipment.','muted'));const grid=el('div',undefined,'topics');for(const [i,t] of topics.entries()){const a=link(t.title,'guide-'+i,({on:'white',home:'home',experiment:'experiment',health:'system',room:'white'})[t.icon]);a.className='topic';grid.append(a);}root.append(grid);return;} + const t=topics[index],card=el('article',undefined,'card topic-body'),head=el('div',undefined,'topic-head');head.append(icon(({on:'white',home:'home',experiment:'experiment',health:'system',room:'white'})[t.icon]),el('h3',t.title));card.append(head);for(const p of t.text.split('\n\n'))card.append(el('p',p));root.append(card); + const nav=el('nav',undefined,'row');nav.append(link('All topics','guide','guide'));if(index>0)nav.append(link('Previous','guide-'+(index-1)));if(index{chosenHelp=Number(select.value);renderHelp();};const detail=el('div');detail.id='help-detail';parent.append(detail);renderHelp(); + const a=el('a','More diagnostics');a.href=window.SCR_DEMO?'#tools-system':'/diagnostics';parent.append(a); +} +function powerControls(parent){ + const card=el('section',undefined,'card power-controls');card.id='panel-power-controls';card.append(el('h3','Panel power'),el('p','These actions affect this touch panel and its website. They do not stop the room computer or connected recorders. Stop recordings and experiments first.','muted')); + const status=el('p','Power controls unavailable.','power-status');status.id='power-status';status.setAttribute('role','status');card.append(status); + const actions=el('div',undefined,'row');for(const [operation,label] of [['reboot','Restart panel'],['shutdown','Shut down panel']]){const b=button(label,()=>{if(!powerAllowed(operation))return;powerChoice={operation,revision:state.revision};$('power-confirm-title').textContent=operation==='reboot'?'Restart this panel?':'Shut down this panel?';$('power-confirm-text').textContent=operation==='reboot'?'Controls and this website will be unavailable while the panel restarts.':'Controls and this website will be unavailable until the panel is powered on again.';$('power-ack').checked=false;$('power-confirm').textContent=operation==='reboot'?'Confirm restart':'Confirm shutdown';$('power-confirmation').hidden=false;syncPower();$('power-ack').focus();});b.id='power-'+operation;b.disabled=true;actions.append(b);}card.append(actions); + const confirm=el('section',undefined,'power-confirmation');confirm.id='power-confirmation';confirm.hidden=true;confirm.setAttribute('aria-labelledby','power-confirm-title');const title=el('h3');title.id='power-confirm-title';const text=el('p');text.id='power-confirm-text';const ack=el('label',undefined,'power-ack'),input=el('input');input.type='checkbox';input.id='power-ack';input.onchange=syncPower;ack.append(input,el('span','I have stopped any recording or experiment')); + const row=el('div',undefined,'row'),cancel=button('Cancel',()=>{const operation=powerChoice?.operation;powerChoice=null;confirm.hidden=true;$('power-'+operation)?.focus();}),submit=button('Confirm',()=>{if(!powerChoice||!input.checked||!powerAllowed(powerChoice.operation))return;const choice=powerChoice;command('power',{operation:choice.operation,confirmed:true},choice.revision);});submit.id='power-confirm';submit.className='power-submit';submit.disabled=true;row.append(cancel,submit);confirm.append(title,text,ack,row);card.append(confirm);parent.append(card); +} +function powerAllowed(operation){return !!state?.available&&!!state?.power?.available&&state.power.actions?.[operation]===true&&!posting&&!unknown&&!state.busy&&!state.experiment?.running&&state.mode!=='original'&&state.lights?.state!=='failed'&&!powerRequest?.accepted;} +function syncPower(){ + if(!$('panel-power-controls'))return; + for(const operation of ['reboot','shutdown'])$('power-'+operation).disabled=!powerAllowed(operation); + if($('power-confirm'))$('power-confirm').disabled=!powerChoice||!$('power-ack').checked||!powerAllowed(powerChoice.operation); + let text=state?.power?.message||'Power controls unavailable. Ask the room host to check this panel.'; + if(powerRequest?.accepted&&state?.available&&state.power?.phase==='ready'&&state.session===powerRequest.session)text=powerRequest.operation==='reboot'?'Restart requested. Waiting for this panel to finish.':'Shutdown requested. Waiting for this panel to finish.'; + if(powerRequest?.accepted&&!state?.available)text=powerRequest.operation==='reboot'?'The panel is restarting. This website will reconnect when it is ready.':'The panel is shutting down. This website will disconnect until the panel is powered on again.'; + update('power-status',text); +} +function renderHelp(){if(!$('help-detail'))return;const topic=(state?.help||content.help)[chosenHelp];$('help-detail').replaceChildren();if(!topic)return; + const card=el('section',undefined,'card');for(const p of topic.text.split('\n\n'))card.append(el('p',p));$('help-detail').append(card); + if(topic.commands){$('help-detail').append(el('p','Room host reference · copy only. These commands are never run by this website.','subtle')); + const reference=topic.reference,steps=reference?.steps?.length?reference.steps:[{title:topic.title,commands:topic.commands,command_tokens:topic.command_tokens}]; + const head=el('div',undefined,'reference-head');head.append(el('div',reference?reference.machine+' · '+reference.effect:'Reference commands',['Start services','Restart computer'].includes(topic.title)?'reference-warning':'muted'),copyButton('Copy commands',topic.commands));$('help-detail').append(head); + for(const [index,step] of steps.entries()){ + const block=el('article',undefined,'command command-step'),bar=el('div',undefined,'command-head'),pre=el('pre'),code=el('code'); + bar.append(el('h3',(steps.length>1?(index+1)+'. ':'')+step.title),copyButton('Copy command',step.commands));block.append(bar); + if(step.why)block.append(el('p',step.why,'command-why')); + for(const token of step.command_tokens||[{text:step.commands,kind:'text'}])code.append(el('span',token.text,'syntax-'+token.kind)); + pre.append(code);block.append(pre); + if(step.expect){const expected=el('div',undefined,'command-expect');expected.append(el('strong','What to expect'),el('p',step.expect));block.append(expected);} + $('help-detail').append(block); + } + if(reference?.documentation){const a=el('a',reference.documentation.label,'documentation-link');a.href=reference.documentation.url;a.target='_blank';a.rel='noopener noreferrer';$('help-detail').append(a);} + } +} +function copyButton(label,value){return button(label,async event=>{const b=event.currentTarget;try{await copy(value);b.textContent='Copied';}catch(_){b.textContent='Select text to copy';}setTimeout(()=>{if(b.isConnected)b.textContent=label;},1800);});} +async function copy(value){ + if(navigator.clipboard&&window.isSecureContext){try{await navigator.clipboard.writeText(value);return;}catch(_){}} + const active=document.activeElement,input=el('textarea');input.value=value;input.className='visually-hidden';document.body.append(input);input.select();const ok=document.execCommand('copy');input.remove();active?.focus({preventScroll:true});if(!ok)throw Error('Copy unavailable'); +} +function legacy(root){root.append(el('h2','Legacy view'),el('p','Legacy is the original application on the touch panel. Its device controls remain on that panel.','muted')); + const preview=el('div',undefined,'legacy-preview'),head=el('div',undefined,'legacy-header');head.append(el('strong','Smart Conference Room'),el('span','Legacy view','badge'),button('Home',()=>command('home').then(ok=>{if(ok)location.hash='home';}),{icon:'home',action:'home'}));preview.append(head,el('p','The matching panel header stays visible above the original controls. Use Home to return to everyday lighting.'));root.append(preview); + root.append(button('Open Legacy on touch panel',()=>command('legacy'),{action:'legacy'}),el('p','Opening Legacy restores the starting light settings before handing over. Blind controls remain disabled here; do not operate the original blind controls.','subtle')); +} +function update(id,text){if($(id)&&$(id).textContent!==String(text))$(id).textContent=text;} +function sync(){ + const p=state?.preferences||{},s=state?.lights||{},available=state?.available===true; + const version=String(state?.app_version||content.app_version||'').match(/^\d+\.\d+\.\d+/)?.[0]||''; + update('app-version',version?'Version '+version:''); + if($('app-build')){const build=state?.build_commit;$('app-build').hidden=!/^[0-9a-f]{7,40}$/.test(build||'');update('app-build',build?'Build '+build.slice(0,7):'');} + if(state?.palette)window.SCRDesign.apply(state.palette,p.resolved_theme); + update('connection',!available&&powerRequest?.accepted?(powerRequest.operation==='reboot'?'Restarting panel':'Shutting down panel'):!available?'Panel unavailable':state.mode==='original'?'Legacy':state.busy?'Sending':state.can_control?'Ready':s.state==='failed'?'Uncertain':'Controls unavailable'); + if($('blind-group')){$('blind-group').hidden=p.blinds_display==='hidden';$('essentials').classList.toggle('single',p.blinds_display==='hidden');$('blind-group').classList.toggle('disabled-group',!state?.blinds?.available);update('blind-status',state?.blinds?.available?'Ready':'Disabled');} + update('blind-reason',state?.blinds?.reason||(!p.blinds_enabled?'Controls locked in settings':'Controller not connected')); + for(const b of document.querySelectorAll('[data-action]')){const a=b.dataset.action,lighting=['on','off','cct','preset','channels'].includes(a),session=['start','pause','previous','next','mark'].includes(a);b.disabled=posting||Boolean(unknown)||!available||state.busy||(a==='blind'?!state?.blinds?.available:lighting?!state.can_control:session?!state.can_session:false);} + for(const input of document.querySelectorAll('[data-range]')){const number=$('value-'+input.dataset.range);input.disabled=number.disabled=posting||Boolean(unknown)||!available||!state.can_control;if(!dragging){const k=input.dataset.range,value=k.startsWith('channel')?s.channels?.[Number(k.split('-')[1])]:s[k];if(value!==undefined){input.value=value;number.value=value;}}} + const preview=window.SCR_PREVIEW?.read(),previewReady=!!preview?.connected&&available&&state.can_control&&!posting&&!unknown&&!state.busy; + if($('gradient-apply')){$('gradient-apply').disabled=!previewReady;const note=$('gradient-apply').parentElement.querySelector('small');note.textContent=preview?.connected?'Offline simulation':'Fixture control not connected';} + if($('programme-start')){const programme=preview?.programme,running=programme?.phase==='running';$('programme-start').disabled=!previewReady||running;$('programme-stop').disabled=!previewReady||!running;update('programme-status',preview?.connected?(programme?programme.name+' · '+programme.phase+' · accelerated simulation':'Ready to preview · one cycle'):'Programme control not connected');$('programme-progress').hidden=!programme;$('programme-progress').value=programme?.progress||0;} + for(const b of document.querySelectorAll('[data-choice]'))b.classList.toggle('selected',String(p[b.dataset.choice])===b.dataset.value); + for(const b of document.querySelectorAll('[data-action=on],[data-action=off]'))b.classList.toggle('selected',available&&!posting&&!unknown&&s.power===b.dataset.action&&state.can_control); + for(const b of document.querySelectorAll('[data-preset]'))b.classList.toggle('selected',available&&!posting&&!unknown&&s.scene===b.dataset.preset&&state.can_control); + update('light-message',!available?'Panel unavailable. Controls are disabled.':state.experiment?.running?'Instructions are running. Pause Experiment mode before using lighting.':state.mode==='original'?'Use Home to return from Legacy.':s.message||'Lighting unavailable.');update('light-recent','Recent settings: '+(s.recent||'Not checked')+(s.confirmed_age_s===null||s.confirmed_age_s===undefined?'':' · '+(s.confirmed_age_s<60?'just now':Math.floor(s.confirmed_age_s/60)+' min ago'))); + const e=state?.experiment;if(e){update('profile-title',e.profile.title);update('instruction',e.profile.stages[e.stage]);update('elapsed',Math.floor(e.elapsed_s/60).toString().padStart(2,'0')+':'+(e.elapsed_s%60).toString().padStart(2,'0'));update('session-state','Instruction '+(e.stage+1)+' of '+e.profile.stages.length+' · '+(e.running?'Running':'Paused'));update('annotation',e.annotation); + if($('marks')&&!$('marks').children.length)for(const label of e.profile.annotations){const b=button(label,()=>command('mark',{label}),{action:'mark'});b.disabled=posting||Boolean(unknown)||!available||!state.can_session;$('marks').append(b);} + } + update('schedule','Light from '+minutes(p.light_at??420)+' · Dark from '+minutes(p.dark_at??1140)); + const h=state?.health;if(h){update('panel-memory',h.error?'Resource reading unavailable':`Memory: ${h.ram_available??'—'} / ${h.ram_total??'—'} MiB free`);update('panel-storage',h.error?'':`Storage: ${h.disk_available??'—'} GiB free · CPU: ${h.temperature??'—'} °C`);update('panel-power',h.power?'Power: '+h.power:'Power reading unavailable');$('panel-power')?.classList.toggle('system-warning',h.power?.startsWith('Current')===true);}update('room-health',state?.room_status||'No current room observation.'); + syncPower();weather(); +} +function minutes(value){return Math.floor(value/60).toString().padStart(2,'0')+':'+(value%60).toString().padStart(2,'0');} +function weather(){ + const p=state?.preferences||{},value=state?.weather,tz=state?.timezone||'America/New_York',now=new Date(); + update('clock',new Intl.DateTimeFormat('en-US',{timeZone:tz,hour:'numeric',minute:'2-digit',hour12:p.clock!=='24'}).format(now));update('date',new Intl.DateTimeFormat('en-US',{timeZone:tz,weekday:'long',year:'numeric',month:'long',day:'numeric'}).format(now)); + if(!value){update('condition','Weather unavailable');update('temperature','—');update('weather-source','No current outdoor reading. Outdoor weather is separate from room sensors.');if($('weather-stats'))$('weather-stats').hidden=true;if($('weather-notice')){$('weather-notice').hidden=false;update('weather-notice','No current outdoor reading.');}if($('daylight'))$('daylight').hidden=true;if($('weather-art')){$('weather-art').replaceChildren();delete $('weather-art').dataset.symbol;}return;} + const parts=Object.fromEntries(new Intl.DateTimeFormat('en-CA',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}).formatToParts(now).map(v=>[v.type,v.value])); + const today=`${parts.year}-${parts.month}-${parts.day}`,local=`${today}T${parts.hour}:${parts.minute}`,current=value.sunrise.slice(0,10)===today&&value.sunset.slice(0,10)===today,night=current&&(local=value.sunset),old=!current||value.model_time.slice(0,10)!==today||(state.weather_age_s??Infinity)>1800; + const unit=p.temperature||'f',convert=v=>unit==='c'?(v-32)*5/9:v,degree=v=>Math.round(convert(v))+'°';update('temperature',degree(value.temperature_f)+' '+unit.toUpperCase());update('condition',value.condition); + if($('weather-stats'))$('weather-stats').hidden=old;if($('weather-notice'))$('weather-notice').hidden=!old;update('weather-notice',old?'Earlier reading: '+value.model_time.replace('T',' '):''); + for(const [key,reading,isRain] of [['feels',value.feels_like_f,false],['rain',value.rain_chance,true],['high',value.high_f,false],['low',value.low_f,false]])update('weather-'+key,Number.isFinite(reading)?(isRain?Math.round(reading)+'%':degree(reading)):'—'); + update('weather-source','Open-Meteo · model '+value.model_time.replace('T',' ')+' · Outdoor weather is separate from room sensors.'); + let name=value.code===0?(night?'moon':'sun'):[1,2].includes(value.code)?(night?'cloud-moon':'cloud-sun'):value.code===3?'cloud':[45,48].includes(value.code)?'cloud-fog':[71,73,75,77,85,86].includes(value.code)?'cloud-snow':[95,96,99].includes(value.code)?'cloud-lightning':[51,53,55,56,57,61,63,65,66,67,80,81,82].includes(value.code)?'cloud-rain':'circle-question-mark';if(!current&&[0,1,2].includes(value.code))name='circle-question-mark'; + if($('weather-art')){$('weather-art').style.color=state?.palette?.[content.weather_accents?.[name]||'icon_colour']||'var(--icon-colour)';if($('weather-art').dataset.symbol!==name){$('weather-art').replaceChildren(icon('weather-'+name));$('weather-art').dataset.symbol=name;}} + if($('daylight')){$('daylight').hidden=old;if(!old){const minute=s=>Number(s.slice(11,13))*60+Number(s.slice(14,16)),rise=minute(value.sunrise),set=minute(value.sunset),time=Number(parts.hour)*60+Number(parts.minute);$('daylight-fill').style.width=(Math.max(0,Math.min(1,(time-rise)/(set-rise)))*100)+'%';const remaining=(time=set?'After sunset · night':`Sun ${timer.id===unknown); + if(receipt){ + if(powerRequest?.id===unknown){ + if(receipt.ok){powerRequest.accepted=true;powerChoice=null;if($('power-confirmation'))$('power-confirmation').hidden=true;message('The panel received the power request. Check its progress below.');} + else{powerRequest=null;message(receipt.detail||'Power request was not accepted.');} + }else message('The panel received the command. Check its current confirmation below.'); + unknown=null; + } + if(powerRequest&&state.power?.phase==='failed'){powerRequest=null;message(state.power.message||'Power operation stopped. The panel remains available.');} + if(powerRequest?.accepted&&state.available===true&&state.session!==powerRequest.session&&state.power?.phase==='ready'){const operation=powerRequest.operation;powerRequest=null;message(operation==='reboot'?'Panel restarted. This website is connected again.':'The panel is back online. This website is connected again.');} + if(state.guide)content={...content,guide:state.guide,help:state.help}; + if(!view||(!$('help-topic')&&view==='tools-system')||(view.startsWith('guide')&&!$('app').children.length)){view='';layout();} + sync(); + }catch(_){ + if(state)state.available=false; + if(powerRequest?.accepted)message(powerRequest.operation==='reboot'?'The panel is restarting. This website will reconnect when it is ready.':'The panel is shutting down. This website will disconnect until the panel is powered on again.'); + sync(); + }finally{fetching=false;} +} +document.addEventListener('click',event=>{const a=event.target.closest('a[href="#home"]');if(a&&state?.available&&state.mode!=='room'){event.preventDefault();command('home').then(ok=>{if(ok)location.hash='home';});}}); +window.addEventListener('hashchange',layout);document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh();}); +window.addEventListener('scr-preview-change',()=>{const name=window.SCR_PREVIEW?.read().programme?.name;if(name)selectedProgramme=name;view='';layout();}); +(async()=>{try{content=await(await fetch('/room-content.json')).json();}catch(_){}layout();await refresh();setInterval(refresh,1000);setInterval(weather,1000);})(); diff --git a/browser/scr-browser-status.service b/browser/scr-browser-status.service new file mode 100644 index 0000000..9767ef4 --- /dev/null +++ b/browser/scr-browser-status.service @@ -0,0 +1,17 @@ +[Unit] +Description=Read-only Smart Conference Room website +StartLimitIntervalSec=0 + +[Service] +Type=simple +EnvironmentFile=%h/.config/scr-browser-status/deployment.env +ExecStart=/bin/sh %h/scr-browser-status/current/browser/start.sh +Restart=on-failure +RestartSec=15 +KillSignal=SIGINT +TimeoutStopSec=15 +NoNewPrivileges=yes +Nice=5 + +[Install] +WantedBy=default.target diff --git a/browser/serve.py b/browser/serve.py new file mode 100644 index 0000000..198e8a4 --- /dev/null +++ b/browser/serve.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""SCR browser interface with read-only diagnostics and an optional panel gateway. + +Python 3.11+, no installed dependencies. + +Author: Diyanko Bhowmik, with Codex assistance. No control adapter is imported. +""" +import argparse +import datetime as dt +import http.client +from http.server import BaseHTTPRequestHandler, HTTPServer +import ipaddress +import json +import secrets +import hmac +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import threading +import time +from urllib.parse import urlsplit +import xmlrpc.client + +ROOT = Path(__file__).resolve().parent +GROUPS = { + 'Lighting': {'/get_lights', '/get_sources', '/cct_all'}, + 'Blinds': {'/get_blinds', '/lift_all', '/tilt_all'}, + 'Environment': {'/get_temp', '/get_rh'}, + 'Time-of-flight': {'/get_distances', '/get_distances_all'}, +} +ASSETS = {'/': ('index.html', 'text/html'), '/styles.css': ('styles.css', 'text/css'), + '/status.js': ('status.js', 'text/javascript'), + '/design.js': ('design.js','text/javascript'), '/theme.js': ('theme.js','text/javascript'), + '/room.js': ('room.js','text/javascript'), '/room.css': ('room.css','text/css'), + '/tools': ('tools.html','text/html'), '/room-content.json': ('room-content.json','application/json'), + '/diagnostics': ('diagnostics.html', 'text/html'), '/guide': ('guide.html', 'text/html'), + '/logo.jpg': ('lesa-logo.jpeg', 'image/jpeg')} + +# Explicit static artwork allowlist; never resolve request paths from the filesystem. +ASSETS.update({ + '/fonts.css': ('fonts.css','text/css'), + '/fonts/Cantarell-Regular.otf': ('fonts/Cantarell-Regular.otf','font/otf'), + '/fonts/Cantarell-Bold.otf': ('fonts/Cantarell-Bold.otf','font/otf'), + '/icons/book-open.svg': ('icons/book-open.svg', 'image/svg+xml'), + '/icons/circle-question-mark-light.png': ('icons/circle-question-mark-light.png', 'image/png'), + '/icons/cloud-fog-light.png': ('icons/cloud-fog-light.png', 'image/png'), + '/icons/cloud-light.png': ('icons/cloud-light.png', 'image/png'), + '/icons/cloud-lightning-light.png': ('icons/cloud-lightning-light.png', 'image/png'), + '/icons/cloud-moon-light.png': ('icons/cloud-moon-light.png', 'image/png'), + '/icons/cloud-rain-light.png': ('icons/cloud-rain-light.png', 'image/png'), + '/icons/cloud-snow-light.png': ('icons/cloud-snow-light.png', 'image/png'), + '/icons/cloud-sun-light.png': ('icons/cloud-sun-light.png', 'image/png'), + '/icons/lightbulb-off.svg': ('icons/lightbulb-off.svg', 'image/svg+xml'), + '/icons/lightbulb.svg': ('icons/lightbulb.svg', 'image/svg+xml'), + '/icons/moon-light.png': ('icons/moon-light.png', 'image/png'), + '/icons/sliders-horizontal.svg': ('icons/sliders-horizontal.svg', 'image/svg+xml'), + '/icons/sun-light.png': ('icons/sun-light.png', 'image/png'), +}) + + +def utc(): + return dt.datetime.now(dt.timezone.utc).isoformat() + + +def private_address(value): + address = ipaddress.IPv4Address(value) + if address.is_unspecified or address.is_multicast or not (address.is_private or address.is_loopback): + raise ValueError('Use one explicit private or loopback IPv4 address') + return str(address) + + +def master_url(value): + parsed = urlsplit(value) + if (parsed.scheme != 'http' or not parsed.hostname or parsed.username or + parsed.password or parsed.query or parsed.fragment or parsed.path not in ('', '/')): + raise ValueError('Use an approved http://PRIVATE_IP:PORT room master') + private_address(parsed.hostname) + if parsed.port is None or not 1 <= parsed.port <= 65535: + raise ValueError('The room master needs an explicit port') + return value + + +def client_network(value): + network = ipaddress.IPv4Network(value, strict=True) + private_address(str(network.network_address)) + private_address(str(network.broadcast_address)) + return network + + +class RoomServer(HTTPServer): + def verify_request(self, request, client_address): + # Use the socket peer, never a caller-supplied forwarding header. + return ipaddress.IPv4Address(client_address[0]) in self.allowed_clients + + +def host_status(): + """Resources belong to this server host, not every room computer.""" + result = {'memory_available_mib': None, 'memory_total_mib': None, + 'uptime_s': None, 'temperature_c': None, 'power': 'unavailable'} + try: + memory = {line.split(':')[0]: int(line.split()[1]) + for line in Path('/proc/meminfo').read_text().splitlines()} + result.update(memory_available_mib=memory['MemAvailable'] // 1024, + memory_total_mib=memory['MemTotal'] // 1024) + result['uptime_s'] = float(Path('/proc/uptime').read_text().split()[0]) + except (OSError, ValueError, KeyError): + pass + try: + result['temperature_c'] = float(Path('/sys/class/thermal/thermal_zone0/temp').read_text()) / 1000 + except (OSError, ValueError): + pass + disk = shutil.disk_usage(Path.cwd().anchor or '/') + result.update(disk_available_gib=round(disk.free / 2**30, 1), + disk_used_percent=round(100 * disk.used / disk.total, 1)) + utility = shutil.which('vcgencmd') + if utility: + try: + reply = subprocess.run([utility, 'get_throttled'], capture_output=True, + text=True, timeout=2, check=True) + flags = int(reply.stdout.strip().split('=')[1], 16) + result['power'] = 'warning_now' if flags & 1 else 'warning_earlier' if flags & 0x10000 else 'no_warning' + except (OSError, ValueError, IndexError, subprocess.SubprocessError): + pass + return result + + +def service_graph(master): + """Only getSystemState; no ROS node, service call, registration or publisher.""" + parsed = urlsplit(master) + connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=2) + request = xmlrpc.client.dumps(('scr_browser_status',), methodname='getSystemState').encode() + try: + connection.request('POST', '/', body=request, headers={'Content-Type': 'text/xml'}) + response = connection.getresponse() + body = response.read(262145) + if response.status != 200 or len(body) > 262144 or b' document.getElementById(id); +const text = (id, value) => { const node = byId(id); if (node && node.textContent !== value) node.textContent = value; }; +const numeric = value => typeof value === 'number' && Number.isFinite(value); +const diagnostic = document.body.dataset.view === 'diagnostics'; +const roomTime = new Intl.DateTimeFormat('en-US', {timeZone:'America/New_York',hour:'numeric',minute:'2-digit'}); +const roomDate = new Intl.DateTimeFormat('en-US', {timeZone:'America/New_York',weekday:'long',month:'long',day:'numeric',year:'numeric'}); +const roomParts = new Intl.DateTimeFormat('en-CA', {timeZone:'America/New_York',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}); +const age = seconds => seconds < 60 ? `${Math.floor(seconds)} seconds ago` : `${Math.floor(seconds/60)} minutes ago`; +function localTime(value) { const [h,m] = value.slice(11,16).split(':').map(Number); return `${h%12||12}:${String(m).padStart(2,'0')} ${h<12?'AM':'PM'}`; } +function badge(node, value, colour='') { if (!node) return; node.textContent=value; node.className=(node.id==='connection'||node.id==='room-connection'?'connection':'service-state')+' '+colour; } +let snapshot=null, receivedAt=0, pending=false, failed=false; +function weatherIcon(code, night=false) { + if (code===0) return night?'moon':'sun'; + if ([1,2].includes(code)) return night?'cloud-moon':'cloud-sun'; + if (code===3) return 'cloud'; + if ([45,48].includes(code)) return 'cloud-fog'; + if ([51,53,55,56,57,61,63,65,66,67,80,81,82].includes(code)) return 'cloud-rain'; + if ([71,73,75,77,85,86].includes(code)) return 'cloud-snow'; + if ([95,96,99].includes(code)) return 'cloud-lightning'; + return 'circle-question-mark'; +} +function setWeatherIcon(name) { + const icon=byId('weather-icon'); + if(icon && icon.getAttribute('src')!==`/icons/${name}-light.png`) icon.setAttribute('src',`/icons/${name}-light.png`); +} +function weather(elapsed) { + const info=snapshot.weather || {state:'unconfigured',data:null}; + const value=info.data; + if (!value) { + setWeatherIcon('circle-question-mark'); + text('weather-temperature','—'); text('weather-condition',info.state==='unconfigured'?'Weather not configured':'Weather unavailable'); + text('weather-source','Weather is optional.'); + text('weather-model','No weather reading is available.'); + text('weather-secondary','Outdoor weather is separate from room sensors.'); + text('weather-daily',''); + if (byId('daylight')) byId('daylight').hidden=true; + return; + } + const parts=Object.fromEntries(roomParts.formatToParts(new Date()).map(item=>[item.type,item.value])); + const today=`${parts.year}-${parts.month}-${parts.day}`; + const stale=failed||info.state!=='available'||!numeric(info.observation_age_s)||info.observation_age_s+elapsed>1800||value.model_time.slice(0,10)!==today; + text('weather-temperature',`${Math.round(value.temperature_f)} °F`); + text('weather-condition',value.condition); + text('weather-secondary',stale?'Earlier outdoor reading':numeric(value.feels_like_f)?`Feels like ${Math.round(value.feels_like_f)} °F`:`${Math.round(value.temperature_c)} °C · Troy outdoors`); + const daily=[]; + if (numeric(value.high_f)) daily.push(`High ${Math.round(value.high_f)}°`); + if (numeric(value.low_f)) daily.push(`Low ${Math.round(value.low_f)}°`); + if (numeric(value.rain_chance)) daily.push(`Rain today ${Math.round(value.rain_chance)}%`); + text('weather-daily',stale?'':daily.join(' · ')); + text('weather-source',stale?'Earlier weather':''); + text('weather-model',`Open-Meteo · Model ${value.model_time.slice(0,10)} at ${localTime(value.model_time)} · Outdoor weather is separate from room sensors.`); + const daylightCurrent=value.sunrise.slice(0,10)===today&&value.sunset.slice(0,10)===today; + const localNow=`${today}T${parts.hour}:${parts.minute}`; + const night=daylightCurrent&&(localNow=value.sunset); + setWeatherIcon(!daylightCurrent&&[0,1,2].includes(value.code)?'circle-question-mark':weatherIcon(value.code,night)); + const daylight=byId('daylight'); + if (!daylight) return; + daylight.hidden=stale||value.sunrise.slice(0,10)!==today||value.sunset.slice(0,10)!==today; + if (daylight.hidden) return; + const minutes=value=>Number(value.slice(11,13))*60+Number(value.slice(14,16)); + const rise=minutes(value.sunrise), set=minutes(value.sunset), now=Number(parts.hour)*60+Number(parts.minute); + const progress=Math.max(0,Math.min(1,(now-rise)/(set-rise))); + byId('daylight-fill').style.width=`${progress*100}%`; + text('sunrise',`Sunrise ${localTime(value.sunrise)}`); text('sunset',`Sunset ${localTime(value.sunset)}`); + const remaining=(now=set?'After sunset · Night':`${now90; + const host=snapshot.host, room=snapshot.room; + badge(byId('connection'),failed?'Page connection lost':stale?'Readings are stale':'Updating normally',stale?'warning':'good'); + text('freshness',`Computer readings ${age(sampleAge)}${stale?' · not current':''}.`); + text('host-label',snapshot.host_label); + text('source-description',room.state==='unconfigured'?`Host-only preview · These readings are from ${snapshot.host_label}, not the cabinet Pi.`:`Host readings from ${snapshot.host_label}. Room interfaces are a separate observation.`); + text('memory',numeric(host.memory_available_mib)?`${host.memory_available_mib} MiB`:'Unavailable'); + text('memory-note',numeric(host.memory_total_mib)?`Available of ${host.memory_total_mib} MiB total`:'Not supported on this host'); + text('temperature',numeric(host.temperature_c)?`${host.temperature_c.toFixed(1)} °C`:'Unavailable'); + const power={warning_now:'Low voltage now',warning_earlier:'Earlier low voltage',no_warning:'No voltage warning',unavailable:'Not available'}; + text('power',power[host.power]||'Not available'); + byId('power').className=`power-value ${String(host.power).startsWith('warning')?'warning':''}`; + text('uptime',numeric(host.uptime_s)?`Uptime · ${(host.uptime_s/3600).toFixed(1)} hours`:'Uptime unavailable'); + const graphStale=stale||room.state!=='available'||!numeric(room.observation_age_s)||room.observation_age_s+elapsed>90; + badge(byId('room-connection'),room.state==='unconfigured'?'Not configured':room.state==='available'&&!graphStale?'Service list received':'Current availability unknown',graphStale?'':'good'); + document.querySelectorAll('[data-service]').forEach(card=>{ + const service=room.services.find(item=>item.name===card.dataset.service); + const labels={advertised:'Service listed',partial:'Some services listed',not_advertised:'Not listed'}; + const label=service?labels[service.state]||'Not checked':'Not checked'; + badge(card.querySelector('.service-state'),service&&graphStale?`Previous: ${label.toLowerCase()}`:label,graphStale?'':service?.state==='advertised'?'good':'warning'); + }); + text('room-note',room.observed_at?`Last service list ${age(room.observation_age_s+elapsed)}${graphStale?' · previous observation':''}.`:'No successful service-list observation.'); +} +async function refresh() { + if (pending||document.hidden) return; + pending=true; + const controller=new AbortController(), timer=setTimeout(()=>controller.abort(),4000); + try { + const response=await fetch('/api/status',{signal:controller.signal,cache:'no-store'}); + if (!response.ok) throw new Error('Unavailable'); + const next=await response.json(); + if (next.version!==1||next.read_only!==true||!next.host||!next.room||!Array.isArray(next.room.services)||!numeric(next.sample_age_s)) throw new Error('Invalid status'); + snapshot=next; receivedAt=performance.now(); failed=false; + } catch (_) { failed=true; } + finally {clearTimeout(timer);pending=false;render();} +} +render(); refresh(); setInterval(render,1000); setInterval(refresh,30000); +document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh();}); diff --git a/browser/styles.css b/browser/styles.css new file mode 100644 index 0000000..70f3fba --- /dev/null +++ b/browser/styles.css @@ -0,0 +1,129 @@ +:root { + color-scheme: light; + --ink: #12384b; + --muted: #476373; + --line: #cbd6df; + --soft: #edf1f4; + --accent: #176b72; + --good: #26744b; + --warning: #92580c; +} +* { box-sizing: border-box; } +body { margin: 0; background: #fff; color: var(--ink); font: 17px/1.5 Cantarell, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +h1,h2,h3,p { margin: 0; } +h1 { font-size: clamp(1.4rem,3vw,2.1rem); line-height: 1.2; } +h2 { font-size: 1.35rem; line-height: 1.3; } +h3 { font-size: 1.05rem; } +p { overflow-wrap: anywhere; } +a { color: var(--accent); text-underline-offset: 4px; } +a:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } +.shell { width: min(1180px,100%); margin: auto; padding-inline: clamp(16px,3vw,36px); } +.header { display: flex; align-items: center; gap: 24px; padding-block: 18px; } +.brand { width: 140px; max-width: 100%; background: #fff; aspect-ratio: 5/4; overflow: hidden; flex-shrink: 0; } +.brand img { display: block; width: 120%; height: auto; margin-left: -6%; margin-top: -20%; } +.title-line { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +.beta { color: var(--accent); background: #edf1f4; border-radius: 4px; padding: 3px 9px; font-size: .95rem; } +.weather-details summary { color: var(--accent); font-size: .8rem; cursor: pointer; min-height: 44px; padding-block: 10px; } +.heading { flex: 1; min-width: 0; padding-top: 18px; } +.heading p { margin-top: 7px; color: var(--muted); } +.trial { border: 1px solid var(--line); border-radius: 4px; padding: 5px 10px; font-size: .82rem; color: var(--muted); white-space: nowrap; } +.navigation { display: flex; gap: 26px; border-block: 1px solid var(--line); } +.navigation a { display: inline-flex; align-items: center; min-height: 50px; text-decoration: none; color: var(--muted); border-bottom: 3px solid transparent; font-weight: 600; } +.navigation a[aria-current] { color: var(--accent); border-bottom-color: var(--accent); } +main { padding-block: 26px; } +.intro { margin-bottom: 24px; } +.intro p { margin-top: 8px; color: var(--muted); max-width: 80ch; } +.overview { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; } +.clock-card,.weather-card { min-width: 0; border: 1px solid var(--line); border-radius: 8px; padding: 24px; background: var(--soft); } +.eyebrow { color: var(--muted); font-size: .84rem; font-weight: 600; } +.clock { font-size: clamp(2.8rem,6.5vw,5.2rem); line-height: 1.2; font-weight: 500; letter-spacing: -.03em; font-variant-numeric: tabular-nums; margin: 12px 0; } +.small { color: var(--muted); font-size: .8rem; margin-top: 10px; } +.weather-heading { display: flex; justify-content: space-between; align-items: center; } +.weather-icon { width: 64px; height: 44px; fill: #fff; stroke: var(--accent); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.weather-icon circle { fill: #f0c477; stroke: #c99431; } +.weather-reading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 16px; } +.weather-reading strong { font-size: 3rem; font-weight: 500; font-variant-numeric: tabular-nums; } +.weather-reading span { font-size: 1.15rem; } +.muted,.section-note { color: var(--muted); font-size: .9rem; } +.daylight { margin-top: 18px; } +.daylight-labels { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font-size: .78rem; } +.daylight-track { height: 5px; margin: 8px 0; background: #dedfdc; border-radius: 2px; overflow: hidden; } +#daylight-fill { height: 100%; width: 0; background: var(--accent); } +#daylight-note { font-size: .85rem; color: var(--muted); } +.section-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin: 28px 0 14px; } +.section-heading a { font-size: .9rem; } +.capabilities,.host-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 16px; } +.feature,.card { border: 1px solid var(--line); border-radius: 7px; padding: 20px; min-width: 0; } +.feature h3 { margin: 10px 0 7px; } +.feature p { color: var(--muted); font-size: .9rem; } +.feature-label { font-size: .78rem; font-weight: 600; } +.warm { color: #8e5b18; } .blue { color: var(--accent); } .green { color: #26744b; } +.preview-note { margin: 24px 0 0; color: var(--muted); font-size: .82rem; } +.footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; border-top: 1px solid var(--line); padding-block: 20px; color: var(--muted); font-size: .82rem; } +.source-note { background: var(--soft); border-left: 3px solid var(--accent); padding: 12px 16px; margin: 0 0 20px; font-size: .9rem; } +.diagnostic-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; } +.diagnostic-summary > div { background: var(--soft); border: 1px solid var(--line); border-radius: 7px; padding: 20px; } +.connection { font-size: 1.25rem; font-weight: 600; margin: 8px 0; } +.connection::before,.service-state::before { content: ''; display: inline-block; width: 8px; height: 8px; border: 2px solid currentColor; border-radius: 50%; margin-right: 8px; vertical-align: middle; } +.service-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 16px; } +.service-state { font-weight: 600; margin: 10px 0 6px; font-size: .94rem; } +.good { color: var(--good); } .warning { color: var(--warning); } +.value { font-size: 2rem; line-height: 1.2; margin: 10px 0; font-variant-numeric: tabular-nums; } +.power-value { font-size: 1.15rem; font-weight: 600; margin: 10px 0; } +.host-grid { margin-top: 16px; } +.guide-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 28px; } +.guide p { margin-top: 8px; color: var(--muted); max-width: 85ch; } +.guide-row { padding-block: 18px; border-bottom: 1px solid var(--line); } +.everyday-home { padding-block: 38px 22px; } +.everyday-layout { display: grid; grid-template-columns: minmax(0,.95fr) minmax(0,1.05fr); gap: 54px; align-items: center; min-height: 440px; } +.essentials { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px; } +.essential-group .eyebrow { margin-top: 7px; } +.essential-actions { display: grid; gap: 14px; margin-top: 22px; } +.essential-actions button { min-height: 106px; width: 100%; padding: 16px 8px; border: 0; border-radius: 7px; background: var(--soft); color: var(--muted); font: inherit; font-size: 1.25rem; } +.essential-group .small { min-height: 44px; } +.everyday-weather { border-left: 1px solid var(--line); padding-left: 40px; min-width: 0; } +.everyday-weather .clock { font-size: clamp(3rem,6.6vw,5.5rem); } +.everyday-date { font-size: 1.15rem; } +.everyday-weather .weather-heading { margin-top: 30px; } +.everyday-weather .weather-reading strong { font-size: 3.2rem; } +#weather-daily { margin-top: 5px; } +.home-tools { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 14px; padding-top: 22px; margin-top: 24px; border-top: 1px solid var(--line); } +.home-tools a { display: inline-flex; align-items: center; min-height: 52px; padding: 10px 20px; border: 1px solid var(--line); border-radius: 6px; text-decoration: none; } +@media(max-width:900px) { + .everyday-layout { gap: 30px; } + .everyday-weather { padding-left: 24px; } + .brand { width: 120px; } + .header { gap: 18px; } + .service-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } +} +@media(max-width:600px) { + .everyday-layout { grid-template-columns: 1fr; gap: 30px; } + .everyday-weather { border-left: 0; border-top: 1px solid var(--line); padding: 24px 0 0; } + .essential-actions button { min-height: 88px; font-size: 1.05rem; } + .home-tools a { padding-inline: 12px; } + body { font-size: 16px; } + .brand { width: 92px; } + .header { display: grid; grid-template-columns: 92px minmax(0,1fr); gap: 8px 14px; } + .brand { grid-row: 1 / span 2; } + .trial { grid-column: 2; justify-self: start; } + .navigation { gap: 20px; } + .overview,.diagnostic-summary,.capabilities,.host-grid,.guide-grid { grid-template-columns: 1fr; gap: 14px; } + .clock-card,.weather-card,.feature,.card { padding: 20px; } + .clock { font-size: 3rem; } +} +@media(max-width:370px) { + .service-grid { grid-template-columns: 1fr; } + .clock { font-size: 2.6rem; } +} + +.weather-icon { width: 96px; height: 96px; object-fit: contain; } +.control-icon { width: 24px; height: 24px; flex-shrink: 0; } +.essential-actions button, .home-tools a { display: flex; align-items: center; justify-content: center; gap: 10px; } + +body{background:var(--bg,#fff)} +[data-theme=dark] .warm{color:var(--ink)} +.beta{background:var(--soft);color:var(--muted)} +.brand{width:116px;height:66px;aspect-ratio:auto} +.brand img{width:124.14%;margin-left:-7.59%;margin-top:-22.07%} +.header,.title-line{align-items:flex-end}.heading{padding-top:0}.beta{border-radius:0} +@media(max-width:600px){.header{grid-template-columns:88px minmax(0,1fr)}.brand{width:88px;height:50px;align-self:start}} diff --git a/browser/theme.js b/browser/theme.js new file mode 100644 index 0000000..14009ea --- /dev/null +++ b/browser/theme.js @@ -0,0 +1,3 @@ +/* Extra diagnostics remain observation-only and share the panel's visual roles. */ +(()=>{async function refresh(){try{const response=await fetch('/api/controller',{cache:'no-store'});const value=await response.json();window.SCRDesign.apply(value.palette,value.preferences?.resolved_theme);}catch(_){}} +refresh();setInterval(refresh,15000);})(); diff --git a/browser/tools.html b/browser/tools.html new file mode 100644 index 0000000..bfee5d1 --- /dev/null +++ b/browser/tools.html @@ -0,0 +1 @@ +Smart Conference Room
LESA

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/docs/interface.md b/docs/interface.md index 1a7dcc9..60a8fcb 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -1,8 +1,8 @@ # Panel interfaces, version 1 -These contracts serve the existing Tk client and optional SCR light helper. -They are deliberately small. No registry, plugin loader, network API or recorder -service is implemented. +These contracts serve the existing Tk client, optional SCR light helper and the +read-only browser status page. No registry, plugin loader, browser control API or +recorder service is implemented. ## Study profile @@ -75,3 +75,41 @@ service graph through one XML-RPC call, then exits without a ROS node or fixture queries. The UI bounds that subprocess, refreshes only while System is visible and labels the result as advertised service availability. It does not query TOF, blinds, HVAC or acquisition data, and does not assess IRMA recorder health. + +## Browser status, version 1 + +`GET /api/status` returns `version: 1`, `read_only: true`, a public `host_label`, +UTC `sampled_at`, monotonic `sample_age_s`, `host` resources and a `room` observation. +The endpoint reads a cached snapshot; it never initiates a room query. + +Overview, Diagnostics and Guide share this contract. They do not depend on ROS +paths or client libraries. Diagnostics is currently readable by anyone on the +trusted LAN; choosing a view does not grant a role or permission to control devices. + +Host fields are `memory_available_mib`, `memory_total_mib`, `disk_available_gib`, +`disk_used_percent`, `uptime_s`, `temperature_c` and `power`. Unsupported readings +are null; power is `unavailable`, `no_warning`, `warning_earlier` or `warning_now`. +These resources belong to the server host. Power reflects Pi undervoltage flags, +not a physical voltage measurement. + +Room `state` is `unconfigured`, `available` or `unavailable`. `observed_at` and +`observation_age_s` identify the last successful service-graph read; both are null +until one succeeds. The fixed service groups have `name` and `state`: `advertised`, +`partial` or `not_advertised`. A failed read retains the previous observations and +their age; clients must not display them as current availability. Host/graph +observations older than 90 seconds are stale. Clock time is not sensor timing. + +Optional `weather` has `state` (`unconfigured`, `available`, `unavailable`), +`observed_at`, monotonic `observation_age_s` and `data`. Data uses the shared +`app/weather.py` fields: `temperature_f`, `temperature_c`, `condition`, `code`, +`sunrise`, `sunset` and `model_time`. Provider times are dated local Troy times; +`observed_at` is UTC receipt time, not an acquisition timestamp. Weather failures +retain values and their original age. Clients label failed/old values and hide +daylight for another date or observations older than 30 minutes. Weather is model +data independent of indoor sensors. Older clients may ignore this additional field. + +The sampler makes only `getSystemState` requests to a privately configured master. +POST/PUT/PATCH/DELETE/OPTIONS return 405; other routes are an explicit static-asset +allowlist. Host/Origin checks and same-origin browser requests provide a limited +visitor boundary, not authentication. This API has no experimental metadata, +device state, actuator commands, control ownership or staff role implementation. diff --git a/tests/test_browser_status.py b/tests/test_browser_status.py new file mode 100644 index 0000000..fcc73bd --- /dev/null +++ b/tests/test_browser_status.py @@ -0,0 +1,208 @@ +"""Read-only boundary and freshness checks; no room hardware is used.""" +import http.client +import importlib.util +import json +from pathlib import Path +import socket +import threading +import time +from unittest.mock import patch +from xmlrpc.server import SimpleXMLRPCServer + +import pytest + +spec = importlib.util.spec_from_file_location('room_status', Path(__file__).parents[1] / 'browser/serve.py') +status = importlib.util.module_from_spec(spec) +spec.loader.exec_module(status) + + +def test_graph_calls_only_master_state_and_preserves_unknown_results(): + methods = [] + master = SimpleXMLRPCServer(('127.0.0.1', 0), logRequests=False) + def graph(caller): + methods.append(('getSystemState', caller)) + return [1, '', [[], [], [[name, ['existing-server']] for name in status.GROUPS['Lighting']]]] + master.register_function(graph, 'getSystemState') + thread = threading.Thread(target=master.serve_forever, daemon=True) + thread.start() + try: + result = status.service_graph('http://127.0.0.1:%d' % master.server_address[1]) + assert methods == [('getSystemState', 'scr_browser_status')] + assert result[0] == {'name': 'Lighting', 'state': 'advertised'} + assert result[1]['state'] == 'not_advertised' + finally: + master.shutdown() + master.server_close() + thread.join(timeout=2) + + +def test_failure_retains_original_observation_age(): + snapshot = status.Snapshot('Cabinet Pi', 'http://127.0.0.1:11311') + with patch.object(status, 'host_status', return_value={}), patch.object(status, 'bounded_graph', return_value=[{'name': 'Lighting', 'state': 'advertised'}]): + snapshot.collect() + snapshot.graph_time -= 100 + with patch.object(status, 'host_status', return_value={}), patch.object(status, 'bounded_graph', side_effect=OSError): + snapshot.collect() + value = snapshot.read() + assert value['room']['state'] == 'unavailable' + assert value['room']['services'][0]['state'] == 'advertised' + assert value['room']['observation_age_s'] >= 100 + assert value['sample_age_s'] < 1 + + +@pytest.fixture +def server(): + snapshot = status.Snapshot('Test host') + with patch.object(status, 'host_status', return_value={}): + snapshot.collect() + instance = status.RoomServer(('127.0.0.1', 0), status.Handler) + instance.allowed_clients = status.client_network('127.0.0.0/8') + instance.expected_host = '%s:%d' % instance.server_address + instance.snapshot = snapshot + thread = threading.Thread(target=instance.serve_forever, daemon=True) + thread.start() + yield instance + instance.shutdown() + instance.server_close() + thread.join(timeout=2) + + +def request(server, method, path, headers=None, body=None): + connection = http.client.HTTPConnection(*server.server_address, timeout=7) + try: + connection.request(method, path, body=body, headers=headers or {}) + response = connection.getresponse() + return response.status, response.read(), dict(response.headers) + finally: + connection.close() + + +def test_http_boundary_and_cache(server): + with patch.object(server.snapshot, 'collect', side_effect=AssertionError('HTTP must not poll room')): + code, body, headers = request(server, 'GET', '/api/status') + assert code == 200 and json.loads(body)['read_only'] is True + assert headers['Cache-Control'] == 'no-store' + assert 'Access-Control-Allow-Origin' not in headers + assert "frame-ancestors 'none'" in headers['Content-Security-Policy'] + code, image, headers = request(server, 'GET', '/logo.jpg') + assert code == 200 and image.startswith(b'\xff\xd8') + assert headers['Content-Type'] == 'image/jpeg' + code, image, headers = request(server, 'GET', '/icons/cloud-moon-light.png') + assert code == 200 and image.startswith(b'\x89PNG\r\n\x1a\n') + assert headers['Content-Type'] == 'image/png' + code, icon, headers = request(server, 'GET', '/icons/lightbulb.svg') + assert code == 200 and b'= 1900 + +def test_gateway_requires_exact_origin_nonce_and_bounded_json_without_polling_devices(server): + class Gateway: + def __init__(self):self.calls=[] + def read(self):return {'available':True,'revision':'one'} + def exchange(self,value):self.calls.append(value);return {'ok':True,'code':202,'id':value['id']} + gateway=Gateway();server.controller=gateway;server.control_nonce='test-nonce' + code,body,_=request(server,'GET','/api/controller') + assert code==200 and json.loads(body)['csrf']=='test-nonce' and not gateway.calls + payload=json.dumps({'id':'known-command-0001','revision':'one','action':'off','arguments':{}}) + headers={'Origin':'http://'+server.expected_host,'X-SCR-Token':'test-nonce','Content-Type':'application/json'} + for override in ({'Origin':'http://foreign.test'},{'X-SCR-Token':'wrong'},{'Host':'foreign.test'}): + assert request(server,'POST','/api/command',dict(headers,**override),payload)[0]==403 + assert not gateway.calls + assert request(server,'POST','/api/command',dict(headers,**{'Content-Type':'text/plain'}),payload)[0]==400 + assert not gateway.calls + assert request(server,'POST','/api/command',headers,payload)[0]==202 + assert len(gateway.calls)==1 and gateway.calls[0]['action']=='off' + for method in ('PUT','DELETE','PATCH','OPTIONS'): + assert request(server,method,'/api/command',headers,payload)[0]==405 + assert len(gateway.calls)==1 + + +@pytest.mark.parametrize('operation', ['reboot', 'shutdown']) +def test_gateway_relays_confirmed_panel_power_to_its_existing_owner(server, operation): + class Owner: + def __init__(self):self.calls=[] + def exchange(self, value): + self.calls.append(value) + return {'ok':True,'code':202,'id':value['id']} + owner=Owner();server.controller=owner;server.control_nonce='power-test-nonce' + value={'id':'confirmed-panel-power-0001','revision':'current-owner-revision', + 'action':'power','arguments':{'operation':operation,'confirmed':True}} + headers={'Origin':'http://'+server.expected_host,'X-SCR-Token':'power-test-nonce', + 'Content-Type':'application/json'} + code,body,_=request(server,'POST','/api/command',headers,json.dumps(value)) + assert code==202 and json.loads(body)['id']==value['id'] + assert owner.calls==[value]