From 8ab82d644307326112c8190b274f399b9fb01e95 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Wed, 14 Oct 2026 13:03:16 -0400 Subject: [PATCH 01/24] Add independent read-only room browser status --- README.md | 15 ++ WORKFLOWS.md | 44 +++++ browser/index.html | 52 ++++++ browser/serve.py | 264 +++++++++++++++++++++++++++++ browser/status.js | 67 ++++++++ browser/styles.css | 319 +++++++++++++++++++++++++++++++++++ docs/interface.md | 31 +++- tests/test_browser_status.py | 125 ++++++++++++++ 8 files changed, 914 insertions(+), 3 deletions(-) create mode 100644 browser/index.html create mode 100644 browser/serve.py create mode 100644 browser/status.js create mode 100644 browser/styles.css create mode 100644 tests/test_browser_status.py diff --git a/README.md b/README.md index ab20aeb..5a822f1 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,21 @@ The updated branch has different navigation and additional features. ## Install, maintain or restore +### Read-only browser status + +Run `python -B browser/serve.py` with Python 3.11 or newer, then open the printed +local URL. No packages, build tools or room hardware are required. The page shows +room time, this host's resources and optional advertised room-service availability. +It shares the panel's LESA logo, neutral surfaces and blue accents; light/dark +appearance is saved only in that browser. Phone, tablet and desktop layouts scroll +normally rather than shrinking or clipping content. + +For room-network access and the approved ROS master, use the explicit addresses +in [browser operation](WORKFLOWS.md#read-only-browser-status). The default is +loopback-only. This is a separate visitor status page with no controls or recorder +metadata; it does not change the cabinet panel or its launcher. Service presence +is not device health. Browser control and staff access remain future work. + | Need | Reference | |---|---| | Connect lighting and establish ownership | [Connection checks](WORKFLOWS.md#before-connected-control) | diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 3c6b44c..8427a00 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -114,6 +114,50 @@ 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 --port 8768 \ + --label "Cabinet Pi" --master http://ROOM_PRIVATE_IP:11311 +``` + +The server binds one explicit private IPv4 address, not all interfaces. 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: anyone able to reach it can view the limited status payload. +Keep it on the trusted room network. 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. + +Host readings describe the server computer, not every room computer. 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. + +For this trial, start the process separately and retain its release commit. Do not +replace panel autostart, install an OS service or restart the room computer. Stop +the foreground process with Ctrl+C, or stop only its verified PID if launched in +the background. No lighting restoration is needed because it owns no lights. +Reboot persistence is deliberately not configured. Validate small screens, +keyboard navigation, failure age and resource use before broader deployment. + ## Backup and rebuild The Git repository preserves panel source. It does not preserve the installed diff --git a/browser/index.html b/browser/index.html new file mode 100644 index 0000000..2e725c9 --- /dev/null +++ b/browser/index.html @@ -0,0 +1,52 @@ + + + + + + Smart Conference Room · Status + + + + +
+
LESA — Lighting Enabled Systems & Applications
+

Smart Conference Room

Read-only room status Beta

+ +
+
+
+

Troy, New York · Room time

—

—

+
+

Status connection

+
Checking…
+

Waiting for the first snapshot.

+

Updates every 30 seconds while this page is visible.

+
+
+
+

Room services

View only
+

These services advertise what the room computer offers. Presence does not prove that a device is working or recording.

+
+

Lighting

Not checked

Existing room lighting interface

+

Blinds

Not checked

Existing blind control interface

+

Environment

Not checked

Temperature and humidity interfaces

+

Time-of-flight

Not checked

Distance sensing interfaces

+
+

No room connection has been checked yet.

+
+
+

Dashboard host

Status server
+

Resources on the computer serving this page. These readings do not describe the whole room.

+
+

Memory available

—

Not checked

+

Storage free

—

System disk

+

Temperature

—

Host processor, when available

+

Power

Not checked

Uptime unavailable

+
+
+
What does this page tell me?

It reads host resources and, when configured, the room ROS service list. It does not call device services, operate lights or blinds, inspect recordings, or restart anything. “Advertised” means the expected service names are registered, not that a physical device is healthy.

For room controls, use the cabinet panel with the room host. If information is stale or unavailable, coordinate with staff; this page provides no recovery actions.

+
+ + + + diff --git a/browser/serve.py b/browser/serve.py new file mode 100644 index 0000000..4e3fe0f --- /dev/null +++ b/browser/serve.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Read-only SCR status page. 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 +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'), + '/logo.png': ('../lesa_banner.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 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 theme = byId('theme'); +const systemTheme = matchMedia('(prefers-color-scheme: dark)'); +function applyTheme() { + document.documentElement.dataset.theme = theme.value === 'auto' ? (systemTheme.matches ? 'dark' : 'light') : theme.value; +} +try { theme.value = ['auto', 'light', 'dark'].includes(localStorage.getItem('scr-status-theme')) ? localStorage.getItem('scr-status-theme') : 'auto'; } catch (_) {} +theme.addEventListener('change', () => { applyTheme(); try { localStorage.setItem('scr-status-theme', theme.value); } catch (_) {} }); +systemTheme.addEventListener('change', applyTheme); +applyTheme(); + +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'}); +function clock() { const now = new Date(); byId('clock').textContent = roomTime.format(now); byId('date').textContent = roomDate.format(now); } +clock(); setInterval(clock, 1000); + +let snapshot = null, receivedAt = 0, pending = false, failed = false; +const number = value => typeof value === 'number' && Number.isFinite(value); +function age(seconds) { return seconds < 60 ? `${Math.floor(seconds)} seconds ago` : `${Math.floor(seconds / 60)} minutes ago`; } +function badge(element, text, state) { element.textContent = text; element.className = element.id === 'connection' ? `connection ${state}` : `service-state ${state}`; } +function render() { + if (!snapshot) { badge(byId('connection'), failed ? 'Status unavailable' : 'Checking…', failed ? 'warning' : ''); return; } + const elapsed = Math.max(0, (performance.now() - receivedAt) / 1000); + const sampleAge = snapshot.sample_age_s + elapsed; + const stale = failed || sampleAge > 90; + badge(byId('connection'), failed ? 'Last snapshot · connection lost' : stale ? 'Snapshot stale' : 'Status page connected', stale ? 'warning' : 'good'); + byId('freshness').textContent = `Host snapshot ${age(sampleAge)}${stale ? ' · not current' : ''}.`; + const host = snapshot.host, room = snapshot.room; + byId('host-label').textContent = snapshot.host_label; + byId('memory').textContent = number(host.memory_available_mib) ? `${host.memory_available_mib} MiB` : 'Unavailable'; + byId('memory-note').textContent = number(host.memory_total_mib) ? `Available of ${host.memory_total_mib} MiB total` : 'Not supported on this host'; + byId('storage').textContent = number(host.disk_available_gib) ? `${host.disk_available_gib} GiB` : 'Unavailable'; + byId('storage-note').textContent = number(host.disk_used_percent) ? `System disk · ${host.disk_used_percent}% used` : 'System disk'; + byId('temperature').textContent = number(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'}; + byId('power').textContent = power[host.power] || 'Not available'; + byId('power').className = `power-value ${host.power.startsWith('warning') ? 'warning' : ''}`; + byId('uptime').textContent = number(host.uptime_s) ? `Uptime · ${(host.uptime_s / 3600).toFixed(1)} hours` : 'Uptime unavailable'; + const graphStale = stale || room.state !== 'available' || !number(room.observation_age_s) || room.observation_age_s + elapsed > 90; + document.querySelectorAll('[data-service]').forEach(card => { + const service = room.services.find(item => item.name === card.dataset.service); + const labels = {advertised: 'Advertised', partial: 'Partly advertised', not_advertised: 'Not advertised'}; + 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'); + }); + byId('room-note').textContent = room.state === 'unconfigured' ? 'Room service lookup is not configured. Host status remains available.' : + room.observed_at ? `Service list observed ${age(room.observation_age_s + elapsed)}${graphStale ? ' · previous information; current availability is unknown' : ' · registrations only'}.` : + 'Room service lookup is unavailable. Device state is unknown; no recovery action was attempted.'; +} +async function refresh() { + if (pending || document.hidden) return; + pending = true; + const controller = new AbortController(); + const 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) || !number(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(); } +} +refresh(); setInterval(refresh, 30000); setInterval(render, 1000); +document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); diff --git a/browser/styles.css b/browser/styles.css new file mode 100644 index 0000000..5dd24f4 --- /dev/null +++ b/browser/styles.css @@ -0,0 +1,319 @@ +:root { + color-scheme:light; + --surface:#fff; + --card:#fff; + --ink:#24282d; + --muted:#5e6670; + --line:#dce0e5; + --soft:#f4f5f7; + --accent:#0072b2; + --good:#26744b; + --warning:#92580c; + --warning-bg:#fff5e4; + --good-bg:#edf7f1; + --focus:#0072b2 +} +:root[data-theme=dark] { + color-scheme:dark; + --surface:#16181b; + --card:#202327; + --ink:#f0f1f2; + --muted:#b9bdc4; + --line:#41464e; + --soft:#30343a; + --accent:#72bde8; + --good:#89d5ad; + --warning:#f0c179; + --warning-bg:#352a1b; + --good-bg:#20372c; + --focus:#72bde8 +} +* { + box-sizing:border-box +} +body { + margin:0; + background:var(--surface); + 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.35rem,2.7vw,1.85rem); + line-height:1.2 +} +h2 { + font-size:1.3rem +} +h3 { + font-size:1.05rem; + font-weight:600 +} +p { + overflow-wrap:anywhere +} +.shell { + width:min(1180px,100%); + margin:auto; + padding-inline:clamp(16px,3vw,36px) +} +.header { + display:flex; + align-items:center; + gap:24px; + padding-block:24px; + border-bottom:1px solid var(--line) +} +.brand { + width:235px; + max-width:100%; + padding:10px; + background:#fff; + border-radius:6px; + flex-shrink:0 +} +.brand img { + display:block; + width:100%; + height:auto +} +.heading { + flex:1; + min-width:0 +} +.heading p { + margin-top:6px; + color:var(--muted); + font-size:.95rem +} +.beta,.tag { + display:inline-block; + border:1px solid var(--line); + background:var(--soft); + padding:3px 10px; + border-radius:5px; + font-size:.82rem; + font-weight:500; + overflow-wrap:anywhere +} +.beta { + margin-left:6px +} +.theme { + display:grid; + gap:4px; + font-size:.8rem; + color:var(--muted); + min-width:145px +} +select { + font:inherit; + font-size:1rem; + min-height:46px; + padding:8px 12px; + border:1px solid var(--line); + border-radius:6px; + background:var(--card); + color:var(--ink); + width:100% +} +select:focus-visible,summary:focus-visible { + outline:3px solid var(--focus); + outline-offset:4px +} +main { + padding-block:28px +} +.overview { + display:grid; + grid-template-columns:1.15fr 1fr; + gap:24px; + padding-bottom:24px +} +.clock-card,.connection-card { + min-width:0; + background:var(--soft); + padding:24px; + border:1px solid var(--line); + border-radius:8px +} +.clock { + font-size:clamp(2.8rem,7vw,5.4rem); + font-weight:500; + line-height:1.2; + letter-spacing:-.025em; + font-variant-numeric:tabular-nums; + margin:8px 0 +} +.eyebrow { + font-size:.84rem; + color:var(--muted); + font-weight:600 +} +.connection { + font-size:1.5rem; + font-weight:600; + margin:12px 0 8px; + overflow-wrap:anywhere +} +.connection::before,.service-state::before { + content:""; + display:inline-block; + width:9px; + height:9px; + border:2px solid currentColor; + border-radius:50%; + margin-right:9px; + vertical-align:middle +} +.connection-card .muted { + margin-top:12px +} +.muted,.section-note { + color:var(--muted); + font-size:.91rem +} +.section-heading { + display:flex; + flex-wrap:wrap; + gap:12px; + align-items:center; + justify-content:space-between; + margin:20px 0 8px +} +.section-note { + margin-bottom:16px +} +.service-grid,.host-grid { + display:grid; + grid-template-columns:repeat(4,minmax(0,1fr)); + gap:16px +} +.card { + background:var(--card); + border:1px solid var(--line); + border-radius:8px; + padding:20px; + min-width:0 +} +.icon { + width:28px; + height:28px; + fill:none; + stroke:var(--accent); + stroke-width:1.6; + stroke-linecap:round; + stroke-linejoin:round; + margin-bottom:12px +} +.service-state { + font-weight:600; + margin:10px 0 6px; + font-size:.96rem +} +.good { + color:var(--good) +} +.warning { + color:var(--warning) +} +.observation { + margin:14px 0 24px; + color:var(--muted); + font-size:.88rem +} +.value { + font-size:clamp(1.7rem,3vw,2.4rem); + font-weight:500; + font-variant-numeric:tabular-nums; + margin:8px 0; + line-height:1.25 +} +.power-value { + font-size:1.2rem; + font-weight:600; + margin:12px 0 +} +.help { + margin-top:28px; + border:1px solid var(--line); + border-radius:8px; + padding:16px 20px +} +.help summary { + cursor:pointer; + min-height:28px; + font-weight:600 +} +.help p { + margin-top:14px; + color:var(--muted); + max-width:85ch +} +.footer { + display:flex; + flex-wrap:wrap; + justify-content:space-between; + gap:10px; + padding-block:24px; + color:var(--muted); + font-size:.83rem; + border-top:1px solid var(--line) +} +@media(max-width:900px) { + .brand { + width:175px + } + .header { + gap:16px + } + .service-grid,.host-grid { + grid-template-columns:repeat(2,minmax(0,1fr)) + } +} +@media(max-width:600px) { + .header { + flex-wrap:wrap; + padding-block:16px + } + .brand { + width:200px + } + .heading { + flex-basis:100% + } + .theme { + width:100% + } + .overview { + grid-template-columns:1fr; + gap:16px + } + .clock-card,.connection-card { + padding:20px + } + .service-grid,.host-grid { + gap:12px + } + .card { + padding:16px + } + .clock { + font-size:3.1rem + } +} +@media(max-width:370px) { + .service-grid,.host-grid { + grid-template-columns:1fr + } + .clock { + font-size:2.65rem + } +} +@media(prefers-reduced-motion:reduce) { + * { + scroll-behavior:auto + } +} diff --git a/docs/interface.md b/docs/interface.md index 1a7dcc9..065d818 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,28 @@ 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. + +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. + +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..9d14873 --- /dev/null +++ b/tests/test_browser_status.py @@ -0,0 +1,125 @@ +"""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.HTTPServer(('127.0.0.1', 0), status.Handler) + 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): + connection = http.client.HTTPConnection(*server.server_address, timeout=7) + try: + connection.request(method, path, 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'] + for method in ('POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'): + assert request(server, method, '/api/status')[0] == 405 + for path in ('/../README.md', '/api/status?master=http://other', '/deployment.env'): + assert request(server, 'GET', path)[0] == 404 + assert request(server, 'GET', '/api/status', {'Host': 'foreign.test'})[0] == 403 + assert request(server, 'GET', '/api/status', {'Origin': 'http://foreign.test'})[0] == 403 + + +def test_trickled_request_has_absolute_deadline(server): + connection = socket.create_connection(server.server_address) + stopped = threading.Event() + def trickle(): + while not stopped.wait(0.25): + try: + connection.sendall(b'x') + except OSError: + return + started = time.monotonic() + connection.sendall(b'GET / HTTP/1.1\r\nX-Test: ') + worker = threading.Thread(target=trickle, daemon=True) + worker.start() + try: + # The healthy request must finish while the slow socket remains open. + assert request(server, 'GET', '/api/status')[0] == 200 + assert time.monotonic() - started < 6 + finally: + stopped.set() + connection.close() + worker.join(timeout=1) + + +def test_explicit_network_configuration(): + assert status.private_address('192.168.1.5') == '192.168.1.5' + for value in ('0.0.0.0', '8.8.8.8', '224.0.0.1'): + with pytest.raises(ValueError): + status.private_address(value) + for value in ('https://192.168.1.5:11311', 'http://192.168.1.5:11311/path', + 'http://user:secret@192.168.1.5:11311', 'http://192.168.1.5:11311?redirect=x'): + with pytest.raises(ValueError): + status.master_url(value) From e9972b9b01b15e80246c8227ae2084e4fe3e3439 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Wed, 14 Oct 2026 13:30:32 -0400 Subject: [PATCH 02/24] Simplify browser overview and clarify reading sources --- README.md | 9 +++-- WORKFLOWS.md | 6 ++- browser/index.html | 25 ++++++------ browser/lesa-logo.jpeg | Bin 0 -> 53647 bytes browser/serve.py | 4 +- browser/status.js | 19 +++++----- browser/styles.css | 71 +++++++++++++++++------------------ tests/test_browser_status.py | 3 ++ 8 files changed, 72 insertions(+), 65 deletions(-) create mode 100644 browser/lesa-logo.jpeg diff --git a/README.md b/README.md index 5a822f1..7dc8302 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,16 @@ The updated branch has different navigation and additional features. ## Install, maintain or restore -### Read-only browser status +### Browser room overview (read-only trial) Run `python -B browser/serve.py` with Python 3.11 or newer, then open the printed local URL. No packages, build tools or room hardware are required. The page shows room time, this host's resources and optional advertised room-service availability. -It shares the panel's LESA logo, neutral surfaces and blue accents; light/dark -appearance is saved only in that browser. Phone, tablet and desktop layouts scroll +It uses the supplied main LESA logo, neutral surfaces and blue accents. Appearance +follows the browser's light/dark preference without a page selector. Diagnostics +are collapsed below the room overview; storage is not shown. A host-only preview +identifies its readings as coming from the preview computer. These are actual host +readings, not generated demonstration values. Phone, tablet and desktop layouts scroll normally rather than shrinking or clipping content. For room-network access and the approved ROS master, use the explicit addresses diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 8427a00..cbddc09 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -143,7 +143,11 @@ 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. -Host readings describe the server computer, not every room computer. The clock +Host readings describe the server computer, not every room computer. The page +names that source explicitly; a host-only preview is labelled as such. Diagnostics +start collapsed, storage is not displayed, and appearance follows the browser's +light/dark preference without a page selector. The supplied main logo is unchanged; +its blank margins are trimmed only by the page 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 diff --git a/browser/index.html b/browser/index.html index 2e725c9..2d38b97 100644 --- a/browser/index.html +++ b/browser/index.html @@ -9,23 +9,23 @@
-
LESA — Lighting Enabled Systems & Applications
-

Smart Conference Room

Read-only room status Beta

- +
LESA — Lighting Enabled Systems & Applications
+

Smart Conference Room

Room overview · Read-only

Browser trial
+

Checking where these readings come from…

-

Troy, New York · Room time

—

—

+

Troy time · Device clock

—

—

-

Status connection

+

Page updates

Checking…

Waiting for the first snapshot.

Updates every 30 seconds while this page is visible.

-

Room services

View only
-

These services advertise what the room computer offers. Presence does not prove that a device is working or recording.

+

Room connections

View only
+

Listed connections are offered by the room computer. This does not confirm physical device health or recording.

Lighting

Not checked

Existing room lighting interface

Blinds

Not checked

Existing blind control interface

@@ -34,17 +34,16 @@

No room connection has been checked yet.

-
-

Dashboard host

Status server
-

Resources on the computer serving this page. These readings do not describe the whole room.

+
+ Computer diagnostics +

Actual readings from the computer serving this page, rather than room-wide measurements. No sample values are generated.

Memory available

—

Not checked

-

Storage free

—

System disk

Temperature

—

Host processor, when available

Power

Not checked

Uptime unavailable

-
-
What does this page tell me?

It reads host resources and, when configured, the room ROS service list. It does not call device services, operate lights or blinds, inspect recordings, or restart anything. “Advertised” means the expected service names are registered, not that a physical device is healthy.

For room controls, use the cabinet panel with the room host. If information is stale or unavailable, coordinate with staff; this page provides no recovery actions.

+ +
What does this page tell me?

It reads computer resources and, when configured, the room ROS service list. It does not call device services, operate lights or blinds, inspect recordings, or restart anything. “Service listed” means the expected interface names are registered, not that a physical device is healthy.

For room controls, use the cabinet panel with the room host. If information is stale or unavailable, coordinate with staff; this page provides no recovery actions.

LESA Smart Conference RoomRead-only · No room actions
diff --git a/browser/lesa-logo.jpeg b/browser/lesa-logo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..cbb4e8339db6892a7d0851e13afe44d3e16300e6 GIT binary patch literal 53647 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 G>i+=gYX)He literal 0 HcmV?d00001 diff --git a/browser/serve.py b/browser/serve.py index 4e3fe0f..ea3f271 100644 --- a/browser/serve.py +++ b/browser/serve.py @@ -28,7 +28,7 @@ } ASSETS = {'/': ('index.html', 'text/html'), '/styles.css': ('styles.css', 'text/css'), '/status.js': ('status.js', 'text/javascript'), - '/logo.png': ('../lesa_banner.png', 'image/png')} + '/logo.jpg': ('lesa-logo.jpeg', 'image/jpeg')} def utc(): @@ -185,7 +185,7 @@ def log_message(self, *_): def respond(self, status, data=b'', content_type='text/plain'): self.send_response(status) - self.send_header('Content-Type', content_type + ('; charset=utf-8' if content_type != 'image/png' else '')) + self.send_header('Content-Type', content_type + ('' if content_type.startswith('image/') else '; charset=utf-8')) self.send_header('Content-Length', str(len(data))) self.send_header('Cache-Control', 'no-store') self.send_header('X-Content-Type-Options', 'nosniff') diff --git a/browser/status.js b/browser/status.js index a5ce022..98250c1 100644 --- a/browser/status.js +++ b/browser/status.js @@ -1,12 +1,9 @@ 'use strict'; const byId = id => document.getElementById(id); -const theme = byId('theme'); const systemTheme = matchMedia('(prefers-color-scheme: dark)'); function applyTheme() { - document.documentElement.dataset.theme = theme.value === 'auto' ? (systemTheme.matches ? 'dark' : 'light') : theme.value; + document.documentElement.dataset.theme = systemTheme.matches ? 'dark' : 'light'; } -try { theme.value = ['auto', 'light', 'dark'].includes(localStorage.getItem('scr-status-theme')) ? localStorage.getItem('scr-status-theme') : 'auto'; } catch (_) {} -theme.addEventListener('change', () => { applyTheme(); try { localStorage.setItem('scr-status-theme', theme.value); } catch (_) {} }); systemTheme.addEventListener('change', applyTheme); applyTheme(); @@ -24,14 +21,16 @@ function render() { const elapsed = Math.max(0, (performance.now() - receivedAt) / 1000); const sampleAge = snapshot.sample_age_s + elapsed; const stale = failed || sampleAge > 90; - badge(byId('connection'), failed ? 'Last snapshot · connection lost' : stale ? 'Snapshot stale' : 'Status page connected', stale ? 'warning' : 'good'); - byId('freshness').textContent = `Host snapshot ${age(sampleAge)}${stale ? ' · not current' : ''}.`; + badge(byId('connection'), failed ? 'Page connection lost' : stale ? 'Readings are stale' : 'Updating normally', stale ? 'warning' : 'good'); + byId('freshness').textContent = `Computer readings ${age(sampleAge)}${stale ? ' · not current' : ''}.`; const host = snapshot.host, room = snapshot.room; - byId('host-label').textContent = snapshot.host_label; + byId('host-label').textContent = `${snapshot.host_label} · diagnostics`; + byId('source-description').textContent = room.state === 'unconfigured' ? + `Host-only preview · These readings come from ${snapshot.host_label}, not the room. No room connection is configured.` : + `Readings from ${snapshot.host_label} · Room connections are checked separately below.`; + byId('source-description').className = `source-note ${room.state === 'unconfigured' ? 'preview-note' : ''}`; byId('memory').textContent = number(host.memory_available_mib) ? `${host.memory_available_mib} MiB` : 'Unavailable'; byId('memory-note').textContent = number(host.memory_total_mib) ? `Available of ${host.memory_total_mib} MiB total` : 'Not supported on this host'; - byId('storage').textContent = number(host.disk_available_gib) ? `${host.disk_available_gib} GiB` : 'Unavailable'; - byId('storage-note').textContent = number(host.disk_used_percent) ? `System disk · ${host.disk_used_percent}% used` : 'System disk'; byId('temperature').textContent = number(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'}; byId('power').textContent = power[host.power] || 'Not available'; @@ -40,7 +39,7 @@ function render() { const graphStale = stale || room.state !== 'available' || !number(room.observation_age_s) || room.observation_age_s + elapsed > 90; document.querySelectorAll('[data-service]').forEach(card => { const service = room.services.find(item => item.name === card.dataset.service); - const labels = {advertised: 'Advertised', partial: 'Partly advertised', not_advertised: 'Not advertised'}; + 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'); diff --git a/browser/styles.css b/browser/styles.css index 5dd24f4..d219ab4 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -67,17 +67,22 @@ p { border-bottom:1px solid var(--line) } .brand { - width:235px; + width:156px; max-width:100%; - padding:10px; + padding:0; background:#fff; border-radius:6px; + aspect-ratio:5/4; + overflow:hidden; flex-shrink:0 } .brand img { display:block; - width:100%; - height:auto + /* Trim only the supplied JPEG's blank margins in layout; preserve all artwork. */ + width:120%; + height:auto; + margin-left:-6%; + margin-top:-20% } .heading { flex:1; @@ -99,33 +104,26 @@ p { overflow-wrap:anywhere } .beta { - margin-left:6px + margin-top:10px } -.theme { - display:grid; - gap:4px; - font-size:.8rem; - color:var(--muted); - min-width:145px -} -select { - font:inherit; - font-size:1rem; - min-height:46px; - padding:8px 12px; - border:1px solid var(--line); - border-radius:6px; - background:var(--card); - color:var(--ink); - width:100% -} -select:focus-visible,summary:focus-visible { +summary:focus-visible { outline:3px solid var(--focus); outline-offset:4px } main { padding-block:28px } +.source-note { + margin-bottom:24px; + padding:12px 16px; + background:var(--soft); + border:1px solid var(--line); + border-left:4px solid var(--accent); + font-size:.91rem +} +.preview-note { + border-left-color:var(--warning) +} .overview { display:grid; grid-template-columns:1.15fr 1fr; @@ -191,6 +189,10 @@ main { grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px } +.host-grid { + grid-template-columns:repeat(3,minmax(0,1fr)); + margin-top:16px +} .card { background:var(--card); border:1px solid var(--line); @@ -244,10 +246,11 @@ main { } .help summary { cursor:pointer; - min-height:28px; + min-height:44px; + padding-block:8px; font-weight:600 } -.help p { +.help > p { margin-top:14px; color:var(--muted); max-width:85ch @@ -264,28 +267,21 @@ main { } @media(max-width:900px) { .brand { - width:175px + width:140px } .header { gap:16px } - .service-grid,.host-grid { + .service-grid { grid-template-columns:repeat(2,minmax(0,1fr)) } } @media(max-width:600px) { .header { - flex-wrap:wrap; padding-block:16px } .brand { - width:200px - } - .heading { - flex-basis:100% - } - .theme { - width:100% + width:110px } .overview { grid-template-columns:1fr; @@ -297,6 +293,9 @@ main { .service-grid,.host-grid { gap:12px } + .host-grid { + grid-template-columns:1fr + } .card { padding:16px } diff --git a/tests/test_browser_status.py b/tests/test_browser_status.py index 9d14873..0fdd3cc 100644 --- a/tests/test_browser_status.py +++ b/tests/test_browser_status.py @@ -83,6 +83,9 @@ def test_http_boundary_and_cache(server): 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' for method in ('POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'): assert request(server, method, '/api/status')[0] == 405 for path in ('/../README.md', '/api/status?master=http://other', '/deployment.env'): From 5b67f9e643c967447be041a929a8f4eb28e9dc13 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Wed, 14 Oct 2026 13:57:27 -0400 Subject: [PATCH 03/24] Separate visitor overview, diagnostics and room guide --- README.md | 12 +- WORKFLOWS.md | 32 ++- app/weather.py | 102 +++++++++ browser/diagnostics.html | 19 ++ browser/guide.html | 9 + browser/index.html | 68 ++---- browser/serve.py | 43 +++- browser/status.js | 133 +++++++----- browser/styles.css | 397 ++++++++--------------------------- docs/interface.md | 13 ++ tests/test_browser_status.py | 22 ++ 11 files changed, 417 insertions(+), 433 deletions(-) create mode 100644 app/weather.py create mode 100644 browser/diagnostics.html create mode 100644 browser/guide.html diff --git a/README.md b/README.md index 7dc8302..c4014ff 100644 --- a/README.md +++ b/README.md @@ -55,18 +55,18 @@ The updated branch has different navigation and additional features. Run `python -B browser/serve.py` with Python 3.11 or newer, then open the printed local URL. No packages, build tools or room hardware are required. The page shows room time, this host's resources and optional advertised room-service availability. -It uses the supplied main LESA logo, neutral surfaces and blue accents. Appearance -follows the browser's light/dark preference without a page selector. Diagnostics -are collapsed below the room overview; storage is not shown. A host-only preview -identifies its readings as coming from the preview computer. These are actual host -readings, not generated demonstration values. Phone, tablet and desktop layouts scroll +Overview introduces lighting, sensing and research, with clock and optional outdoor +weather. Diagnostics is a separate view for actual host readings and room-interface +registrations. Guide explains room use and failures. All three share fixed navigation, +the original main LESA logo and a white surface. There are no appearance switches, +storage cards or technical status on Overview. Phone, tablet and desktop layouts scroll normally rather than shrinking or clipping content. For room-network access and the approved ROS master, use the explicit addresses in [browser operation](WORKFLOWS.md#read-only-browser-status). The default is loopback-only. This is a separate visitor status page with no controls or recorder metadata; it does not change the cabinet panel or its launcher. Service presence -is not device health. Browser control and staff access remain future work. +is not device health. Browser control and staff authentication remain future work. | Need | Reference | |---|---| diff --git a/WORKFLOWS.md b/WORKFLOWS.md index cbddc09..f992780 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -126,14 +126,16 @@ python -B browser/serve.py # Room host: substitute verified private interface/master addresses. python -B browser/serve.py --bind PANEL_PRIVATE_IP --port 8768 \ - --label "Cabinet Pi" --master http://ROOM_PRIVATE_IP:11311 + --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, not all interfaces. 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: anyone able to reach it can view the limited status payload. -Keep it on the trusted room network. This does not secure existing ROS services. +The touch-panel Pi hosts this trial, accessible from the room's SCR_AP LAN. Keep +it on that trusted network. 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 @@ -143,11 +145,14 @@ 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. -Host readings describe the server computer, not every room computer. The page -names that source explicitly; a host-only preview is labelled as such. Diagnostics -start collapsed, storage is not displayed, and appearance follows the browser's -light/dark preference without a page selector. The supplied main logo is unchanged; -its blank margins are trimmed only by the page layout. The clock +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 @@ -162,6 +167,19 @@ the background. No lighting restoration is needed because it owns no lights. Reboot persistence is deliberately not configured. Validate small screens, keyboard navigation, failure age and resource use before broader deployment. +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..416958a --- /dev/null +++ b/app/weather.py @@ -0,0 +1,102 @@ +# -*- 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' + return validate({'temperature_f': current['temperature_2m'], 'condition': condition, + 'code': code, 'sunrise': daily['sunrise'][0], 'sunset': daily['sunset'][0], + 'model_time': current['time']}) + + +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']) + return {'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']} + + +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', 'daily': 'sunrise,sunset', + '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/diagnostics.html b/browser/diagnostics.html new file mode 100644 index 0000000..9fb9581 --- /dev/null +++ b/browser/diagnostics.html @@ -0,0 +1,19 @@ + +Smart Conference Room · Diagnostics + +
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Diagnostics for room staff

Read-only trial
+ +

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.

+
LESAReadings retain their age after a failed update.
diff --git a/browser/guide.html b/browser/guide.html new file mode 100644 index 0000000..5e5a98c --- /dev/null +++ b/browser/guide.html @@ -0,0 +1,9 @@ + +Smart Conference Room · Guide +
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

A guide for visitors and room staff

Read-only trial
+ +

Start with the room host.

This room combines lighting, sensing and research equipment. This website introduces those capabilities; the cabinet panel remains the control surface.

+

For a demonstration

Ask the host to show warm/cool lighting and the room's existing colour scenes. Coordinate before operating blinds, demos or sensing systems.

For an experiment

Agree who owns each device and how data will be recorded. A panel timer or listed service does not mean sensors are recording.

+

If something looks wrong

Page connection lost

The browser cannot fetch the status host. Check that your device is on SCR_AP and the touch-panel Pi is reachable. Old readings remain dated.

Room connection unavailable

The status host cannot read the room computer's service list. This is separate from browser connectivity. It does not establish whether a physical device has failed.

Service not listed

The expected interface names were absent from the last successful list. Coordinate with staff before restarting services; installed room launchers can replace whole sessions.

Earlier low voltage

The Pi reports a warning since boot, with no current warning at the sample time. A current low-voltage warning needs attention to the supply, cable and connector. Do not reboot just to hide it.

+

How this view works

The touch-panel Pi hosts the page on the room LAN. It keeps a cached read-only summary. Outdoor weather comes from Open-Meteo model data, independently of room sensors. Diagnostics shows registration and host information; device commands and staff control are future work.

+
LESACoordinate changes with the room owner.
diff --git a/browser/index.html b/browser/index.html index 2d38b97..c327ef4 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1,51 +1,19 @@ - - - - - Smart Conference Room · Status - - - - -
-
LESA — Lighting Enabled Systems & Applications
-

Smart Conference Room

Room overview · Read-only

Browser trial
-
-
-

Checking where these readings come from…

-
-

Troy time · Device clock

—

—

-
-

Page updates

-
Checking…
-

Waiting for the first snapshot.

-

Updates every 30 seconds while this page is visible.

-
-
-
-

Room connections

View only
-

Listed connections are offered by the room computer. This does not confirm physical device health or recording.

-
-

Lighting

Not checked

Existing room lighting interface

-

Blinds

Not checked

Existing blind control interface

-

Environment

Not checked

Temperature and humidity interfaces

-

Time-of-flight

Not checked

Distance sensing interfaces

-
-

No room connection has been checked yet.

-
-
- Computer diagnostics -

Actual readings from the computer serving this page, rather than room-wide measurements. No sample values are generated.

-
-

Memory available

—

Not checked

-

Temperature

—

Host processor, when available

-

Power

Not checked

Uptime unavailable

-
-
-
What does this page tell me?

It reads computer resources and, when configured, the room ROS service list. It does not call device services, operate lights or blinds, inspect recordings, or restart anything. “Service listed” means the expected interface names are registered, not that a physical device is healthy.

For room controls, use the cabinet panel with the room host. If information is stale or unavailable, coordinate with staff; this page provides no recovery actions.

-
-
LESA Smart Conference RoomRead-only · No room actions
- - - +Smart Conference Room · Overview + +
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Lighting, sensing and research

Read-only trial
+ +
+

A place to explore the room.

Discover what the room can do. For demonstrations or experiments, start with the room host.

+
+

Troy, New York

—

—

Time from your device

+

Outdoors in Troy

—Loading weather…

Outdoor model data, separate from room sensors.

Sunrise —Sunset —

Daylight information unavailable

Weather is optional. Missing data is not replaced with sample values.

+
+

Explore the room

Visitor guide
+
Lighting

Change the atmosphere

Warm and cool white light, plus existing colour scenes. Try them with the host at the cabinet panel.

+
Sensing

Understand the space

The room brings together environmental and activity sensing for demonstrations and research.

+
Research

Bring an idea to life

Experiments can combine room equipment and recordings. Coordinate with staff before starting a study.

+
+

Design trial · This browser view does not operate room equipment.

+
LESAUse the cabinet panel with the room host for controls.
+ diff --git a/browser/serve.py b/browser/serve.py index ea3f271..72bbc56 100644 --- a/browser/serve.py +++ b/browser/serve.py @@ -28,6 +28,7 @@ } ASSETS = {'/': ('index.html', 'text/html'), '/styles.css': ('styles.css', 'text/css'), '/status.js': ('status.js', 'text/javascript'), + '/diagnostics': ('diagnostics.html', 'text/html'), '/guide': ('guide.html', 'text/html'), '/logo.jpg': ('lesa-logo.jpeg', 'image/jpeg')} @@ -117,15 +118,38 @@ def bounded_graph(master): class Snapshot: - def __init__(self, label, master=None): + def __init__(self, label, master=None, weather_location=None): self.label, self.master = label, master self.lock = threading.Lock() self.data = None self.sample_time = self.graph_time = None self.last_graph = None self.last_graph_utc = None + self.weather_location = weather_location + self.weather_time = self.weather_attempt = None + self.weather_data = None + self.weather_utc = None + self.weather_state = 'unconfigured' if weather_location is None else 'unavailable' + + def collect_weather(self): + if self.weather_location is None or (self.weather_attempt is not None and time.monotonic() - self.weather_attempt < 900): + return + self.weather_attempt = time.monotonic() + try: + result = subprocess.run([sys.executable, '-B', str(ROOT.parent / 'app/weather.py'), + *map(str, self.weather_location), 'America/New_York'], + capture_output=True, text=True, timeout=8, check=True) + value = json.loads(result.stdout) + if 'error' in value or 'temperature_f' not in value: + raise ValueError('Weather unavailable') + self.weather_data = value + self.weather_time, self.weather_utc = time.monotonic(), utc() + self.weather_state = 'available' + except (OSError, ValueError, subprocess.SubprocessError): + self.weather_state = 'unavailable' def collect(self): + self.collect_weather() host = host_status() graph = {'state': 'unconfigured', 'observed_at': None, 'services': []} graph_time = self.graph_time @@ -138,7 +162,10 @@ def collect(self): graph['state'] = 'unavailable' graph.update(services=self.last_graph or [], observed_at=self.last_graph_utc) data = {'version': 1, 'read_only': True, 'host_label': self.label, - 'sampled_at': utc(), 'host': host, 'room': graph} + 'sampled_at': utc(), 'host': host, 'room': graph, + 'weather': {'state': self.weather_state, 'data': self.weather_data, + 'observed_at': self.weather_utc, + 'observation_age_s': None if self.weather_time is None else round(time.monotonic() - self.weather_time, 1)}} with self.lock: self.data, self.sample_time, self.graph_time = data, time.monotonic(), graph_time @@ -148,6 +175,9 @@ def read(self): result['sample_age_s'] = max(0, round(time.monotonic() - self.sample_time, 1)) result['room'] = dict(result['room']) result['room']['observation_age_s'] = None if self.graph_time is None else max(0, round(time.monotonic() - self.graph_time, 1)) + result['weather'] = dict(result['weather']) + if result['weather']['observation_age_s'] is not None: + result['weather']['observation_age_s'] += result['sample_age_s'] return result def run(self, stopped): @@ -228,6 +258,8 @@ def main(): parser.add_argument('--port', type=int, default=8768) parser.add_argument('--label', default='Dashboard host', help='Public host label; no private experiment metadata') parser.add_argument('--master', help='Approved private ROS master URL; omit for host-only status') + parser.add_argument('--weather-latitude', type=float, help='Optional public outdoor-weather location') + parser.add_argument('--weather-longitude', type=float) parser.add_argument('--probe-master', help=argparse.SUPPRESS) args = parser.parse_args() try: @@ -239,9 +271,14 @@ def main(): if args.probe_master: print(json.dumps(service_graph(master_url(args.probe_master)))) return + if (args.weather_latitude is None) != (args.weather_longitude is None): + raise ValueError('Weather needs both latitude and longitude') + if args.weather_latitude is not None and not (-90 <= args.weather_latitude <= 90 and -180 <= args.weather_longitude <= 180): + raise ValueError('Weather coordinates are out of range') except ValueError as error: parser.error(str(error)) - snapshot = Snapshot(args.label, args.master) + location = None if args.weather_latitude is None else (args.weather_latitude, args.weather_longitude) + snapshot = Snapshot(args.label, args.master, location) snapshot.collect() server = HTTPServer((args.bind, args.port), Handler) server.expected_host = '%s:%d' % server.server_address diff --git a/browser/status.js b/browser/status.js index 98250c1..e475ed3 100644 --- a/browser/status.js +++ b/browser/status.js @@ -1,66 +1,87 @@ 'use strict'; const byId = id => document.getElementById(id); -const systemTheme = matchMedia('(prefers-color-scheme: dark)'); -function applyTheme() { - document.documentElement.dataset.theme = systemTheme.matches ? 'dark' : 'light'; +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 weather(elapsed) { + const info=snapshot.weather || {state:'unconfigured',data:null}; + const value=info.data; + if (!value) { + text('weather-temperature','—'); text('weather-condition',info.state==='unconfigured'?'Weather not configured':'Weather unavailable'); + text('weather-source','No sample values are substituted.'); + 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',`${Math.round(value.temperature_c)} °C · Troy outdoors`); + text('weather-source',`${stale?'Earlier weather · '+value.model_time.slice(0,10)+' · ':''}Weather as of ${localTime(value.model_time)} · Open-Meteo`); + 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':`${now typeof value === 'number' && Number.isFinite(value); -function age(seconds) { return seconds < 60 ? `${Math.floor(seconds)} seconds ago` : `${Math.floor(seconds / 60)} minutes ago`; } -function badge(element, text, state) { element.textContent = text; element.className = element.id === 'connection' ? `connection ${state}` : `service-state ${state}`; } function render() { - if (!snapshot) { badge(byId('connection'), failed ? 'Status unavailable' : 'Checking…', failed ? 'warning' : ''); return; } - const elapsed = Math.max(0, (performance.now() - receivedAt) / 1000); - const sampleAge = snapshot.sample_age_s + elapsed; - const stale = failed || sampleAge > 90; - badge(byId('connection'), failed ? 'Page connection lost' : stale ? 'Readings are stale' : 'Updating normally', stale ? 'warning' : 'good'); - byId('freshness').textContent = `Computer readings ${age(sampleAge)}${stale ? ' · not current' : ''}.`; - const host = snapshot.host, room = snapshot.room; - byId('host-label').textContent = `${snapshot.host_label} · diagnostics`; - byId('source-description').textContent = room.state === 'unconfigured' ? - `Host-only preview · These readings come from ${snapshot.host_label}, not the room. No room connection is configured.` : - `Readings from ${snapshot.host_label} · Room connections are checked separately below.`; - byId('source-description').className = `source-note ${room.state === 'unconfigured' ? 'preview-note' : ''}`; - byId('memory').textContent = number(host.memory_available_mib) ? `${host.memory_available_mib} MiB` : 'Unavailable'; - byId('memory-note').textContent = number(host.memory_total_mib) ? `Available of ${host.memory_total_mib} MiB total` : 'Not supported on this host'; - byId('temperature').textContent = number(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'}; - byId('power').textContent = power[host.power] || 'Not available'; - byId('power').className = `power-value ${host.power.startsWith('warning') ? 'warning' : ''}`; - byId('uptime').textContent = number(host.uptime_s) ? `Uptime · ${(host.uptime_s / 3600).toFixed(1)} hours` : 'Uptime unavailable'; - const graphStale = stale || room.state !== 'available' || !number(room.observation_age_s) || room.observation_age_s + elapsed > 90; - 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('clock',roomTime.format(new Date())); text('date',roomDate.format(new Date())); + if (!snapshot) { badge(byId('connection'),failed?'Page connection lost':'Checking…',failed?'warning':''); return; } + const elapsed=Math.max(0,(performance.now()-receivedAt)/1000); + if (!diagnostic) { + weather(elapsed); + text('preview-note',snapshot.room.state==='unconfigured'?'Layout preview · Outdoor weather is real model data; no room connection is configured.':'Visitor overview · This page does not operate room equipment.'); + return; + } + const sampleAge=snapshot.sample_age_s+elapsed, stale=failed||sampleAge>90; + 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'); }); - byId('room-note').textContent = room.state === 'unconfigured' ? 'Room service lookup is not configured. Host status remains available.' : - room.observed_at ? `Service list observed ${age(room.observation_age_s + elapsed)}${graphStale ? ' · previous information; current availability is unknown' : ' · registrations only'}.` : - 'Room service lookup is unavailable. Device state is unknown; no recovery action was attempted.'; + 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(); - const timer = setTimeout(() => controller.abort(), 4000); + 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'}); + 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) || !number(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(); } + 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();} } -refresh(); setInterval(refresh, 30000); setInterval(render, 1000); -document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); +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 index d219ab4..b4916eb 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -1,318 +1,93 @@ :root { - color-scheme:light; - --surface:#fff; - --card:#fff; - --ink:#24282d; - --muted:#5e6670; - --line:#dce0e5; - --soft:#f4f5f7; - --accent:#0072b2; - --good:#26744b; - --warning:#92580c; - --warning-bg:#fff5e4; - --good-bg:#edf7f1; - --focus:#0072b2 -} -:root[data-theme=dark] { - color-scheme:dark; - --surface:#16181b; - --card:#202327; - --ink:#f0f1f2; - --muted:#b9bdc4; - --line:#41464e; - --soft:#30343a; - --accent:#72bde8; - --good:#89d5ad; - --warning:#f0c179; - --warning-bg:#352a1b; - --good-bg:#20372c; - --focus:#72bde8 -} -* { - box-sizing:border-box -} -body { - margin:0; - background:var(--surface); - 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.35rem,2.7vw,1.85rem); - line-height:1.2 -} -h2 { - font-size:1.3rem -} -h3 { - font-size:1.05rem; - font-weight:600 -} -p { - overflow-wrap:anywhere -} -.shell { - width:min(1180px,100%); - margin:auto; - padding-inline:clamp(16px,3vw,36px) -} -.header { - display:flex; - align-items:center; - gap:24px; - padding-block:24px; - border-bottom:1px solid var(--line) -} -.brand { - width:156px; - max-width:100%; - padding:0; - background:#fff; - border-radius:6px; - aspect-ratio:5/4; - overflow:hidden; - flex-shrink:0 -} -.brand img { - display:block; - /* Trim only the supplied JPEG's blank margins in layout; preserve all artwork. */ - width:120%; - height:auto; - margin-left:-6%; - margin-top:-20% -} -.heading { - flex:1; - min-width:0 -} -.heading p { - margin-top:6px; - color:var(--muted); - font-size:.95rem -} -.beta,.tag { - display:inline-block; - border:1px solid var(--line); - background:var(--soft); - padding:3px 10px; - border-radius:5px; - font-size:.82rem; - font-weight:500; - overflow-wrap:anywhere -} -.beta { - margin-top:10px -} -summary:focus-visible { - outline:3px solid var(--focus); - outline-offset:4px -} -main { - padding-block:28px -} -.source-note { - margin-bottom:24px; - padding:12px 16px; - background:var(--soft); - border:1px solid var(--line); - border-left:4px solid var(--accent); - font-size:.91rem -} -.preview-note { - border-left-color:var(--warning) -} -.overview { - display:grid; - grid-template-columns:1.15fr 1fr; - gap:24px; - padding-bottom:24px -} -.clock-card,.connection-card { - min-width:0; - background:var(--soft); - padding:24px; - border:1px solid var(--line); - border-radius:8px -} -.clock { - font-size:clamp(2.8rem,7vw,5.4rem); - font-weight:500; - line-height:1.2; - letter-spacing:-.025em; - font-variant-numeric:tabular-nums; - margin:8px 0 -} -.eyebrow { - font-size:.84rem; - color:var(--muted); - font-weight:600 -} -.connection { - font-size:1.5rem; - font-weight:600; - margin:12px 0 8px; - overflow-wrap:anywhere -} -.connection::before,.service-state::before { - content:""; - display:inline-block; - width:9px; - height:9px; - border:2px solid currentColor; - border-radius:50%; - margin-right:9px; - vertical-align:middle -} -.connection-card .muted { - margin-top:12px -} -.muted,.section-note { - color:var(--muted); - font-size:.91rem -} -.section-heading { - display:flex; - flex-wrap:wrap; - gap:12px; - align-items:center; - justify-content:space-between; - margin:20px 0 8px -} -.section-note { - margin-bottom:16px -} -.service-grid,.host-grid { - display:grid; - grid-template-columns:repeat(4,minmax(0,1fr)); - gap:16px -} -.host-grid { - grid-template-columns:repeat(3,minmax(0,1fr)); - margin-top:16px -} -.card { - background:var(--card); - border:1px solid var(--line); - border-radius:8px; - padding:20px; - min-width:0 -} -.icon { - width:28px; - height:28px; - fill:none; - stroke:var(--accent); - stroke-width:1.6; - stroke-linecap:round; - stroke-linejoin:round; - margin-bottom:12px -} -.service-state { - font-weight:600; - margin:10px 0 6px; - font-size:.96rem -} -.good { - color:var(--good) -} -.warning { - color:var(--warning) -} -.observation { - margin:14px 0 24px; - color:var(--muted); - font-size:.88rem -} -.value { - font-size:clamp(1.7rem,3vw,2.4rem); - font-weight:500; - font-variant-numeric:tabular-nums; - margin:8px 0; - line-height:1.25 -} -.power-value { - font-size:1.2rem; - font-weight:600; - margin:12px 0 -} -.help { - margin-top:28px; - border:1px solid var(--line); - border-radius:8px; - padding:16px 20px -} -.help summary { - cursor:pointer; - min-height:44px; - padding-block:8px; - font-weight:600 -} -.help > p { - margin-top:14px; - color:var(--muted); - max-width:85ch -} -.footer { - display:flex; - flex-wrap:wrap; - justify-content:space-between; - gap:10px; - padding-block:24px; - color:var(--muted); - font-size:.83rem; - border-top:1px solid var(--line) -} + color-scheme: light; + --ink: #24282d; + --muted: #636a72; + --line: #e1e3e6; + --soft: #f6f6f5; + --accent: #0072b2; + --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,2.8vw,1.9rem); 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%; } +.heading { flex: 1; min-width: 0; } +.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: #c99431; } +#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); } @media(max-width:900px) { - .brand { - width:140px - } - .header { - gap:16px - } - .service-grid { - grid-template-columns:repeat(2,minmax(0,1fr)) - } + .brand { width: 120px; } + .header { gap: 18px; } + .service-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } } @media(max-width:600px) { - .header { - padding-block:16px - } - .brand { - width:110px - } - .overview { - grid-template-columns:1fr; - gap:16px - } - .clock-card,.connection-card { - padding:20px - } - .service-grid,.host-grid { - gap:12px - } - .host-grid { - grid-template-columns:1fr - } - .card { - padding:16px - } - .clock { - font-size:3.1rem - } + 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,.host-grid { - grid-template-columns:1fr - } - .clock { - font-size:2.65rem - } -} -@media(prefers-reduced-motion:reduce) { - * { - scroll-behavior:auto - } + .service-grid { grid-template-columns: 1fr; } + .clock { font-size: 2.6rem; } } diff --git a/docs/interface.md b/docs/interface.md index 065d818..60a8fcb 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -82,6 +82,10 @@ blinds, HVAC or acquisition data, and does not assess IRMA recorder health. 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`. @@ -95,6 +99,15 @@ until one succeeds. The fixed service groups have `name` and `state`: `advertise 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 diff --git a/tests/test_browser_status.py b/tests/test_browser_status.py index 0fdd3cc..44b5892 100644 --- a/tests/test_browser_status.py +++ b/tests/test_browser_status.py @@ -126,3 +126,25 @@ def test_explicit_network_configuration(): 'http://user:secret@192.168.1.5:11311', 'http://192.168.1.5:11311?redirect=x'): with pytest.raises(ValueError): status.master_url(value) + + +def test_weather_cache_preserves_provenance_on_failure(): + value = {'temperature_f': 60, 'temperature_c': 15.6, 'condition': 'Cloudy', + 'code': 3, 'sunrise': '2026-10-14T07:00', 'sunset': '2026-10-14T18:00', + 'model_time': '2026-10-14T12:00'} + snapshot = status.Snapshot('Test host', weather_location=(42.7, -73.7)) + reply = type('Reply', (), {'stdout': json.dumps(value)})() + with patch.object(status, 'host_status', return_value={}), patch.object(status.subprocess, 'run', return_value=reply) as fetch: + snapshot.collect() + original = snapshot.read()['weather']['observed_at'] + snapshot.collect() + assert fetch.call_count == 1 # Multiple samplings do not multiply weather requests. + snapshot.weather_attempt -= 901 + snapshot.weather_time -= 1900 + fetch.side_effect = OSError('Offline') + snapshot.collect() + result = snapshot.read()['weather'] + assert result['state'] == 'unavailable' + assert result['observed_at'] == original + assert result['data'] == value + assert result['observation_age_s'] >= 1900 From a036c4dd79f1b24fc81777f831e134756db2afec Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Wed, 14 Oct 2026 14:21:15 -0400 Subject: [PATCH 04/24] Start the read-only website at boot on the room subnet --- README.md | 6 ++- WORKFLOWS.md | 64 +++++++++++++++++++++++++----- browser/scr-browser-status.service | 17 ++++++++ browser/serve.py | 20 +++++++++- browser/start.sh | 10 +++++ tests/test_browser_status.py | 15 ++++++- 6 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 browser/scr-browser-status.service create mode 100644 browser/start.sh diff --git a/README.md b/README.md index c4014ff..b0f0fb0 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,9 @@ normally rather than shrinking or clipping content. For room-network access and the approved ROS master, use the explicit addresses in [browser operation](WORKFLOWS.md#read-only-browser-status). The default is -loopback-only. This is a separate visitor status page with no controls or recorder +loopback-only. Room hosting requires an explicit trusted client subnet. The optional +independent user service starts the website at boot; its setup and rollback are in +that runbook. This is a separate visitor status page with no controls or recorder metadata; it does not change the cabinet panel or its launcher. Service presence is not device health. Browser control and staff authentication remain future work. @@ -92,7 +94,7 @@ 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. +Read-only browser views are implemented; browser control remains future work. Beta: **Diyanko Bhowmik**, with Codex assistance. Original contributors remain in Git history. The supplied LESA logo is retained unchanged. diff --git a/WORKFLOWS.md b/WORKFLOWS.md index f992780..3342e72 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -125,17 +125,21 @@ control adapter and exposes no actuator, recorder or maintenance command route. python -B browser/serve.py # Room host: substitute verified private interface/master addresses. -python -B browser/serve.py --bind PANEL_PRIVATE_IP --port 8768 \ +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, not all interfaces. Open the +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: anyone able to reach it can view the limited status payload. +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. This does not secure existing ROS services. +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 @@ -160,12 +164,52 @@ 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. -For this trial, start the process separately and retain its release commit. Do not -replace panel autostart, install an OS service or restart the room computer. Stop -the foreground process with Ctrl+C, or stop only its verified PID if launched in -the background. No lighting restoration is needed because it owns no lights. -Reboot persistence is deliberately not configured. Validate small screens, -keyboard navigation, failure age and resource use before broader deployment. +### Independent website startup + +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 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 index 72bbc56..042c96e 100644 --- a/browser/serve.py +++ b/browser/serve.py @@ -54,6 +54,19 @@ def master_url(value): 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, @@ -255,6 +268,7 @@ def do_POST(self): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--bind', default='127.0.0.1', help='Explicit private IPv4 address; default is local-only') + parser.add_argument('--allow-clients', help='Trusted IPv4 subnet in CIDR form; required for room-network hosting') parser.add_argument('--port', type=int, default=8768) parser.add_argument('--label', default='Dashboard host', help='Public host label; no private experiment metadata') parser.add_argument('--master', help='Approved private ROS master URL; omit for host-only status') @@ -264,6 +278,9 @@ def main(): args = parser.parse_args() try: args.bind = private_address(args.bind) + allowed = client_network(args.allow_clients or '127.0.0.0/8') + if ipaddress.IPv4Address(args.bind) not in allowed: + raise ValueError('The bind address must belong to the explicit allowed client subnet') if not 1 <= args.port <= 65535 or not 1 <= len(args.label) <= 60: raise ValueError('Use port 1..65535 and a host label of 1..60 characters') if args.master: @@ -280,7 +297,8 @@ def main(): location = None if args.weather_latitude is None else (args.weather_latitude, args.weather_longitude) snapshot = Snapshot(args.label, args.master, location) snapshot.collect() - server = HTTPServer((args.bind, args.port), Handler) + server = RoomServer((args.bind, args.port), Handler) + server.allowed_clients = allowed server.expected_host = '%s:%d' % server.server_address server.snapshot = snapshot stopped = threading.Event() diff --git a/browser/start.sh b/browser/start.sh new file mode 100644 index 0000000..63114d3 --- /dev/null +++ b/browser/start.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Deployment values come from the independent user service's EnvironmentFile. +set -eu +exec /usr/bin/python3 -B "$(dirname "$0")/serve.py" \ + --bind "${SCR_WEB_BIND:?Set the verified room interface address}" \ + --allow-clients "${SCR_WEB_CLIENTS:?Set the trusted room subnet}" \ + --port "${SCR_WEB_PORT:-8768}" --label "${SCR_WEB_LABEL:-Cabinet Pi}" \ + --master "${SCR_WEB_MASTER:?Set the approved room master}" \ + --weather-latitude "${SCR_WEB_LATITUDE:?Set the outdoor weather latitude}" \ + --weather-longitude "${SCR_WEB_LONGITUDE:?Set the outdoor weather longitude}" diff --git a/tests/test_browser_status.py b/tests/test_browser_status.py index 44b5892..f6bd3c4 100644 --- a/tests/test_browser_status.py +++ b/tests/test_browser_status.py @@ -55,7 +55,8 @@ def server(): snapshot = status.Snapshot('Test host') with patch.object(status, 'host_status', return_value={}): snapshot.collect() - instance = status.HTTPServer(('127.0.0.1', 0), status.Handler) + 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) @@ -128,6 +129,18 @@ def test_explicit_network_configuration(): status.master_url(value) +def test_client_subnet_rejects_other_peers_and_forwarded_headers(server): + assert request(server, 'GET', '/api/status')[0] == 200 + server.allowed_clients = status.client_network('192.168.0.0/24') + with pytest.raises((http.client.RemoteDisconnected, ConnectionResetError, ConnectionAbortedError)): + request(server, 'GET', '/api/status', {'X-Forwarded-For': '192.168.0.20'}) + assert not server.verify_request(None, ('192.168.1.20', 1234)) + assert server.verify_request(None, ('192.168.0.20', 1234)) + for value in ('0.0.0.0/0', '8.8.8.0/24', '192.168.0.28/24'): + with pytest.raises(ValueError): + status.client_network(value) + + def test_weather_cache_preserves_provenance_on_failure(): value = {'temperature_f': 60, 'temperature_c': 15.6, 'condition': 'Cloudy', 'code': 3, 'sunrise': '2026-10-14T07:00', 'sunset': '2026-10-14T18:00', From bdf3c348137e2c8936c1e2d84e11baa472f278f0 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 15 Oct 2026 16:51:48 -0400 Subject: [PATCH 05/24] Align the read-only website with everyday panel controls --- README.md | 6 ++++-- WORKFLOWS.md | 5 +++++ browser/index.html | 27 +++++++++++---------------- browser/status.js | 2 +- browser/styles.css | 20 ++++++++++++++++++++ 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b0f0fb0..964ed6b 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,10 @@ The updated branch has different navigation and additional features. Run `python -B browser/serve.py` with Python 3.11 or newer, then open the printed local URL. No packages, build tools or room hardware are required. The page shows room time, this host's resources and optional advertised room-service availability. -Overview introduces lighting, sensing and research, with clock and optional outdoor -weather. Diagnostics is a separate view for actual host readings and room-interface +Home follows the cabinet layout: stacked Lights and Blinds actions beside a large +clock and optional outdoor weather. Website action buttons are disabled; lighting +uses the cabinet panel and blinds are not enabled. Advanced · diagnostics and +Demo & guide lead to separate views. Diagnostics shows actual host readings and room-interface registrations. Guide explains room use and failures. All three share fixed navigation, the original main LESA logo and a white surface. There are no appearance switches, storage cards or technical status on Overview. Phone, tablet and desktop layouts scroll diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 3342e72..b396bd0 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -166,6 +166,11 @@ 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 diff --git a/browser/index.html b/browser/index.html index c327ef4..d81e4fd 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1,19 +1,14 @@ -Smart Conference Room · Overview +Smart Conference Room · Home -
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Lighting, sensing and research

Read-only trial
- -
-

A place to explore the room.

Discover what the room can do. For demonstrations or experiments, start with the room host.

-
-

Troy, New York

—

—

Time from your device

-

Outdoors in Troy

—Loading weather…

Outdoor model data, separate from room sensors.

Sunrise —Sunset —

Daylight information unavailable

Weather is optional. Missing data is not replaced with sample values.

-
-

Explore the room

Visitor guide
-
Lighting

Change the atmosphere

Warm and cool white light, plus existing colour scenes. Try them with the host at the cabinet panel.

-
Sensing

Understand the space

The room brings together environmental and activity sensing for demonstrations and research.

-
Research

Bring an idea to life

Experiments can combine room equipment and recordings. Coordinate with staff before starting a study.

-
-

Design trial · This browser view does not operate room equipment.

-
LESAUse the cabinet panel with the room host for controls.
+
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Lights, blinds and the room at a glance

Read-only website
+
+
+

Lights

At the cabinet panel

Use the cabinet panel for lighting.

+

Blinds

Whole room

Not enabled. Ask the room host.

+

Troy, New York

—

—

+

Outdoors in Troy

—Loading weather…

Outdoor weather is separate from room sensors.

+
Sunrise —Sunset —

Daylight information unavailable

Weather is optional.

Time from your device · America/New_York

+

This website operates no equipment.

+
LESACoordinate demonstrations and experiments with the room host.
diff --git a/browser/status.js b/browser/status.js index e475ed3..3c95d41 100644 --- a/browser/status.js +++ b/browser/status.js @@ -44,7 +44,7 @@ function render() { const elapsed=Math.max(0,(performance.now()-receivedAt)/1000); if (!diagnostic) { weather(elapsed); - text('preview-note',snapshot.room.state==='unconfigured'?'Layout preview · Outdoor weather is real model data; no room connection is configured.':'Visitor overview · This page does not operate room equipment.'); + text('preview-note',snapshot.room.state==='unconfigured'?'Local preview · No room connection is configured. Use the cabinet panel for controls.':'Use the cabinet panel for lighting. Blind controls are not enabled; this website operates no equipment.'); return; } const sampleAge=snapshot.sample_age_s+elapsed, stale=failed||sampleAge>90; diff --git a/browser/styles.css b/browser/styles.css index b4916eb..a947444 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -71,12 +71,32 @@ main { padding-block: 26px; } .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; } +.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; } From 8f872ff2fad80b18dedc7c94cb2574ecab9e1cc6 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 15 Oct 2026 19:12:46 -0400 Subject: [PATCH 06/24] Polish Beta branding and simplify weather details --- README.md | 4 +++- browser/diagnostics.html | 2 +- browser/guide.html | 4 ++-- browser/index.html | 4 ++-- browser/status.js | 17 ++++++++++++++--- browser/styles.css | 5 ++++- 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 964ed6b..8e6a5e4 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ Run `python -B browser/serve.py` with Python 3.11 or newer, then open the printe local URL. No packages, build tools or room hardware are required. The page shows room time, this host's resources and optional advertised room-service availability. Home follows the cabinet layout: stacked Lights and Blinds actions beside a large -clock and optional outdoor weather. Website action buttons are disabled; lighting +clock and optional outdoor weather. The shared header identifies Beta. Weather +keeps a short source credit with model provenance under Weather details, and marks +earlier readings or a lost page connection. Website action buttons are disabled; lighting uses the cabinet panel and blinds are not enabled. Advanced · diagnostics and Demo & guide lead to separate views. Diagnostics shows actual host readings and room-interface registrations. Guide explains room use and failures. All three share fixed navigation, diff --git a/browser/diagnostics.html b/browser/diagnostics.html index 9fb9581..dba6081 100644 --- a/browser/diagnostics.html +++ b/browser/diagnostics.html @@ -1,7 +1,7 @@ Smart Conference Room · Diagnostics -
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Diagnostics for room staff

Read-only trial
+
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Beta

Diagnostics for room staff

Read-only website

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…

diff --git a/browser/guide.html b/browser/guide.html index 5e5a98c..6a84e6d 100644 --- a/browser/guide.html +++ b/browser/guide.html @@ -1,9 +1,9 @@ Smart Conference Room · Guide -
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

A guide for visitors and room staff

Read-only trial
+
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Beta

A guide for visitors and room staff

Read-only website

Start with the room host.

This room combines lighting, sensing and research equipment. This website introduces those capabilities; the cabinet panel remains the control surface.

For a demonstration

Ask the host to show warm/cool lighting and the room's existing colour scenes. Coordinate before operating blinds, demos or sensing systems.

For an experiment

Agree who owns each device and how data will be recorded. A panel timer or listed service does not mean sensors are recording.

If something looks wrong

Page connection lost

The browser cannot fetch the status host. Check that your device is on SCR_AP and the touch-panel Pi is reachable. Old readings remain dated.

Room connection unavailable

The status host cannot read the room computer's service list. This is separate from browser connectivity. It does not establish whether a physical device has failed.

Service not listed

The expected interface names were absent from the last successful list. Coordinate with staff before restarting services; installed room launchers can replace whole sessions.

Earlier low voltage

The Pi reports a warning since boot, with no current warning at the sample time. A current low-voltage warning needs attention to the supply, cable and connector. Do not reboot just to hide it.

-

How this view works

The touch-panel Pi hosts the page on the room LAN. It keeps a cached read-only summary. Outdoor weather comes from Open-Meteo model data, independently of room sensors. Diagnostics shows registration and host information; device commands and staff control are future work.

+

Advanced on the cabinet

One sidebar opens White light, Scenes & channels, Experiment mode, System & help and device preferences directly. Fid, Sat and Dul keep their existing room scene names. Experiment mode runs instructions and saves panel annotations; it does not start a sensor recorder. Legacy is available directly from the cabinet header and restores Beta’s starting light scene before handing over.

How this view works

The touch-panel Pi hosts the page on the room LAN. It keeps a cached read-only summary. Outdoor weather comes from Open-Meteo model data, independently of room sensors. Diagnostics shows registration and host information; device commands and staff control are future work.

LESACoordinate changes with the room owner.
diff --git a/browser/index.html b/browser/index.html index d81e4fd..9731da4 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1,14 +1,14 @@ Smart Conference Room · Home -
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Lights, blinds and the room at a glance

Read-only website
+
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Beta

Lights, blinds and the room at a glance

Read-only website

Lights

At the cabinet panel

Use the cabinet panel for lighting.

Blinds

Whole room

Not enabled. Ask the room host.

Troy, New York

—

—

Outdoors in Troy

—Loading weather…

Outdoor weather is separate from room sensors.

-
Sunrise —Sunset —

Daylight information unavailable

Weather is optional.

Time from your device · America/New_York

+
Sunrise —Sunset —

Daylight information unavailable

Weather is optional.

Weather details

Weather is optional.

Time from your device · America/New_York

This website operates no equipment.

LESACoordinate demonstrations and experiments with the room host.
diff --git a/browser/status.js b/browser/status.js index 3c95d41..e6fa361 100644 --- a/browser/status.js +++ b/browser/status.js @@ -15,7 +15,8 @@ function weather(elapsed) { const value=info.data; if (!value) { text('weather-temperature','—'); text('weather-condition',info.state==='unconfigured'?'Weather not configured':'Weather unavailable'); - text('weather-source','No sample values are substituted.'); + text('weather-source','Weather is optional.'); + text('weather-model','No weather reading is available.'); if (byId('daylight')) byId('daylight').hidden=true; return; } @@ -25,7 +26,8 @@ function weather(elapsed) { text('weather-temperature',`${Math.round(value.temperature_f)} °F`); text('weather-condition',value.condition); text('weather-secondary',`${Math.round(value.temperature_c)} °C · Troy outdoors`); - text('weather-source',`${stale?'Earlier weather · '+value.model_time.slice(0,10)+' · ':''}Weather as of ${localTime(value.model_time)} · Open-Meteo`); + text('weather-source',`${stale?'Earlier weather':'Weather'} · Open-Meteo`); + text('weather-model',`Model ${value.model_time.slice(0,10)} at ${localTime(value.model_time)} · Outdoor weather is separate from room sensors.`); const daylight=byId('daylight'); if (!daylight) return; daylight.hidden=stale||value.sunrise.slice(0,10)!==today||value.sunset.slice(0,10)!==today; @@ -40,7 +42,16 @@ function weather(elapsed) { } function render() { text('clock',roomTime.format(new Date())); text('date',roomDate.format(new Date())); - if (!snapshot) { badge(byId('connection'),failed?'Page connection lost':'Checking…',failed?'warning':''); return; } + if (!snapshot) { + badge(byId('connection'),failed?'Page connection lost':'Checking…',failed?'warning':''); + if (failed&&!diagnostic) { + text('weather-condition','Weather unavailable'); + text('weather-source','Page connection lost. Weather will retry automatically.'); + text('weather-model','No weather reading is available.'); + if (byId('daylight')) byId('daylight').hidden=true; + } + return; + } const elapsed=Math.max(0,(performance.now()-receivedAt)/1000); if (!diagnostic) { weather(elapsed); diff --git a/browser/styles.css b/browser/styles.css index a947444..0e302d0 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -21,7 +21,10 @@ a:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } .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%; } -.heading { flex: 1; min-width: 0; } +.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: .8rem; } +.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); } From f4f5d3ac61bb713c02f9b603c5c2ea2ee63544eb Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 15 Oct 2026 21:05:03 -0400 Subject: [PATCH 07/24] Show compact daily weather beside a larger room header --- README.md | 3 ++- app/weather.py | 50 +++++++++++++++++++++++++++++++++++++++++----- browser/index.html | 2 +- browser/status.js | 13 +++++++++--- browser/styles.css | 5 +++-- 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8e6a5e4..2b4f52f 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ local URL. No packages, build tools or room hardware are required. The page show room time, this host's resources and optional advertised room-service availability. Home follows the cabinet layout: stacked Lights and Blinds actions beside a large clock and optional outdoor weather. The shared header identifies Beta. Weather -keeps a short source credit with model provenance under Weather details, and marks +shows feels-like temperature, today's high/low and rain chance when available. +Source credit and model provenance stay under Weather details. It marks earlier readings or a lost page connection. Website action buttons are disabled; lighting uses the cabinet panel and blinds are not enabled. Advanced · diagnostics and Demo & guide lead to separate views. Diagnostics shows actual host readings and room-interface diff --git a/app/weather.py b/app/weather.py index 416958a..e3a08d8 100644 --- a/app/weather.py +++ b/app/weather.py @@ -30,9 +30,16 @@ def summary(data): condition = 'Thunderstorms' else: condition = 'Conditions unavailable' - return validate({'temperature_f': current['temperature_2m'], 'condition': condition, - 'code': code, 'sunrise': daily['sunrise'][0], 'sunset': daily['sunset'][0], - 'model_time': current['time']}) + 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): @@ -54,9 +61,41 @@ def validate(value): if local_time(value['sunrise']) >= local_time(value['sunset']): raise ValueError('Invalid daylight interval') code = int(value['code']) - return {'temperature_f': temperature, 'temperature_c': (temperature - 32) * 5.0 / 9, + 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): @@ -78,7 +117,8 @@ def daylight(value, now): 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', 'daily': 'sunrise,sunset', + '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: diff --git a/browser/index.html b/browser/index.html index 9731da4..fabbfbd 100644 --- a/browser/index.html +++ b/browser/index.html @@ -7,7 +7,7 @@

Lights

At the cabinet panel

Use the cabinet panel for lighting.

Blinds

Whole room

Not enabled. Ask the room host.

Troy, New York

—

—

-

Outdoors in Troy

—Loading weather…

Outdoor weather is separate from room sensors.

+

Outdoors in Troy

—Loading weather…

Outdoor weather is separate from room sensors.

Sunrise —Sunset —

Daylight information unavailable

Weather is optional.

Weather details

Weather is optional.

Time from your device · America/New_York

This website operates no equipment.

LESACoordinate demonstrations and experiments with the room host.
diff --git a/browser/status.js b/browser/status.js index e6fa361..e0eef48 100644 --- a/browser/status.js +++ b/browser/status.js @@ -17,6 +17,8 @@ function weather(elapsed) { 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; } @@ -25,9 +27,14 @@ function weather(elapsed) { 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',`${Math.round(value.temperature_c)} °C · Troy outdoors`); - text('weather-source',`${stale?'Earlier weather':'Weather'} · Open-Meteo`); - text('weather-model',`Model ${value.model_time.slice(0,10)} at ${localTime(value.model_time)} · Outdoor weather is separate from room sensors.`); + 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 daylight=byId('daylight'); if (!daylight) return; daylight.hidden=stale||value.sunrise.slice(0,10)!==today||value.sunset.slice(0,10)!==today; diff --git a/browser/styles.css b/browser/styles.css index 0e302d0..566296f 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -11,7 +11,7 @@ * { 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,2.8vw,1.9rem); line-height: 1.2; } +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; } @@ -22,7 +22,7 @@ a:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } .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: .8rem; } +.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); } @@ -86,6 +86,7 @@ main { padding-block: 26px; } .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) { From bbf6bf8db08d1640045770412cb602a58a4c65b3 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Thu, 15 Oct 2026 23:13:49 -0400 Subject: [PATCH 08/24] Match browser icons and weather to panel styling --- README.md | 2 + browser/icons/LICENSE | 43 +++++++++++++++++ browser/icons/book-open.svg | 14 ++++++ browser/icons/circle-question-mark-light.png | Bin 0 -> 11148 bytes browser/icons/cloud-fog-light.png | Bin 0 -> 4648 bytes browser/icons/cloud-light.png | Bin 0 -> 4625 bytes browser/icons/cloud-lightning-light.png | Bin 0 -> 5351 bytes browser/icons/cloud-moon-light.png | Bin 0 -> 5902 bytes browser/icons/cloud-rain-light.png | Bin 0 -> 4604 bytes browser/icons/cloud-snow-light.png | Bin 0 -> 4691 bytes browser/icons/cloud-sun-light.png | Bin 0 -> 5625 bytes browser/icons/lightbulb-off.svg | 17 +++++++ browser/icons/lightbulb.svg | 15 ++++++ browser/icons/moon-light.png | Bin 0 -> 5673 bytes browser/icons/sliders-horizontal.svg | 21 ++++++++ browser/icons/source.json | 48 +++++++++++++++++++ browser/icons/sun-light.png | Bin 0 -> 5987 bytes browser/index.html | 8 ++-- browser/serve.py | 18 +++++++ browser/status.js | 19 ++++++++ browser/styles.css | 16 ++++--- tests/test_browser_status.py | 9 +++- 22 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 browser/icons/LICENSE create mode 100644 browser/icons/book-open.svg create mode 100644 browser/icons/circle-question-mark-light.png create mode 100644 browser/icons/cloud-fog-light.png create mode 100644 browser/icons/cloud-light.png create mode 100644 browser/icons/cloud-lightning-light.png create mode 100644 browser/icons/cloud-moon-light.png create mode 100644 browser/icons/cloud-rain-light.png create mode 100644 browser/icons/cloud-snow-light.png create mode 100644 browser/icons/cloud-sun-light.png create mode 100644 browser/icons/lightbulb-off.svg create mode 100644 browser/icons/lightbulb.svg create mode 100644 browser/icons/moon-light.png create mode 100644 browser/icons/sliders-horizontal.svg create mode 100644 browser/icons/source.json create mode 100644 browser/icons/sun-light.png diff --git a/README.md b/README.md index 2b4f52f..1482c99 100644 --- a/README.md +++ b/README.md @@ -103,3 +103,5 @@ Read-only browser views are implemented; browser control remains future work. Beta: **Diyanko Bhowmik**, with Codex assistance. Original contributors remain in Git history. The supplied LESA logo is retained unchanged. + +Browser and panel use a shared Lucide icon family and coordinated weather artwork. Artwork notices are retained in [browser/icons/LICENSE](browser/icons/LICENSE). 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 0000000000000000000000000000000000000000..d9dc143e5dd5b62489dd3b5844d9ee3774faf215 GIT binary patch literal 11148 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 LNvaHP6!?DtowFP= literal 0 HcmV?d00001 diff --git a/browser/icons/cloud-fog-light.png b/browser/icons/cloud-fog-light.png new file mode 100644 index 0000000000000000000000000000000000000000..e2a035c90cddee886f6bca99c7651bdeccd1f655 GIT binary patch literal 4648 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 HI{d!?C*^T! literal 0 HcmV?d00001 diff --git a/browser/icons/cloud-light.png b/browser/icons/cloud-light.png new file mode 100644 index 0000000000000000000000000000000000000000..4964b2e608877cd88f9806864d39c59b88f04bb2 GIT binary patch literal 4625 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 literal 0 HcmV?d00001 diff --git a/browser/icons/cloud-lightning-light.png b/browser/icons/cloud-lightning-light.png new file mode 100644 index 0000000000000000000000000000000000000000..83e16f6adaf6deb025d2290224a69eefa30847f5 GIT binary patch literal 5351 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 P7QoEts$nhicI^KF)8+#` literal 0 HcmV?d00001 diff --git a/browser/icons/cloud-moon-light.png b/browser/icons/cloud-moon-light.png new file mode 100644 index 0000000000000000000000000000000000000000..ca4cdce3d7789b7ae7d83aa3bdf1e672470ba03d GIT binary patch literal 5902 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 literal 0 HcmV?d00001 diff --git a/browser/icons/cloud-rain-light.png b/browser/icons/cloud-rain-light.png new file mode 100644 index 0000000000000000000000000000000000000000..561db4f749427f7365ec56a915d5028a4379ba78 GIT binary patch literal 4604 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 literal 0 HcmV?d00001 diff --git a/browser/icons/cloud-sun-light.png b/browser/icons/cloud-sun-light.png new file mode 100644 index 0000000000000000000000000000000000000000..24f468e9fc5ae739b3639441cb91c0196d01d8c5 GIT binary patch literal 5625 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..54c2ca8cce02b8df3e131c233bd5df35b6cd510b GIT binary patch literal 5673 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 JS*76+{y#6Lj|Bh# literal 0 HcmV?d00001 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..c637e6d --- /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 preserves original Lucide paths with a thin outline and restrained day/night colours. No gradients or halos." +} diff --git a/browser/icons/sun-light.png b/browser/icons/sun-light.png new file mode 100644 index 0000000000000000000000000000000000000000..5084ea45683394f1c8c51e7f1ad4ca28b0fcfa57 GIT binary patch literal 5987 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
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Beta

Lights, blinds and the room at a glance

Read-only website
-

Lights

At the cabinet panel

Use the cabinet panel for lighting.

-

Blinds

Whole room

Not enabled. Ask the room host.

+

Lights

At the cabinet panel

Use the cabinet panel for lighting.

+

Blinds

Whole room

Disabled.

Troy, New York

—

—

-

Outdoors in Troy

—Loading weather…

Outdoor weather is separate from room sensors.

+

Outdoors in Troy

—Loading weather…

Outdoor weather is separate from room sensors.

Sunrise —Sunset —

Daylight information unavailable

Weather is optional.

Weather details

Weather is optional.

Time from your device · America/New_York

-

This website operates no equipment.

+

This website operates no equipment.

LESACoordinate demonstrations and experiments with the room host.
diff --git a/browser/serve.py b/browser/serve.py index 042c96e..7e60fc9 100644 --- a/browser/serve.py +++ b/browser/serve.py @@ -31,6 +31,24 @@ '/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({ + '/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() diff --git a/browser/status.js b/browser/status.js index e0eef48..6552b09 100644 --- a/browser/status.js +++ b/browser/status.js @@ -10,10 +10,25 @@ const age = seconds => seconds < 60 ? `${Math.floor(seconds)} seconds 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.'); @@ -35,6 +50,10 @@ function weather(elapsed) { 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; diff --git a/browser/styles.css b/browser/styles.css index 566296f..7817ef1 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -1,10 +1,10 @@ :root { color-scheme: light; - --ink: #24282d; - --muted: #636a72; - --line: #e1e3e6; - --soft: #f6f6f5; - --accent: #0072b2; + --ink: #12384b; + --muted: #476373; + --line: #cbd6df; + --soft: #edf1f4; + --accent: #176b72; --good: #26744b; --warning: #92580c; } @@ -48,7 +48,7 @@ main { padding-block: 26px; } .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: #c99431; } +#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; } @@ -115,3 +115,7 @@ main { padding-block: 26px; } .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; } diff --git a/tests/test_browser_status.py b/tests/test_browser_status.py index f6bd3c4..a3a1d68 100644 --- a/tests/test_browser_status.py +++ b/tests/test_browser_status.py @@ -87,9 +87,16 @@ def test_http_boundary_and_cache(server): 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' Date: Fri, 16 Oct 2026 00:01:19 -0400 Subject: [PATCH 09/24] Add shared room controls and copyable help references --- README.md | 140 ++----- browser/controller.py | 21 ++ browser/design.js | 9 + browser/diagnostics.html | 6 +- browser/guide.html | 10 +- browser/index.html | 15 +- browser/room-content.json | 688 +++++++++++++++++++++++++++++++++++ browser/room.css | 8 + browser/room.js | 166 +++++++++ browser/serve.py | 51 ++- browser/start.sh | 6 +- browser/styles.css | 3 + browser/theme.js | 3 + browser/tools.html | 1 + tests/test_browser_status.py | 25 +- 15 files changed, 1011 insertions(+), 141 deletions(-) create mode 100644 browser/controller.py create mode 100644 browser/design.js create mode 100644 browser/room-content.json create mode 100644 browser/room.css create mode 100644 browser/room.js create mode 100644 browser/theme.js create mode 100644 browser/tools.html diff --git a/README.md b/README.md index 1482c99..52b40b0 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,33 @@ -# 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 - -### Browser room overview (read-only trial) - -Run `python -B browser/serve.py` with Python 3.11 or newer, then open the printed -local URL. No packages, build tools or room hardware are required. The page shows -room time, this host's resources and optional advertised room-service availability. -Home follows the cabinet layout: stacked Lights and Blinds actions beside a large -clock and optional outdoor weather. The shared header identifies Beta. Weather -shows feels-like temperature, today's high/low and rain chance when available. -Source credit and model provenance stay under Weather details. It marks -earlier readings or a lost page connection. Website action buttons are disabled; lighting -uses the cabinet panel and blinds are not enabled. Advanced · diagnostics and -Demo & guide lead to separate views. Diagnostics shows actual host readings and room-interface -registrations. Guide explains room use and failures. All three share fixed navigation, -the original main LESA logo and a white surface. There are no appearance switches, -storage cards or technical status on Overview. Phone, tablet and desktop layouts scroll -normally rather than shrinking or clipping content. - -For room-network access and the approved ROS master, use the explicit addresses -in [browser operation](WORKFLOWS.md#read-only-browser-status). The default is -loopback-only. Room hosting requires an explicit trusted client subnet. The optional -independent user service starts the website at boot; its setup and rollback are in -that runbook. This is a separate visitor status page with no controls or recorder -metadata; it does not change the cabinet panel or its launcher. Service presence -is not device health. Browser control and staff authentication remain future work. - -| 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. -Read-only browser views are implemented; browser control remains future work. - -Beta: **Diyanko Bhowmik**, with Codex assistance. Original contributors remain in -Git history. The supplied LESA logo is retained unchanged. - -Browser and panel use a shared Lucide icon family and coordinated weather artwork. Artwork notices are retained in [browser/icons/LICENSE](browser/icons/LICENSE). +# SCR room browser interface + +A browser companion to the touch panel, with the same Home controls, white light, +scenes/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. Blinds remain disabled; Experiment mode does not +start a recorder. Service-start/restart references are monospace text with Copy, +never executable buttons. + +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. Beta is maintained by Diyanko Bhowmik. 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..60b4d06 --- /dev/null +++ b/browser/design.js @@ -0,0 +1,9 @@ +/* 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; + if(theme==='dark')for(const key of ['good','warning'])document.documentElement.style.setProperty('--'+key,palette.ink); +}}; diff --git a/browser/diagnostics.html b/browser/diagnostics.html index dba6081..4023a86 100644 --- a/browser/diagnostics.html +++ b/browser/diagnostics.html @@ -1,8 +1,8 @@ -Smart Conference Room · Diagnostics +Smart Conference Room · Diagnostics -
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Beta

Diagnostics for room staff

Read-only website
- +
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.

diff --git a/browser/guide.html b/browser/guide.html index 6a84e6d..3a4db2d 100644 --- a/browser/guide.html +++ b/browser/guide.html @@ -1,9 +1 @@ - -Smart Conference Room · Guide -
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Beta

A guide for visitors and room staff

Read-only website
- -

Start with the room host.

This room combines lighting, sensing and research equipment. This website introduces those capabilities; the cabinet panel remains the control surface.

-

For a demonstration

Ask the host to show warm/cool lighting and the room's existing colour scenes. Coordinate before operating blinds, demos or sensing systems.

For an experiment

Agree who owns each device and how data will be recorded. A panel timer or listed service does not mean sensors are recording.

-

If something looks wrong

Page connection lost

The browser cannot fetch the status host. Check that your device is on SCR_AP and the touch-panel Pi is reachable. Old readings remain dated.

Room connection unavailable

The status host cannot read the room computer's service list. This is separate from browser connectivity. It does not establish whether a physical device has failed.

Service not listed

The expected interface names were absent from the last successful list. Coordinate with staff before restarting services; installed room launchers can replace whole sessions.

Earlier low voltage

The Pi reports a warning since boot, with no current warning at the sample time. A current low-voltage warning needs attention to the supply, cable and connector. Do not reboot just to hide it.

-

Advanced on the cabinet

One sidebar opens White light, Scenes & channels, Experiment mode, System & help and device preferences directly. Fid, Sat and Dul keep their existing room scene names. Experiment mode runs instructions and saves panel annotations; it does not start a sensor recorder. Legacy is available directly from the cabinet header and restores Beta’s starting light scene before handing over.

How this view works

The touch-panel Pi hosts the page on the room LAN. It keeps a cached read-only summary. Outdoor weather comes from Open-Meteo model data, independently of room sensors. Diagnostics shows registration and host information; device commands and staff control are future work.

-
LESACoordinate changes with the room owner.
+Smart Conference Room · Beta
LESA

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Blinds disabled · Recorder not connected
\ No newline at end of file diff --git a/browser/index.html b/browser/index.html index 1a47bf6..3a4db2d 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1,14 +1 @@ - -Smart Conference Room · Home - -
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Beta

Lights, blinds and the room at a glance

Read-only website
-
-
-

Lights

At the cabinet panel

Use the cabinet panel for lighting.

-

Blinds

Whole room

Disabled.

-

Troy, New York

—

—

-

Outdoors in Troy

—Loading weather…

Outdoor weather is separate from room sensors.

-
Sunrise —Sunset —

Daylight information unavailable

Weather is optional.

Weather details

Weather is optional.

Time from your device · America/New_York

-

This website operates no equipment.

-
LESACoordinate demonstrations and experiments with the room host.
- +Smart Conference Room · Beta
LESA

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Blinds disabled · Recorder not connected
\ No newline at end of file diff --git a/browser/room-content.json b/browser/room-content.json new file mode 100644 index 0000000..116102c --- /dev/null +++ b/browser/room-content.json @@ -0,0 +1,688 @@ +{ + "version": 1, + "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" + } + } + ], + "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": { + "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" + }, + "dark": { + "bg": "#171717", + "surface": "#171717", + "ink": "#f2f2f2", + "muted": "#bdbdbd", + "control": "#292929", + "border": "#454545", + "accent": "#dedede", + "selected_ink": "#171717", + "accent_pressed": "#c5c5c5", + "pressed": "#414141", + "icon_colour": "#c5c5c5" + } + }, + "guide": [ + { + "title": "Lighting", + "icon": "on", + "text": "Use Lights on and Lights off on Home for everyday lighting.\n\nRoom tools → White light adjusts warmth and level. Scenes & channels offers the room’s saved lighting mixtures.\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 Beta’s starting lighting scene. Use Home in the Legacy bar to return.\n\nBlinds are currently disabled. Room tools → Screen can hide their Home section." + }, + { + "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 Beta 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": "" + }, + { + "title": "Power", + "text": "An undervoltage warning means the Pi supply voltage is too low. This can slow the panel and affect storage reliability.\n\nCheck the supply, cable and connector. A separately powered LCD does not rule out a Pi power problem.\n\nDo not hide the warning or keep power-cycling to clear it. Shut down normally before changing power.", + "commands": "vcgencmd get_throttled" + }, + { + "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\nuptime\nfree -m\ndf -h /\ntmux list-sessions\nsource /opt/ros/indigo/setup.bash\nsource ~/catkin_ws/devel/setup.bash\ntimeout 10 rosservice list" + }, + { + "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\npython startROS.py\n\ncd ~/scr.status_display\npython startStatus.py" + }, + { + "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" + } + ] +} diff --git a/browser/room.css b/browser/room.css new file mode 100644 index 0000000..86b50d9 --- /dev/null +++ b/browser/room.css @@ -0,0 +1,8 @@ +: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}} diff --git a/browser/room.js b/browser/room.js new file mode 100644 index 0000000..5ff8931 --- /dev/null +++ b/browser/room.js @@ -0,0 +1,166 @@ +'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; +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(''); + 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)){message('Command was rejected. Refresh the page before trying a new action.');return false;} + if(!reply){unknown=id;message('Command receipt unknown. Check the panel; do not repeat the action.');return false;} + if(!response.ok||!reply.ok){message(reply.detail||'Command was not accepted.');return false;} + 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('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-white':'home'));} +function layout(){ + const next=route();if(next===view)return;view=next;dragging=false;rangeRevision=null; + const root=$('app');root.replaceChildren(); + for(const a of document.querySelectorAll('.header a'))a.setAttribute('aria-current',a.hash.slice(1)===view?'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,disabled] of [['Lights','Whole room',false],['Blinds','Unavailable',true]]){ + const group=el('div',undefined,'essential-group'+(disabled?' disabled-group':''));group.id=disabled?'blind-group':'light-group'; + group.append(el('h2',title),el('p',caption,'muted')); + for(const label of disabled?['Open slats','Close & lower']:['Lights on','Lights off']){ + const action=label==='Lights on'?'on':'off';const b=button(label,()=>command(action),{icon:disabled?null:action==='on'?'white':'off',action:disabled?null:action}); + if(disabled)b.disabled=true;group.append(b); + }group.append(el('small',disabled?'Disabled':'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); + for(const [id,cls] of [['condition','condition'],['weather-stats','weather-stats']]){const p=el('p',undefined,cls);p.id=id;right.append(p);} + 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-white','tools'),link('Demo & guide','guide','guide'));root.append(nav); +} +const pages=[['White light','white'],['Scenes & channels','scenes'],['Experiment mode','experiment'],['Appearance','appearance'],['Date & weather','clock'],['Screen','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){ + if(!pages.some(p=>p[1]===page))page='white'; + 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);} + shell.append(nav,detail);root.append(shell);detail.append(el('h2',pages.find(p=>p[1]===page)[0])); + if(page==='white'){ + detail.append(el('p','Selected values apply after your touch settles.','muted')); + range(detail,'Colour temperature','cct',1600,10000,100,'K');range(detail,'Light level','intensity',0,1900,25,'controller units');feedback(detail); + }else if(page==='scenes'){ + detail.append(el('p','Room scenes keep their Legacy names.','muted'));const row=el('div',undefined,'row'); + for(const name of ['Fid','Sat','Dul']){const b=button(name,()=>command('preset',{name}),{action:'preset'});b.dataset.preset=name;row.append(b);}detail.append(row); + 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 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.','muted'));detail.append(el('h3','Touch panel display')); + const row=el('div',undefined,'row');row.append(button('Clock & weather on panel',()=>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','Blinds on Home'));choices(detail,[['Show disabled','blinds_display','disabled'],['Hide','blinds_display','hidden']]); + }else if(page==='system')system(detail); + else{detail.append(el('h3','Smart Conference Room'),el('p','Touch panel and browser · Beta mode','muted'),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'));} +} +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('output');output.id='value-'+key;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.textContent=input.value;} + input.oninput=selected;input.onchange=()=>{selected();commitRange(key);}; + for(const [label,delta] of (key.startsWith('channel')?[]:[['−',-step],['+',step]]))steps.append(button(label,()=>{input.value=Math.max(low,Math.min(high,Number(input.value)+delta));selected();commitRange(key);},{action:key.startsWith('channel')?'channels':'cct'})); + 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 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 block=el('div',undefined,'command'),head=el('div',undefined,'command-head'),pre=el('pre'),code=el('code',topic.commands);pre.append(code);head.append(el('span',topic.title+' · commands'),button('Copy commands',async event=>{ + const b=event.currentTarget;try{await copy(topic.commands);b.textContent='Copied';}catch(_){b.textContent='Select text to copy';}setTimeout(()=>{b.textContent='Copy commands';},1800); + }));block.append(head,pre);$('help-detail').append(block); + } +} +async function copy(value){if(navigator.clipboard&&window.isSecureContext)return navigator.clipboard.writeText(value);const input=el('textarea');input.value=value;input.className='visually-hidden';document.body.append(input);input.select();const ok=document.execCommand('copy');input.remove();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 Beta light settings before handing over. Blind commands stay disabled in Beta; 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; + if(state?.palette)window.SCRDesign.apply(state.palette,p.resolved_theme); + update('connection',!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');} + 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||(lighting?!state.can_control:session?!state.can_session:state.busy);} + for(const input of document.querySelectorAll('[data-range]')){input.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;update('value-'+k,value);}}} + 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-health',h.error?'Resource reading unavailable':`Memory: ${h.ram_available??'—'} / ${h.ram_total??'—'} MiB free · CPU: ${h.temperature??'—'} °C · ${h.power??'Power unavailable'}`);update('room-health',state?.room_status||'No current room observation.'); + 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','—');if($('daylight'))$('daylight').hidden=true;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); + const stats=[];if(!old){if(Number.isFinite(value.feels_like_f))stats.push('Feels like '+degree(value.feels_like_f));if(Number.isFinite(value.high_f))stats.push('High '+degree(value.high_f));if(Number.isFinite(value.low_f))stats.push('Low '+degree(value.low_f));if(Number.isFinite(value.rain_chance))stats.push('Rain today '+value.rain_chance+'%');}update('weather-stats',old?'Earlier reading: '+value.model_time.replace('T',' '):stats.join(' · ')); + 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')){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)){unknown=null;message('The panel received the command. Check its current confirmation below.');}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;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();}); +(async()=>{try{content=await(await fetch('/room-content.json')).json();}catch(_){}layout();await refresh();setInterval(refresh,1000);setInterval(weather,1000);})(); diff --git a/browser/serve.py b/browser/serve.py index 7e60fc9..dc84d27 100644 --- a/browser/serve.py +++ b/browser/serve.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -"""Read-only SCR status page. Python 3.11+, no installed dependencies. +"""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. """ @@ -9,6 +11,8 @@ from http.server import BaseHTTPRequestHandler, HTTPServer import ipaddress import json +import secrets +import hmac from pathlib import Path import shutil import socket @@ -28,6 +32,9 @@ } 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')} @@ -267,7 +274,12 @@ def do_GET(self): self.respond(403) return path = self.path - if path == '/api/status': + if path == '/api/controller': + gateway = getattr(self.server, 'controller', None) + value = gateway.read() if gateway else {'available':False,'detail':'Browser control is not configured.'} + value['csrf'] = getattr(self.server, 'control_nonce', '') + self.respond(200, json.dumps(value, allow_nan=False).encode(), 'application/json') + elif path == '/api/status': self.respond(200, json.dumps(self.server.snapshot.read(), allow_nan=False).encode(), 'application/json') elif path in ASSETS: filename, content_type = ASSETS[path] @@ -278,13 +290,36 @@ def do_GET(self): do_HEAD = do_GET def do_POST(self): - self.respond(405, b'Read-only endpoint') + gateway=getattr(self.server,'controller',None) + if self.path!='/api/command' or gateway is None: + self.respond(405,b'Control is not configured');return + if (self.headers.get('Host')!=self.server.expected_host or + self.headers.get('Origin')!='http://'+self.server.expected_host or + not hmac.compare_digest(self.headers.get('X-SCR-Token',''),self.server.control_nonce)): + self.respond(403);return + try: + length=int(self.headers.get('Content-Length','0')) + if (not 1<=length<=4096 or self.headers.get('Transfer-Encoding') or + self.headers.get('Content-Type')!='application/json'): + raise ValueError('Invalid command body') + value=json.loads(self.rfile.read(length)) + if not isinstance(value,dict):raise ValueError('Invalid command') + reply=gateway.exchange(value) + status=int(reply.get('code',503)) + if status not in (202,400,408,409,503):status=503 + self.respond(status,json.dumps(reply,allow_nan=False).encode(),'application/json') + except ValueError: + self.respond(400,b'Invalid command') + except OSError: + self.respond(503,b'Command receipt unknown. Check the panel; do not retry.') - do_PUT = do_DELETE = do_PATCH = do_OPTIONS = do_POST + def unsupported(self):self.respond(405) + do_PUT = do_DELETE = do_PATCH = do_OPTIONS = unsupported def main(): parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--panel-control-socket', help='Opt in to trusted-network controls through the existing panel owner') parser.add_argument('--bind', default='127.0.0.1', help='Explicit private IPv4 address; default is local-only') parser.add_argument('--allow-clients', help='Trusted IPv4 subnet in CIDR form; required for room-network hosting') parser.add_argument('--port', type=int, default=8768) @@ -319,10 +354,16 @@ def main(): server.allowed_clients = allowed server.expected_host = '%s:%d' % server.server_address server.snapshot = snapshot + server.controller = None + server.control_nonce = secrets.token_urlsafe(32) + if args.panel_control_socket: + from controller import Controller + if not Path(args.panel_control_socket).is_absolute():parser.error('Panel socket path must be absolute') + server.controller = Controller(args.panel_control_socket) stopped = threading.Event() worker = threading.Thread(target=snapshot.run, args=(stopped,), daemon=True) worker.start() - print('Read-only room status: http://' + server.expected_host, flush=True) + print('Room interface: http://' + server.expected_host, flush=True) try: server.serve_forever() except KeyboardInterrupt: diff --git a/browser/start.sh b/browser/start.sh index 63114d3..ea4ed41 100644 --- a/browser/start.sh +++ b/browser/start.sh @@ -1,10 +1,14 @@ #!/bin/sh # Deployment values come from the independent user service's EnvironmentFile. set -eu +set -- +if test -n "${SCR_WEB_CONTROL_SOCKET:-}"; then + set -- --panel-control-socket "$SCR_WEB_CONTROL_SOCKET" +fi exec /usr/bin/python3 -B "$(dirname "$0")/serve.py" \ --bind "${SCR_WEB_BIND:?Set the verified room interface address}" \ --allow-clients "${SCR_WEB_CLIENTS:?Set the trusted room subnet}" \ --port "${SCR_WEB_PORT:-8768}" --label "${SCR_WEB_LABEL:-Cabinet Pi}" \ --master "${SCR_WEB_MASTER:?Set the approved room master}" \ --weather-latitude "${SCR_WEB_LATITUDE:?Set the outdoor weather latitude}" \ - --weather-longitude "${SCR_WEB_LONGITUDE:?Set the outdoor weather longitude}" + --weather-longitude "${SCR_WEB_LONGITUDE:?Set the outdoor weather longitude}" "$@" diff --git a/browser/styles.css b/browser/styles.css index 7817ef1..ded0805 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -119,3 +119,6 @@ main { padding-block: 26px; } .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)} 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..3a4db2d --- /dev/null +++ b/browser/tools.html @@ -0,0 +1 @@ +Smart Conference Room · Beta
LESA

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Blinds disabled · Recorder not connected
\ No newline at end of file diff --git a/tests/test_browser_status.py b/tests/test_browser_status.py index a3a1d68..b228e56 100644 --- a/tests/test_browser_status.py +++ b/tests/test_browser_status.py @@ -67,10 +67,10 @@ def server(): thread.join(timeout=2) -def request(server, method, path, headers=None): +def request(server, method, path, headers=None, body=None): connection = http.client.HTTPConnection(*server.server_address, timeout=7) try: - connection.request(method, path, headers=headers or {}) + connection.request(method, path, body=body, headers=headers or {}) response = connection.getresponse() return response.status, response.read(), dict(response.headers) finally: @@ -168,3 +168,24 @@ def test_weather_cache_preserves_provenance_on_failure(): assert result['observed_at'] == original assert result['data'] == value assert result['observation_age_s'] >= 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 From baf0856e40c7f08fc5aa75420af169c1b1bb6a14 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 00:12:23 -0400 Subject: [PATCH 10/24] Highlight help commands without changing copied text --- browser/room-content.json | 239 +++++++++++++++++++++++++++++++++++++- browser/room.css | 6 + browser/room.js | 4 +- 3 files changed, 243 insertions(+), 6 deletions(-) diff --git a/browser/room-content.json b/browser/room-content.json index 116102c..68f39b4 100644 --- a/browser/room-content.json +++ b/browser/room-content.json @@ -662,27 +662,256 @@ { "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": "" + "commands": "", + "command_tokens": [] }, { "title": "Power", "text": "An undervoltage warning means the Pi supply voltage is too low. This can slow the panel and affect storage reliability.\n\nCheck the supply, cable and connector. A separately powered LCD does not rule out a Pi power problem.\n\nDo not hide the warning or keep power-cycling to clear it. Shut down normally before changing power.", - "commands": "vcgencmd get_throttled" + "commands": "vcgencmd get_throttled", + "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\nuptime\nfree -m\ndf -h /\ntmux list-sessions\nsource /opt/ros/indigo/setup.bash\nsource ~/catkin_ws/devel/setup.bash\ntimeout 10 rosservice list" + "commands": "hostname\nuptime\nfree -m\ndf -h /\ntmux list-sessions\nsource /opt/ros/indigo/setup.bash\nsource ~/catkin_ws/devel/setup.bash\ntimeout 10 rosservice list", + "command_tokens": [ + { + "text": "hostname", + "kind": "command" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "uptime", + "kind": "command" + }, + { + "text": "\n", + "kind": "text" + }, + { + "text": "free", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "-m", + "kind": "option" + }, + { + "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": "tmux", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "list-sessions", + "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": "source", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "~/catkin_ws/devel/setup.bash", + "kind": "path" + }, + { + "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" + } + ] }, { "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\npython startROS.py\n\ncd ~/scr.status_display\npython startStatus.py" + "commands": "cd ~/catkin_ws/src/scr_control\npython startROS.py\n\ncd ~/scr.status_display\npython startStatus.py", + "command_tokens": [ + { + "text": "cd", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "~/catkin_ws/src/scr_control", + "kind": "path" + }, + { + "text": "\n", + "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": "\n", + "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" + "commands": "sudo reboot", + "command_tokens": [ + { + "text": "sudo", + "kind": "command" + }, + { + "text": " ", + "kind": "text" + }, + { + "text": "reboot", + "kind": "command" + } + ] } ] } diff --git a/browser/room.css b/browser/room.css index 86b50d9..8c30813 100644 --- a/browser/room.css +++ b/browser/room.css @@ -6,3 +6,9 @@ @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} diff --git a/browser/room.js b/browser/room.js index 5ff8931..17d3690 100644 --- a/browser/room.js +++ b/browser/room.js @@ -116,7 +116,9 @@ function system(parent){ 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 block=el('div',undefined,'command'),head=el('div',undefined,'command-head'),pre=el('pre'),code=el('code',topic.commands);pre.append(code);head.append(el('span',topic.title+' · commands'),button('Copy commands',async event=>{ + const block=el('div',undefined,'command'),head=el('div',undefined,'command-head'),pre=el('pre'),code=el('code'); + for(const token of topic.command_tokens||[{text:topic.commands,kind:'text'}])code.append(el('span',token.text,'syntax-'+token.kind)); + pre.append(code);head.append(el('span',topic.title+' · commands'),button('Copy commands',async event=>{ const b=event.currentTarget;try{await copy(topic.commands);b.textContent='Copied';}catch(_){b.textContent='Select text to copy';}setTimeout(()=>{b.textContent='Copy commands';},1800); }));block.append(head,pre);$('help-detail').append(block); } From 07c5b860950bc319d551531470859829cfedb38d Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 00:34:17 -0400 Subject: [PATCH 11/24] Keep website fonts and disabled controls consistent with the panel --- README.md | 3 + browser/diagnostics.html | 4 +- browser/fonts.css | 3 + browser/fonts/Cantarell-Bold.otf | Bin 0 -> 107248 bytes browser/fonts/Cantarell-Regular.otf | Bin 0 -> 103040 bytes browser/fonts/LICENSE | 124 ++++++++++++++++++++++++++++ browser/guide.html | 2 +- browser/index.html | 2 +- browser/room-content.json | 8 +- browser/room.css | 1 + browser/serve.py | 3 + browser/styles.css | 1 + browser/tools.html | 2 +- 13 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 browser/fonts.css create mode 100644 browser/fonts/Cantarell-Bold.otf create mode 100644 browser/fonts/Cantarell-Regular.otf create mode 100644 browser/fonts/LICENSE diff --git a/README.md b/README.md index 52b40b0..1597ce8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ uncertain or disconnected states clear that selection. Running instructions bloc lighting from both interfaces. Blinds remain disabled; Experiment mode does not start a recorder. Service-start/restart references are monospace text with Copy, never executable buttons. +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 diff --git a/browser/diagnostics.html b/browser/diagnostics.html index 4023a86..3414c43 100644 --- a/browser/diagnostics.html +++ b/browser/diagnostics.html @@ -1,12 +1,12 @@ -Smart Conference Room · Diagnostics +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

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 0000000000000000000000000000000000000000..cde740a7134d05f3d50515db441a5dd4957a467d GIT binary patch literal 107248 zcmbTf2Ut``*El}6E^uLiU6!)T-CeL3a2J+pqpl4BdoM^4lqOwl*jqHVSYwO5m)LuZ z8a2k=ODvc~6Ki4|%q6eCbMK&-yx;eKp8toLJyXw|IWu$SoU^l&q^@0)kTa@442p`i z+s)H1Ki-d!<_)5nJ#P~iZ~x)jvq6Z8d5jSCdz<8>E)U;6Sd6H+n+UbaZqub(OhnQN z9#P}t5EAI5F3p0?fvGtu`62bM*F}hXf{^pG%=DBr)sjp|qjL~KGa*B@ zlRpIYSxDE-%qc2s9W*W$Q8NLD$NHR|Q8T+62Tn}~z)a4N^ z=fWSfV{W_$yxgq+jIF-S^&pr?q5R1;MkrbNL@b?1A3_{L^dWj06x||_DnLP$JhG2} z4-ox63i*xrkP#gd-4$pwOEg^xeZ66Bpj$xla=D!Pncf4y46}fc6B-SWVjUrV00N0q zNCPPnr;!72ki;1n|J9l}i+m}*Ce9%}r{Tde+2UC^J2&Cay*{W-W?E zc_<$ZM+GPg4Mv%$2xtmI7Gy;c5bFv#=>TI<3d)7D6sVVuvf-Z@*i!&)hd?@2X%&r% z0cJv80V;&@h6=VKz+Q+VQ8V~A82TuJwkc>33WQWH)XjnRfl8afP@@e>L`kS4iiddW zfAtxFGN7hRM6C7y{7=*WPbI4(lS&EZL*-I z+^1a64sn^ISxQ?uE%RHR6;j?F@>9_;z>%(CNdPF*o}ysMhMASI!~@6Ep>H$lqF@^e zrMXH;w9;1@lx9I`ccqu%@L33b{JW0K0h!X?P+kK4%e4sh9HqApWCKi9@D##l8sry4i!_KA zKwMsda@{V_TLQo&Sbt>XwU`tBfkCG1-*aq*6(k3;0|;gR?DqpTGA9VHsr85hQt(J%(4F4Kq!WUNd$Q_PezlbC7DXUr^SC9{Fq&YWS+GdEa0TZfHg zW7z?0Dm#=dW|y*S*iG!$>_PT8dyc)u-e;e(?>N!uXmmAt8AYR?v7WJs(P|7cHaE62 zb}{xbW*PI0Ba9P`pBv{I7Z{fr-}4Zk}phX|9jBw9h)sP+o?R#4lh6V%Vtuhd_3W7?u{Z$Ev6K1JVT5W@ra+<|*_ znEDF$LKN;zV5R`~W-_yxRm?_a2XhvsZf0+@ui30{FUTBG!@VMN z1#oYbc`a~nlX-`EmwBK0&?nqW2JX!Q?s339ci^7SNA3;uPXq3q1@1jrH(jD+?lJR# zgUa9gCiM9a@elC5=|A^A#AQhBqxJ%K*Hi1LRj^8zQDLyV(R6zjmM|&T_K0hq$xyC;8(6bL}4wv(KMb)mO!+`l>QinW{;u zX{yDl?WzN+BdX(aJ@Q%GLM7`(NVN~jkExEUPJL2x1oCe~_^~#SQq@h>UC4t#YQWgK zu?^Vvz?FWmtCp}G*kIPi2C$_duOe7~wi$4&9y^*H!H!}Zv7q%L5gAcE6oBI31Wf=b zo&x90U^s0HL1X(0tww9m7IY9DM?a$b=rQ^Uy=22!3meW(WLvVg&>PB)a;Lm06BR~< zQ?02usvFgd>PKZznN$HapIS&Qg4Mj0+CyEYu246rTT~?*!megR+3`SWL$)`Y!}eu| zf%II;-e8wezky7c0F*9gAF?aiu`I{(>}~dQn6V}x6IkjQ8_#N4SJs1VjI<#Aosb^6 zBX49tejx1wL9dHIeNboA4RnnLXcU@(#-MR%DVoX-M@!Io^esAtPNSdEDryU8m4BkY zDHn>R1WHYLfDUP<>QYUpB+5>;qdHJN)G%r+RY;Xkv#C*RIY_|cutyJ2hp5}s0_raE zq#WVYd5gR#CuE{Ds1D_c>QY|FOleU=N{<>*2Gp9eqGpsIilmyN7|Mn$pue`InjWsQjZO{O!3+hG1qg2pqGpIgPUzA1lM?H@k>okh2)^XMUU4gEshK~K;t z>M{DA`Wd~ZenOv9yuF?5DH zjLuR=(D&3))P{;gi>aB&hjIq(-4%TaHjf*{ARXm^!l}ln71&tasrINJ)d@|ZMxy!D zG_-+QiZ+2o@f~##-JrflyQvN6D76z!qejEYG!IRu#-QiaLv|;-4Nj0P>@GNk@3QyU zz3g^&H@gGQlpoofa2{8(57;~G0XU(Lu-~#L;H25l?qg4~->`?+qwFb;=N#yR^fCGy zrZs(>{+5Yn>`WV`B@@H6VxpN?CW?up{-oa0BK4Zq(H^uH?M?g88d^*1slO?SR?%we zIrV~iNxh=}px#iJs-oUee^J%61MNr)v=i-2yU=d5JMBt)g7)D{x241=ukJ&Z1)OX*~~6J16p(D`&8T|gJoMRX3GOBd5U z>F)F(I*T^a!)X&;PMhfwv>!c^`kn4b`_rT7I`n9|EM_17G>9KSJdK}%59#1!- zC%_p#k#0gyqMOo_K^vMv2hvmNX7n^T*{9Q1`ZGERG^CldjsBdTL5I+@=umn#ocVL; zaC$BsLC>Qj=`ZN!^nAJny?|~>FQi-1i|E$$VmgXmLPyhI(lPW>I+k8W$I;8_czOkG zr&rQ#=&z7Jr9+J=U(|#WQ8X2dqNpHbrRty#R4bHB#iB$i8ug?=&!>8$9BL5CqtZ|T zH5ipqpzTvRXe5=7mQi!ia%wJGM9n}8sn5_xY8l#0tw4LIjc6~m32mcRp`FxPw2#`1 zj#0bNH`H!)kGh0zQ9qzc>IQm1T}6+m>*y(U5B)*?f;1F|JSY_s(0`B%Xpd@&Lar1I zeuVdkMHnpWcc4-J1y-sAmU0zpP6eQrR5KJyg`i-n9txovpir>s!>C5cM%71ssbtif zN<^Pg6=)_k4vnWu(L`!EnnabO$2HK^LfR z(Ix66x+W+&)t8b-@_FgnJQ z@nXE0PE2PelgVNVm_lYM=)DV=g`l^sX1->2(U<7U^cDIleT}|O-vEuTj2RBv@d#!l zGm06_jG>>=&*>NROZpZ4n*N>sgW1m|=H_-+;ENVtg1qW8h4j3!B6yDxnA4iS5cJvt8KkY-hG7oJ0|93()kN!@n5N_gk@1 zY&6@NUCXX#o3dZAtJrpITQ-GFXZwRKkjCbLK063(f?jMco6TmhgTXp5ux8eeb!RoK z4s;eT&{}-Je(+^Q*2tPzH9Mc3%g$rJU?;KD*w5H9(1&NRBf+L9VJp}%>>_p{+D@&8 zlScV_|0SGp|HB`JSg?R7$~876wj59^3=Tfqm$<@bQWv*PB7$RGBPY7Kvu$Y56jVr!bjTOgL5;z33`ecOZtDnk zZ6B}-W$UgKG=?eYbMytAv|oYNuooRcC(v1R3Ec$U;V1MP`W?Na5T%0CR!8|#^{D`` zESgg>aM~tQJ*fUv8a0F}rbbees2OnPE(A?@BiL(usUy@$>H>A0x<@^xexrV;s%VB* zgB11!>DCA&RwziKXppB#APf6}1kC~|SqgG*EXcIaK}LN^uc9~7JLr92>79Z#c9p&Z zJLeZzt$)(h48u6V+BY)wn5IlH6A6+(0aj#hW+0QnfQrHQbVGSK& zPBQ11E6i=?A@dXS8}mE!4s03~>jHX-0ko_}pdp39T8RfutP`xF0k9Hs*mBTkrhqOp zAM~=7uzt3}33Qx2&0YZw=OOzG`-1(GtyZ*AE$73TxdvPy7s9pRV!8HQC$1+qfXm== zKp!2!jpL?rv$)0FN^Tvu1vJv5+!^iyca6KtRdT;_uei6|-#p6;yoUGT&3psi%7^o< zc{|^c@5=Y#2k}{aK3~F*8LD}z1*&DLHL6XjovQtyzka7W zuezqXtEyD}s(P)eQvL0~ItUIL2OkHsLx6+Lp}9khLpz6LhdvI29I_np9ZDQVI*fOi z<}lk~p~G^AH4d8{zIHg^aNOYshpP^E93DA5b$IUZhr?fvl%s>AtD~o*uVWp@#*S9U zaL3k;cE^s6T^)Nl4s;ysnCn>VIMQ*d<1EJoj>{a^IBs;@?zq?Su;XdROO7`jA2~jC zeC7Do@oy*ANpNy^GC27=HFRp`6zbH{DbA^bQ)j1MP6M3=JLNhRJB@T2?=;P6w$nnV z5S7wryEW`I{oDI%;^uOzXVEf5L^XMK@{o=O@tsJLWmOD2#G>B zp|6lC3=xJ2Wx^O?k}yM}&Z)et7aMn2c zIGddtI0rh1IJa<)b#Cw6$+@TV0Ot(n9OokE5zgbBr#jDaUf{gUd5!aC=dYa)IDg}O z+WCU>b?1A|kDY&W{@wYV3vyAps9kg}zAklK8oO9s!d+Us*j+lhbam9XEso68=T!!9RWzIVCoa?9m`%P%f3T>f;aRx@fRwY%C|ZBo}) z2dHi8=IR)AJ9VS3uKunKT${LBT*F*jxyHE0yS8^tbnW8W%e9YdKi2`S8LpYG zLtXP-3th`yN4ZwGj(45rI?r{5>w4E6u7_RExL$F+@A|^^PuFTU#?8si-Obz02BF>g>L0;W8J2>eeO2jZK>O8w@q$4-S)d3bNkNiyxTRm zyKa?kzq-A4t8#nq&bzy~d$=3i{oNb6TinCkTe-)(C%AWU@9m!Ap6QvDrnw6UMn$4PRnq8WGn!}oJHD@*FH0L$f zG&eN2H4imEYo2OeY5vf>)x6XEN2}6mwRN=?ZEI~8?Er0-wop4pJ6StZ`-S#P?JDg? z?GEie?NRM1?GM_k+B@1u+NauATCDw#2j}7Jq4m&v_<1z+Xyy^>(b6N%qk~6hk6s=F zJqCN^dK7z%^ce3k&11I5LXYJhYdyAj?D9D1aoppK$3>4D9zS~g#IxE4bctLmFdRlCh2DA=INH`zS3>bZP)G99nqcC zozq>>-PS$SJ<+|?z198g$$AQ&8c!cjvu6X(K+h1*7M`)5?L9kr_VgU!nc!pw zbDZZ?&sm-eJePT{@!ahBwdVoPZ#++XUhur`dC&8)=Wm{GJgdDJFDEapm%*!!S7WbW zuNGc$UY)#pdiD28^UC%r^eXqN@S5T^%WHwxDzA-RJG}OJ9rZfp^@G<{uRC6kyqz(19=UwJq;XTcJp7&Dk)!v)DcY5#lKIZ+M z_j&JY-gmt#y?^z7?Oo;l-iP;b@$v96`1t!Y^0D}Y`Ly!s<&)=A;xo!;g3olHIX;Vg zR`{&*+3K^~=aA30K4*O{`P}rm>+`_pC!c3NfB5|6^B+B@ch+n5K6WAnH^rQ8Y^>g%#^UZe(>5uA9>o4eU>mTZ$=wIsJ8EAuG&=|Z8 zCPO_#6GM=pxgo~T&X8>AVd!s2H)I64f7344XX{C47&`64JQm24A%|! z437=J8UFHRe4Tu~e2u>Ke4F|P`$qcOeLMPg_3h(3$T!P3-?!9vwC_aU&wS_lF8AH! zyWjUa-z&cNeSh=CA|rZ<^+c-}D<+A3#KB^rI7XZ!t`ff%zZI{EzlhkV08EA?$*}?DG6}+B< zOu43F(^&A_%`>ev?JylOoip7vy)ZLo2lD`Pj=3ayP(gZ0dU0-+H9k6C0WqNjSO^Fr zzy`o#jg3=M(Q*nJ#1cRnM2C}9GyqGGg*1<@0WucI42sT4NiE3Bjm{gKmzzE`x*#ie za7t=%QF?T|0%FN$Y6{d*uvx-mQ?m+Ei*quv)5~Jh@`_SYQ`2*cWNg-0Tbxoc4lt(3 zS=M+9p~xbS1S*8YE2E3A#b+fIV#rWq3FWbb$XGJeSY@a|7F+zM-C5&f$oOJv#upk- zhNARqC5WuHcrq|0GbXl;(tR7H`!==XP>2f(YV%M0L6)|IQVNuoLD6mhQQI0HWNY_f zqF`Kh8AFhT5EK;O?w^QaWeTnFv2rogif%^;ZAVCJSCo~VCQrD)m5;} z4U08c9uo8wmY~c@!UrBgNlXHC2&LBe50wZd!K7oGGTGMH=tQMuVr@%Xh|ao%raa!t9j7Ol5?G zc!JNSEWMzh*v?8(=i0%C5NfStKp})$YYo=;F146K<YCvP;^`7#p#kQEstGYC>L6JURkj-m-mqHE+MX%H=Q4X`L&3$l>r z(KUdu*%F*W7?46np7K#5rlc$6rIODNGYBFsBDHp`sUL|?Q);ArA_@s97DZNAV?)!G zVWrn1vXTlhWQ?(dkPUh1dGW-*9NH`x%IAo6~ zS8$g9a|=cUX9pG*Dx%9`Q;seG%4FM=qYF}UdyCCNK!mapZRGT@5w6)3T@b3&v{S^e zO_3AOTs|=XD0|aJ7OhP=o}hukUz;*XP^Bg%vky|r0cW!+3(FQnx(y;d1j(DkVhgIF zjMNNLCejw9Or$MH8N4ls&=N$52&y5X1}Eu{s1-J{k8FyB0m_sGV6zZpmKxNAG7G_J zAw4Kc8Z=NAwk@cJ7Se--&`Hi@TPVR9Ml!<)&Ipot7*a*i^C6QUBWkTpQH3owavxHJ2wM#iH8@FsHZp%UG7lj& zRY+z?&G^V@ZGS78?>8V5hr!|_EKrU* zSZ)Nha>hZ5G>;?A;|LMTp#=+!)KtVVq)1Ig5<`k`R}sjt_()AV8K0fhv@1A6h|mcE zQJH8)=)InH8Ivm0Xympp1@C`YFoG%`2=yPU1x= z3Y4)cks6FjyarQEe){0rsvk~+ni?OH(3RCr%tkxWmF$WngG_m@0LVNCATtzzym$e~ zOtab*2?2oAB-XH9nLx-S?ZV~tX0?aQECe90Qvjp^Ik@fN1U1pm?c`jt6HCTUj&3_K zPVD4twiAopPPA3KV$@pgM5nSVa@%TGoDMG|*SA+-v(vE0*c13JJCLto2v{S@})lSS& zJJG-F#K5&HE81#TWC8%fNJR=jiZIefXi+x2)vm}&0HkI-DN_W6)voM&0HpU2LM}1N z>_i!{6NAl86csx$9_^t7nQ|am?TTmsKu{AM$sS6E0;-Zc#db1iJ0Z|cY7&2toftuO zDfgo>14N*Lni!JB=58=*b77DotCZa zG@!r|921*cT$Ytbwp)HyL1vzk9UD|A@4BLt;$ng)KQk*eGYjH*Ij~=3Ov>(&BOf-& zr*WmQrs=2g4|QvbJ|vYYHAK|JKkQ0nXi8qqh)BGqe}z~@sKIuVj}rj0yaXU0kN{*+ z1wa<|0Az^;Kt5Oj$b!pi4*;lipmBl z(ygMaL5g&%sBD0Rlqp9P2m(?@>{UDQ;Mo-q9&A(6gQ68ciu9nIX^2q{7?aUt7@-^8xyw;(`BP2z~Q6Yq;Xn)DD&dQi@D zSmm;$vIK`km!}ux1K`riIOhNDwC6i;3m(N zBk5U#GmGR%ZWclC0bechA_aGmf*L**%tZt-02yylhKv?I6|6-yNQ(&0q6`IPQHFvM z$Ol)i%w_=cOaPGAG60zg0AxM^kl74CW&!}22>|4!0w6-cn3kKDBad2!@~{=CP$$Qf zfh!T2LOCMSs6d5EIYtIA$CS~_5e0K@aSrG?>47qoF)L8PEXNegN<_viM`X+jR4~gi zf?19!nB|B4vsG_$jc~5$xT&yRX7Jlq+)bjc2+?ODNx2{QB*kiwjeY=y$}vrQbd+p zjJ!#qDd|el{Gg(|uK*~S#Onf%LWOQ%P5Q9ntdf-M^xV{Rxmr+YP;OokWTs?;^DISX zmmJSdFDz6daGDq7l_}BmocyBUKtWb+MpkZC(eNx_E;uc+Wi(dhCvgaf_JsYG%?%?#CtX%kVsxUn@FE>pgz#5!hSOosVqV$@yvgT@B4Z*~2 z3Q|~U3950A)_h@9<9#G1br4z3L5lMgYAP}+NKs2ILB#M0BH!Nx69+=DB~0<$q~_%e zQv5V>L>^)=@hk*eVgpmMB56#3T!+!j zVJfPy@}Ug)AKEnFfHRz!88IK33|aExl)(ocfxaN`A{o?XDgJJG4+P3S`=Ls?;w=F4 z>ywt`OB{RbCoReMg7&yivK03OsGOfr5%)Ls)U$uuKT)D$ zKW5dEtc*o*N6Q;6b$D7jIKFe!(hE|Df09f1R$KI6>B6Fdl&qq(ltS4I%F0TSkEg;J+;UjU0ejN?Lu>AQBi(j6o5ZA!@J;)NTa%yy6&2RvkS|ewOi?T3dyp>h%L8Ay0U3qb!mlJo zz%LAzpbcm<+JW|<{pc6vo}&c!F3nT}xZ!ApUqtk!22sP|PUA}Wea|=4S?Y#zlkq9s z7ylh@gZsiAMl0N5Y!0`;`_L(H`+GjUhdxbTgxj`1!rk8IaF6#L;{bPb{g^POHQY+? z#PniP;GS+S+|wr)7KiU0E;~GPcg$y1lZRQ#Zx7gadc6ROU`lp+hTcFz)Za=#>bMNV1=KS)0m?iS6}uh$>eU)JB$KhVE1 zI2iPX`i1~Ql%b1ZprODp0qzVfHmo!3Gn_HpHdGp3`%=E{zJ72|$mSdEo9sKlH`jN# z?_}REd{_Ez^F8eQz3&a*O5bO`??hhIie|B?7%s+%$zoqIQ!ElI#OdOEaizFf+$$az ze;Ax|ptGS?}&fo@Ju2bw&R(hVw@b+*xR9zJeP%wyeC^APhgt zO`SMl+C;;Mar0K01l^gV%Xe%y2}}EMzuIrzms!>!#p zolK)uQpS|PK6UlNILuX*V_ukkT@-X_B5u_5H>ti9ShtR3l^Q;6j2oKfW3)u^I6 zx5U3-t~!%f9~IRvy1mi}B~|66!gjf|@y}1NsxRO zR;`?~)bQ1k(Pf2Yh1ny4DR<_V_7Vjt16%h9ReN=q`suCZ(s@b5-duZL5~V;+cvqo2 z$72Hy=&@I#8`pUj)s7RO;^nuoa>ci#o=(p5z^{0r;r-5)f9SE>&8OH6d)x_>JnAHc zH4HTh<1rJV@I;5b@DY4PI)XiUVOg2fl@l<+doU8DQruj?xuW_rah#|we8^3z7&&E( zA-8nZ=`p6MbN|H0IZ5D$NnSuzsqV-HDGb+*YQPD}E4JmHFl<^kZ~ht>)1~9vj_)uD zRi~bb@bMh@*{7da-LTrOdsAgg;HCKRuUq}PlM|};!|=;s_$5G&N_aP}1g04`dHDug zaKnISQeCNO19_?&JZXgMo4&wE7v1mF<43!XU%IwG$r_r{HQof1EAl6B42PHT-%0@L z@Y<_GMeXC?@PXf?1Dtx?OU&*W`1p^Pn(f$Aa=`9FLy@|Rb&N0-R>n6+BsC5SY0e2_ zFyoOmu$uyYhmi|aL3jrR&7#G3W1a#-MvB@eA4XHPU3Y$aB<^TV;-aNc zC7$4yEKhwythe5fcqxn%qzN)Xk$iD=BOR2`qHu0^Y3{J>k-bs}%w3#nDvE?UuT{nQ z*<*$n>?Owzn($WG0%uhMt|v|}6+%X4ru5VgUYNBs(2sJ-Y6zS@TyJwR?}%6}6?u zj;qwV#~05&TWm_)#dVmNme$kIBW>5|38sL~T=1gHU4J$RN%*vG)#@c%x34ZAHmqQH z=44~${QO00^umFS8x9`K-q^QKc6OgW8*>iA0>i#H@5AP=$_5sfsN#pDC*`KkU6^mo zXuuWjK3H+spk65AUnLLeFW~>zZc9+n2%+3<=X z!-mZMqR3bt2dgvqjWnCb6YvS{W_5jkJYFS@mrlT%xTxB?e%JDShUKd!PgrVNj~%&r zrNd?oGj!^n*E_=`=y#0UT)N&UIq}PK=VxW$ zNlke?w>q2?x(?cTZnVkT7dBRV$xEf4A!_dm-TDrU1|cD_7h_);)zppW4DKM{{h~_H zoW)w)4toeydvLbAT0HP-UciQ`X4oJ|bMB{>2w#iRD5-Z#Q5Yy{w}0e^Hb3lW&j&{J z{p@Zhni?cSD}Tv?14UB;o~wJf@w-#UH??oE$T&Zd>sypDu#X{e$f=)9f}gx)yqLS5 zI7n0Vu+R7@Rb$QiMGi%A$8D{6LQozS;DcgO&}{Zil8M&T?@klbQG zYIsE7mneykwn;kSgLqe|FN@l80jEvSZ6X`$MaT=Rejffpk@uL!Rt-?|(<|vGUeHDL zKKWDSzHcsHKa^|<>EA65wqH6n;d(enz`I3|*+u5J;UM>hByGi%%V+#8kSGD54+`Dz|2E(qkBMb5~ z%X*r$ud-Wj;kA!*#F7L1#vL+%xJVFnQi^1dOj4YL4hB4mGHGu~4J8&If5vOCwHCG4 z8{<=$l`JalwZ}J(y}+~{M@c@|AoZ6S+fCHrmap$)PZR#UnzCWD{@B3{k4~JeC_8N2 z{+e5p9J|z)D{))U6Z?!+tD6xC_kTm1kmfWfx+vaaH>|I|nq)$Skq_LRP@5*)IqHu5B z{v(I=hX!nJlR02~IhHH34u`231*>J#yk$#Q7_=`G zjY4~#=oH!)AGHeYb+b%#sVs+0JicEwm#dzOwXkXYRnnd6xm?v;$wQ@nH(rMWvDW%P zQcG4&is-5&Ee|9YNWyvUFKT}mMoTfe)hoW-GRt^mjrMZZgsIcR;)4wa*-UQoTkjief z#^Dc=M7*VR4G8+tIT;hO4P8>#?lxgR$xX7WY?r$JX@n6>hE<*+86da6lnSSPJqZ@( z1%Br8nHyH>u}|cWlBZO!ab2kioC>-({3X1DlMK9&SILdc%osn^VD0hkvtJGzezgqE<4;Bb34x>R!o>S9-keg@N`6DX_&S>_~QZ@dY6L;YKvWQP3; z@&!~&T!>j=%uMD z^ztd4Pyxqu=L$Hd(<}b}$Tw|T#X9NCKM$_e6>xB+RlvbD{STb-3b*}3a~Fo3i@jQ6 zz4i_8ZXu7G|M?^Ke$@ADi*+V#)t(J&4}YtFYda~~j8Zv|J?iLw;qUFuN*Y`au0xX) z%1ftMoPryxv~RBOAJDc#T22_~FKObsDQhOI0^xFd*}>yKR){I(*~3N|ze}-iu^EW2 z3qpmcyGEk!`iYew<$7alKK9DQR_z-+_uWib(NB*2J@c-p-UfT)-0Yn@4jK;a9F#cQ z^y^9P(dgLY!G_>=2~A|36Z_mg^~{9ZOY0Kv>DO;vdg|+g<45l^uKkT$(!cY(t{|x* zyEHIK?eMCL!Ft?O&@xxr^?I014{_c z@l?(H~k7RA{*&{2XXP+$@M@_=R z-*5j=NxerMD$pR2!3n_JIIMzGsJTg4B!cpwn~oi|uLNnRCkE>A0ue@a5(I|*ibVJ5 zRi2q@6sCh>>WR&g5Fl!1y*r={7shTBsRLMtjo20Y$+~KkI8D@+33u>{JDNQ!K)uC7 z1>HfS81?Cs1B%fGSuxtTcEg^%`Rn@+$j==xaBa?B(7~tGsMtEJy7tXaw-fjIjTt*_ z+ITp5;dky{r4+V`jBw*&k)#0iQ(%rE?E8z}J$FolO;R#vYCMfk~DgP_~HZrz@Jd20s<(%33=OY+nA?4KQ) z&}z|oyD_7VAoVPH*QJ!2Sv8%36I_LzC&|LX19XNO$?{Ruz;UIX{symqLw#Kp%*d40 zm@xwE22dhmvGpRh#zE)0v)c#7nWTaI)j8XCo;C=7s&JEs!BP{cQDm4@&m?@gM89su zk}dO$Ky(aD*8$83y3S8n3H}1DfT_U?**w&^QMv-G@AN6Zi|nB zc5v>A)P#R>e*gI&^g{NKvTl9bFI?W!nAcP&-F=|qh(Rp{-nM}0`X2SW;q)G5`L%rs z6U|oLE~SK%pu432nb=F#^+4?1IJ^0~y{5$7mxn$#oH#!JtM5#?kGT;82aQWJgml?` zd!z|p$3w(SbH2aJ2Nd#;Q(#Kn}Rbuei+Rjdo<)*L^w=z!tM?x9I0 z&{n%mk?7<)dhNQ3=I6RUGvdFh8tYDPOKW42`tg_M?%00PpzTpn?JLdH?K`#%yUsAq z$e&d>r^q2EFLn3;L!X=#TTSY*wPy&h*i$|B5HTxUA39oeFv+5 z$Pjf0uW}`Ok5qhXSS^~g%P^bvr0p5w(Obyk5H%r%N+*nM3C;$~WPeRc_pdlqFr->2V`5 zM8wUWO|^>J!l){*cU}KW(JG4isA&~OfvQzd@ln?*jQY2-r5zC^0K%Do?(F}F?ea%t zX93r_kEqW67p7lpZ6uTSkmP(K;$^O}tO0jel#MBq_O4=28Rzce)F2&iJYO&38_A+{ zcy$72Er^bq7OXc;Av$31@8J;s%cWmFX&09^<0_>*J-hJ04M5TRJHh|kU zq~Fp`2FVv>RiM=PK?AHch2t-DkM@3ZWzUx3*@Do_3={`vJP?E!F0GoN>)dfnad*@DU~b9I zJ@a-M9(^;Y`C^lFfuB-3C8t;~`P>c1p19t#-*FRcsrQ={Y1~@fRF{yk>#_+CtiZ2SHc=Z|q4-<0-6|GWANc19{)K#kcau+WIB2w(IrU>WPL%Z!+zoqZ z?_Tl5DiJeS3(Wgbg3r6ZOB#sRO@L_lS(W#D5w=cV3 zdhoaf4@b3iVU-rcUxR7E+7_|eZG^3 z-9&t%Ht(D2^UXv&T^#9&6GfoO1Ha~1u9F>2XEtWGGf7(^=a_2Aw8c{v8&|F0x^VkH z^09N9YZBEyaagbZ#zVP1*S0f&Gb(MMVpl07zyZYI?PLGHoM*VPh&zf^XTfJyR;oEu zrPd~6|EiN8H1X|36Svf8;@fLA@nkcc#CkAEWt~mtFrHcp>Yu`2hOqZAuv86#S_fwX z$A8Y_hSCBKH{+!kypy}T94zjmTO^XFb9UcM}?f zNiVJ=Q;^SHn4O7i3--|FR3 zv#KRBky7L8wUT$$Mwv`C?gecOA|4{D$NyPX_fKjstk-R_{`NgsU=eSz3r@xfb+90X zNX^Y)j3r44e@Frj0|jjJwmDyHG~pbv(YcNofiXS?l%wi9x~gnk2ONh7pHo}$YX%Rl zI#?pAbDwbK{kx3qW00CQeF^@-1`qG!`p3^!4Buzk_L5td+(tHn^Q6XLLgfnBUG`PD zS7q?HgCub;c$`?}z*QGwcQ|m}RnqY4?x2fwm-!&z;owNaE~4~R_!X z+*oq(#2r0m8(oqtp^+tn+ZZ=A;MSf!wfLmr@!=s|le-QMF-d$K@VQP0M>Qx6)Yht7 z%vR6;U9;3_lV9Gt#cO9hY>DSKQE3NC8PeBWH#|tCO>Xc=_X_(@ll(b#T;IO&Che%G z`2Bq^8nvS;FnEKGM1gK;ynYY+W1kL}CAvY|PPSIZdfwi`znxJyh3`z;V53(3tHC(e1ns$ytGof z&f)8lw@N5gTq!59RjLEx@)Z_?MWOl{9xApHae*lOA?1LdSkOtD@E5o-91G;^#=85B zq{c?=Avl}y3Gn)+S0`XsaQplWwsZNEk%oS`vK3s2)vv+x>L+O=zc%$QNF!uFsgb{h zb6}=mv1)nArJ9xaDwdP{c-*PFoD-^N$!>l)d6tTMh(%a&Q@B!%SF?g79 z6PUxpBFIfG$W0<2le-cJ{W!c(huJHSzyqX@zc0}e7aJlOCBx-VOals7W%eOoZQQ)| z%hP)gjT?2uwCpEtMdySCNe0O|tOqzKVAJ7pK(DS^%;Trki#c2u){`rb55aO(=`erm z7IwsJ9H{n^s&xy=QDQE|%BX9&^eKxZMWK|9vDu<>94p@e9J?-p{;gXLMqL>c{m=VY zNX4PLy@!`$_Zh}ORdM0a(HVx`Lsr9AEArO}*TC4qvj9Q1fWZnaf|FGCxuk=a!A&YL zNmF3S^Hn$TCb8*y)8{((`r8C85A(1Ufo{>k~KNrSd9p z9)EF5Htj=8s(!+}W*^q$!Z+YIixOWdXUH^ID5CUK*FW#by<2M!?Af=jS47MFK5!~w zhTna6BUa7)*+KH=rvwyBd`=4o*;eR|*N8#`@!O{2zKL+0DAvP9*%tAVcOxK&9d-r_ zv%#s*0&|+G9-yu40~?0J@vu(&VxHOu&QAmzUzmACmtVGQ{pzJ(u3oovcz%BQ@L{GJ zJ^!m0QJAs5swf52eV+@m3(P3uRidm}39_EFYg>6Ph||8RJP@a82H^<|;;=l3y?fX7 zj%bnJ7Y0#+$0?`Xc*UzCn<}6&^{V7CFZJSt9^ym%4eX06@;5eOzg-g7Tw-s<*}-Da z;Ai~Wbz9(QKf6ge+GT&c4Oo_3stI@wuB6k0+js1e)hrQAqYkEQKS7px8GXm586-_B zMdpEl1jp?7Qo?7&Oaf6^qi=ZN$ymz>99YqkD{LN)Y#w0x`}eae&_^KL-d~!Pl~LBV zSJM0?159}Vvd7s~B`;!P6UxpaFj2q(3f?1cPype}3fV_{4>NbJ-lO)u#QxyC$q^pa z&@K&qj(fuahj)T~hFR}D$j10!g@$GSm^rI!OppaZ-{(K;B}adZN@ z-Y8@86sj)b`qU|02e-m?7y(3b5t#dzB{<;0K{rPCJ3k)x;DX@<^QyXzZ(xV&-n?2G zjC~|C?kh}xhRwg?KpC-%fWM|HvG;xOQ{frHq7vNXtr>j7iQw1+#(7Fxq${9PcmeYm z+~*bUiWv?0s*Q|Qm{z?|s;pYbNey}BD@4%XNfN#pizQwjb@EGvQ$(onPYakP5{`f8IXTs6oU;S}6M(;0D0E z3*a_rvm4+J=&u3b*a!KIAbkY-ksk|%C&#JdP-ic|6Oi5q@Fd`X2jJl$H5N4i_yd$T z1$Y4CwL@X*@6C@y92=SkWK(N31CNnQ-St5P&X0cKp#91jp*6X#|?{-+bp2bF-Bjhgu=mhnaL3|a&T@X)2A%^pScCL`;0>J?B;Q(m} z10k3pjDS!8p$LKifra1(p(Wt(g!+yU`a-%tgccB5K~O`;gD@4=s=RKZAQVHrEP$mD zq9Hgy7y_XT!catU^4eq|o(XvMP)Y%;134*x1NIR-7YiBiAM8hX92{|tfSN%7Lx9>e zfZ;%yGt^lNVLgQ95R8xybRrW35#p_&?Uw+fAUy!W0tl@k9Sx)4Ar5PY>i}!O58^5a zg%C0zY=p1|!XgMOp-vaT(+}b+Aml?D<`f>YMO=5->Ej_j4#G&NGYQ~m2vZ=8g)jla z7|5Rpa5B`NfZ{3mpCicky&8eAXbl^rJ22!k5N0>fukdcHH`NSY#mnFABe<764=>zSbN=w^TM(B3H?)Ru6S+m)R_-+Sh&S*td?sJU&*oR~+u)71mnuQ! z3h%HDhWFPNsFtYKs}8Boz>8~@s$U#j;f1vXhh7e44xc%!b$IEhcJy+r3vZ=`I1X_f z=Qzc29lVEj%kfVqjZ+}JbynoG*lC&50jIN0zc{@WIH8WvR!A3i2oHp6=lagg;2pAL z=l;&w@b=hzcysI?+@^AOY3R}g-Vv*Cnd!2@<*LggwHLe))~t>jKx^uJ_$|c!_H|yuEeK?W)^Dx99H2-Pzsj9_il8y&t@=wF%zRx~EZVrf8PH zZK*q&Ds4UO9PI+_V(s^ETS|m?tGaundE|JMd#v-=?eVS0C3u-xv|_w_IJpWuJo|Em8lbxQZJgW1$E?d`Dw^QAWx zZr7`-?^3@}{Soz7)n8x#VEs$=pElqcG;YwR!NdkD8XRx%qM>WU_6OdHa_3@e&d%-s3y85&6*@M$!l_@Np(|oQ*+by zO>>)0ZMvlC!KNpgUTXS2z$G9)U}C`hfb{_f1Fi-<3v3^_tC^{prCHl%qnd5A#97)} zmROcsc3Uo4o?Bh5U9IKT#8EpZ!BHJ9> zIhz#X7t$)ETgYc2t3r;3+z9zI)Fspw+9q^i=%&zN5S!q$gXh6jYl zh3AIP55E~^*0bq7iTQjW-S{Juo(fVR*DJmeUchsz?8_{0TGol|v zKaOb?voPk%m=!S_V*ZHDirpIL5SJGBecZ)(b$nR-)cEsue|tasBK!3=RGT_&#L;{GSd_3dVPnGSj-DOMJAT>mM8``VZ+HAPv2J4T#InSZ ziAxh#CT>VPm3TJsdD6tBsY%Xz$FO(i%$b=p?aXQObwHZ|s|Va0*nD8? zfdK>84SY4I)1YO8F8@;DmwvxQ{Id6#KL=|EHyP|Vc<@avwX>_c>m=75u2pIe|?h+DYZF1LMd zhuj{zJ#l;QUeUdZdrkK`?!UOZxVyVwalhtK-J`F^D356#Q676eE_u8dT6(BBw8_w} zL*0i444pl6_0ZizZwzI_$_%S9tj@3w!$u4X8Mb`b!C_~H-5ZuZyy5W4!&ePIHT=== z&%ajqwHZ8@$Nw7n>yck?{rYJ{xe-5(7(HVBh(jYTj>s6PAK7T+FCzm+MvaW|wDCl2 zD9`1ddp$3CzV{Nn`gzUviuQ{4y5jX>RE1GZMhzV`XVk$_w?{W0J#6&$(KklF_m;c| zc#rXp^xo>7;C}K069!E9b;5-SHzrn@=ri%D?+?DwzPo(iO)55N=%izl(kFeMJZAD%KN~+6 zztMiP{bKx%``z}l_}lnv{RMx$zn%Xq|JDBc{m=V9@XrV+63{6iAYf*|+<^N5nSt6s zF|cM}tH9}jYXgr5UI}~=m=iQ0Xl<}faPQy|!C}Emg0}_71z!lh68wBhktx-tw4X9` zO6ZjRQ|?d6n35e*Eu=|Ew-AqzxglFZPKP`U`4n0zw14Q*&_|)4!i-_9!up1Jg@uG| z3ws)t5yqz0pW1%vkg2n$9-W#MUM&3k@FwA1!`;FshMU4?hHnpl5zeNSnO1jNyJ-Wa zjh?n(+TLmL(~_s%nfAt1+|WK-vtp1Vix?i;QE4> z3o;k{wNO}SSXgCY{e>+Sx-A^FFm&OJh07L3FO(M*TU2V%m_>^htzEQ#(Wyn(7yqz0 zbn&dk&m-GM4vZWg=@&UQa#7^!$gPq4BjX~^M&67}jm%jxW=Zst2TMyW9kw)VX~NR% zWgV7{T()^x-15@PU6=1#{x+&YluMLPR7ljKsJ&5ttkA4zvtrXq+m)qPI<1_#l2(;n z)nHY-RYO;;U3Fu1`_%_lKVDN}O{FzIuIawUV~yXMnQJ1~B(C{w&D}L=Ypbpux;A3% z*0phKudRKv_Di%wbi3#d(Ve2ZM0bzw72PMgfApZ}A<=Hp?$LMF{k-nU`a$b2Z0Nk< z?8Y)1dvAQYX~?Fvo6MUBY+kcDbxXA^^R~R$+GXp;trxc$wyoUuW*gh?y4`pCn(c3Q zl-*Ht$Iu-+cbwdjv(s^B=bipLx9q&TYrwAMyAJHSwR_0!n|qx19NP18uXbpUQYyVb{a!58pZb{P4#k-yNxa zWZIEeF@s_fWBxi?`)G%w1CIJ1oqcrW(d|cLj=nl(I9B;svtzxF4LvsQ*py>4j_p1c zf9(9RyT=|J`y5*-wr8wMtWRus?1I?UvBzUmW8WPYk2g9#^!V)KYme_bo_PF^-z^q@zhmNxvmsP0CCzm0UKtO>(#7G09=ci;~wT zA54x(KAn6u`CW=OrBX_Zl)fngJF@Y~IqAtZ$;osw*)|#KGP&BIp2`lISfxr8pLbQEJF@a;TmRKO*1BS+T z;yT;F(GqKE*`1&IFMm{PvA_P( z$6k!>ku>a)qZk+@h_NT7!gjo3h*IkxH(%YfBcGcuZ;4OnQO*>@&4k38S3Tl8|17X4 zmQ$$geId9}D%^c5oN9 zZDJ)@rCFkZ8FZGbQ6~xiTOi31^#t=!OQHc<%logSs|y9$7%@)3UwU6p$grGKe?M(4 z(eT_-_@J2*nb`^|ul@wk#oQ$Rg%Zn~2{gzO11Jb| zWz9H&nk=9OfW3A{ z$zf8CwBS<}2>iBuO6Lto`r4(nVDYweLIcFG0xg!9B==*shHy#0`>wEiphRb|6*yZG z>qxt6NoO(?ktbb-;y=_f9cchK+FI(KYm47p$lmpi6^JUs2<|dBkmryF>Z1*=MV*8d zDH{4EP0;g#qnZirEV~sXvVXz)>}XksGz!fFFi?F#G*eja4^?=BWWR!ise;0j;_Or2 zFxs1GD-XxAjFj?o=JEB2Jg)v%wvta+kLlL+s6qj!tBVksTa;J(y?}ngD2L+z-6DYuvcn3s6wt5a{G1{?dIIWN9C9Tp0-u|88yN`(!C*7F8msVAwl8LKRxPgp+bg`8#vS)%Jm%OIsb2FzWdErfQ1E8{94 z;UtiqMB+0DsXZZ-t{Xuc1glP?mVj?6Cd6mtD>>w{eWi_-g<%3R7D|+ZW%hG!sl3m< zSWg`-G5oWWl>DuZ1U6+QzECXz?hE7F^5Vq=b+tLb@WSaH6ew~?56U&0{_jE zYv0yU1t-HRy6L3TQ(NZy4p!O2V49a(<37imuZNAs@nf*;Hoy!Ga5yQE^N5@v;^#VA zisciT?Oi6Zy>kUBUr$(QNo<9L$~aFI0#l{FQeVq-o(2G2Yw1g_t@?4enefiC+nRB} z1zUP`rQjVn<6E zYO^a#@3WLXuFhF&pp98Na$CNpphBm0wU(GE(tY|-xUaYmKzniln_m}xX8iB! zXZ`(7(j(q{^zZwV1S@HYoO0{86+9P^Nz%&|B1l`6>oK{G&>$ybOA5XuiE(n8W#AME zKs*a&Vf|vLCoHlg(kxz-r(UwAf0Bq6Am3Oi^Izab}Ak?c9picD5Id7cFx2pS_~9y4hoZEB5 zF#1^#lX7lx`jHEb669hSt_VwFocc+h!5UV`wdJ1$W21-S3O0_xrXn*18po+cs0eEU zP0)WJr@xALSuIIzWrMQ!pS47C6a_8K2LpyPN>kk{b1k@DwxQC0{WXi14>1)Hv*hD! zhT+xTQ!ldYbKG@e8q;g%o0qY-@-hurlpZLsR0}8@^A=6G?q|QjUIC7J*@q87B(#KZ z4DsZBHG=w78AkG|^@$$DI?NTtIoUu}-`^V$lzL9C*i+#ZbsR7QEp; zWnH9WT|5G-gmr=5xwRK${q&lG@2PA#L?%g7{ zqzX>Ev3WPJ9I+NYUT~6AW6O4)O1}tsKv#u1><`Q{USDj%L7oR$Q=){Y(9b)_B{O@H z1Gwz1oXeIYB@ST_Z*gO2Sgvn}DI2${MgwUYKjEQ+r=!aGo{sbVk@bAfLyOARW48>? zEunVm9O;A#@^r-n{RY`sR^=r-C1ms3SJV>LqxSnW@vCb!LHvt$vfr?U-Y)>f33Mf! z_Dg`6fb*plyWdinVOeeMEl6p&S*WMgyGhjClBL!{gI@FFiN%t#Mx&i=@;KoBjz|>>f7{CwX5iR)(1%eRR@XeQLw&nob&>3 zc%ITZ5PX0?u&vBPHu;_)`g2Qk(X0*tug%&l1qTQ@=(U1EX%|Oewze%F$yXpT^Xg&t z06p($fkQo_{e|Zfb+&BFFYxxCyYOhc1W%QGL!5LPDL4WT>~HwA7?~i^5X%`Wv1(xc z(SgwtHBx6-ra%*wBfUv(DGrh;$QsTm)#RKK6(m;YOwtn0B;_T-#|$UTWLPv~f8#yP zfa%K+40DQHk1aEZA^Y}6t=(c2Q>ch=Y|rsz7<$z|oDN>6yZt$d4TCL;BlVr`=;AZM zeTaS6WRHiAVn{H3eaa{J5P`b7&`;<9e##gcR>_i~e#6J`KM2GdPV*#L`}ODEf+yR6 zn~QZha`LG?=M=t!h!Y;KgS2ZwLp$NQ$U`K55-$io)4}Dph{N8XK9-TNwDjzRlC{^z@Kc&K@J1 z8Cg-95B72-8HC$=ILp>c!e2TK8n?3~?o&QuT1!20ZSfo1JXMgAsKjmRE^V?Tx8-H5 z+&!{6FIGVUTYn1KSj_vX2)8%UG=Ws+oMv<5(6OVX_Lkj%RyJ1T^7FdiIi4wgh3TmH zrJrThFliwfg@qBP1e}%*wzRIEaMp4zOHsC46F>JD|`Fm$tO1mZAhh5}!9DVqt zloesT;$B5q*iu4q)EC}7Nb!U3?# zyHwJ@9(29V9^+@~sd+r2N(!@cfLo-t2CHWjgUy4fygXR<^!%B(XJDkX-I0}Mz8cu! zYNlLw?`Hpt=eTa39xI|pJ9^g8_$8eUKLca%MpQxA@Icsr<#0R=jElyG8@Sj~Xn8D; z<5V;aQ{JV(_=mYLn+BK%`VE_Cl|a!Ld>bwU5NU_Bfsdy<{Vu_rEei-X;+Cxu)Ba%$ zQWZ0P)hAla!Zmy&FV5Ese4@b-DaAkHBH(XHYAbBu%#Lb%GXXb+Y9xX~3@wlRtjm_Y zRfG-d+UPA*x2y!8QN~@MHxWMx%8w`Yg!7hj{9`b`(O8XvZ0U`{L@X?p?`|Zw8wy#} z=c?2WUMd{LHFT|IZYe9C8JH&xEivks9E9_GT5d`HMIKl)vU|?r)%bWR&=cjJlg{rs z)|6{sTe|@UbNZY$<*%B_4vgv>%=1_O#kpE&m)Hcr-u06ry&?=DN?!$0#$B|cGW<;bkHawI$&fPw=u2*m(P zshp!ZVQm0VJprl{1*r6eid}&XAcsCcbClye7RSdy93NdQ+fnc?3dW+Kc(Cw*$!%~{ zyI9UzdyU&({$S6=iS48g-e3tbMr2G|MVD8HV1>BQ)zo;{C>)+M$KnCx{jIX?xG&%n1X{PF0JI9I1D*t3?IA9ZzPs8ESGqDX$4qk zE1mMrZO|z%E1jP5b6tG&?a8h{T*aMcaZV{)T2h`Ht1srp>fdWwSMy=!EW=f{j#iHz zHWXxM07icGx0M5M&`q`^9*_!>Dnh^@T6U{Rn8-GCs+M3YXDc$}c9zA3VOKyr{GH|X z0qNu|;Y^lv=956D>IsW2iCvW6UdPI=_yBh!r^!^#hr4LXZ594F?WAR~lF!8k zGy~&PMX|2Fp~?+Qs}8JB350 znodY!a*=G7i-vwvOLR_S=wYg|E&oe3)tfghMK-kV8O~a7ZO=P-_sD^p@V=P?i=2fwa|Hfu8i8IpZ7Jl1I=ckTyVW_6*IN zEWk|m4gi99`&J0z@mQ6ZKv9UAtxXEHp(ZQ3ww#HZezqw)PvCr8F_+KAEZr)H}jm=rqL|Pf~8^$^aM*FI-W!TN~kF{db zrZ~;$eeuD+!CBzXkC4~ZZkox;4IUag-p5GCSQ#CSVIK|m6Z<#(armGPBSx>=<8P#) zx*ePLtv=|WuX9bJBN6@*|GT-NaL=5j{tjy`&|ph%e9#!2oPePg>hB~DCq4osPfZU{gh( z2D`$yPu&()vD3&S-LlV}cRxC4!@!DLeTJ?b;=n32sgFP^_GwO3$++^fq07+Zhqn$N zyL>ZYNZU@sJ$e}R=Qy2b2W<*!41pvZW)I|)f}mH7g{b$O`FFzXnm{(-zo@jpo#?=# zEyb7W5zEKu0^CHqquZ*x?E!Ri0`I1L`m$DxP3@r*4`g6i#Ms*0XY@dG5^L!S2MJI+ zZbZjdf)m|}s2fh}(2B>vlg#UVOr0Twpps^fD#A+e{r5RBQ<0nUuf$A65f5{>VUj#A zf{N7U9`LmluMBGsC*&N##(uzD%hH>7Zz!+k0JwFqQ zC9%))5{oTK2JuM|+;WeeTy%C-(#DDuhK`sJW9Iu?jY;q4IHj&eJW*sS-_4kswBnkwM4>W!_m2}G#bLF4-*+0=3C5QJmzpEj1F#^5JReOn1S=)}U>7FXNM!ZIE zie=6O{*R}Ys^Qyy)Q)d=JyJ|x7kQ2 z!jB0WY8jv;{ho!SLpiSQ5-ZL;wn)fmRFJVY$2CHl$>Q?U*>WxpoWwf-9}_-SC(r@I zvSkYvE;9}s?K^CUeV0U!2XJOQus3S$HY2jjONlW_m-iW$EaLx7c=0_g7R0H1Bu?=5 zPE|)j*~2ls@2H)!g%fu0*Vw>$Pv2qto@jo7W>H&-xhk$ONOCv}UR?QKfW{^gpoo$2w^SM6+C5x|snpjOr zKJ5kb0r3x}YgKawAm#`k=qJ8I>?ZvsDl5Jx5BUmr*1NLu!n*yAHMBk>!iIFV7ayN6 z-z5hQ&y^321q}IK1)PuhV?T4MHM49s$FGvVx@Ev0LKO9oqR3f_QnjL!pbv$dHg~{% zA!-xh%Oz*ecA=x?j^RID%3#{?ZFCV*T(F~XrSKNupN-?kE}Boha*)2Y<@4P)w>uQ5 zC*Mo#YaF zoG(=cM75lL?h+dOBOCEi5lY#WadG2Zt|dp`QEAxs17rtdouS}6+iSi39;ZM zOGiWeu3ho*6LxucOqk%|v1>v+;veuXmTm_naQpnmzV0aW=T|5#UsH(hRwMSVC=~xSwu@=&?7H(lJlrrDvHDigOdauBo z;nwkBA%=(FCh?K&W4Ro~DX0MEjop@3(kaV@C}|uWK5irQ-pGkla>S$X zWcO_=pgJI3$U~{CbT)UQNx<$j|V;2)K8%P7)$&s_XAkb&R`t_ zPVf+^^Z=U@a=StZQbUEnE|2`jO1^Bc^a6{NA@Hey;0%*M{GTLz(;4v8?aY57WYjmw z>st$hEuFqe#T@L1?v^+VLDmCs!f$~jwZA|=SUlPaD>r!y@Zkj^I|}Q_X#*E_Q4;oy z2h5*3;0le}lonbu^AJ4f&viq7O6%B&Kw5}C3_{^Ys309tNx1j8+&@Bqhg5pR7CYf7au{pbx=`F7c_!np;8#l_eF9%p`QMMw<6WlUy%1&hDS-9 zTO^x)LdTml=xSF~cpnwML67dEL3hqrnhldyZlY*t9+9_~`YT(+acmJgu|*um7ICSP z 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 literal 0 HcmV?d00001 diff --git a/browser/fonts/Cantarell-Regular.otf b/browser/fonts/Cantarell-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..3451736457c673c6bf506658bc182f6f31808afc GIT binary patch literal 103040 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 + 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 index 3a4db2d..154598b 100644 --- a/browser/guide.html +++ b/browser/guide.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Blinds disabled · Recorder not connected
\ No newline at end of file diff --git a/browser/index.html b/browser/index.html index 3a4db2d..154598b 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Blinds disabled · Recorder not connected
\ No newline at end of file diff --git a/browser/room-content.json b/browser/room-content.json index 68f39b4..8fa1a4b 100644 --- a/browser/room-content.json +++ b/browser/room-content.json @@ -620,7 +620,9 @@ "selected_ink": "#ffffff", "accent_pressed": "#10575e", "pressed": "#dde8e8", - "icon_colour": "#386a73" + "icon_colour": "#386a73", + "good": "#23794c", + "warning": "#875709" }, "dark": { "bg": "#171717", @@ -633,7 +635,9 @@ "selected_ink": "#171717", "accent_pressed": "#c5c5c5", "pressed": "#414141", - "icon_colour": "#c5c5c5" + "icon_colour": "#c5c5c5", + "good": "#f2f2f2", + "warning": "#f2f2f2" } }, "guide": [ diff --git a/browser/room.css b/browser/room.css index 8c30813..3b1960a 100644 --- a/browser/room.css +++ b/browser/room.css @@ -12,3 +12,4 @@ pre code{font-family:ui-monospace,'DejaVu Sans Mono',Consolas,monospace} .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)} diff --git a/browser/serve.py b/browser/serve.py index dc84d27..f65e09d 100644 --- a/browser/serve.py +++ b/browser/serve.py @@ -40,6 +40,9 @@ # 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'), diff --git a/browser/styles.css b/browser/styles.css index ded0805..7d5ef26 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -122,3 +122,4 @@ main { padding-block: 26px; } body{background:var(--bg,#fff)} [data-theme=dark] .warm{color:var(--ink)} +.beta{background:var(--soft);color:var(--muted)} diff --git a/browser/tools.html b/browser/tools.html index 3a4db2d..154598b 100644 --- a/browser/tools.html +++ b/browser/tools.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Blinds disabled · Recorder not connected
\ No newline at end of file From 67aabe96c264110bbe8c64cd85be60e62182216c Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 00:42:25 -0400 Subject: [PATCH 12/24] Match diagnostics branding to the shared room header --- browser/styles.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/browser/styles.css b/browser/styles.css index 7d5ef26..70f3fba 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -123,3 +123,7 @@ main { padding-block: 26px; } 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}} From e3ef53c74659515a55f595826b88fab33229d41e Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 00:45:55 -0400 Subject: [PATCH 13/24] Deliver rejected requests before closing the connection --- browser/serve.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/browser/serve.py b/browser/serve.py index f65e09d..198e8a4 100644 --- a/browser/serve.py +++ b/browser/serve.py @@ -255,6 +255,16 @@ def log_message(self, *_): pass def respond(self, status, data=b'', content_type='text/plain'): + # Closing with an unread body can reset the connection before the client + # receives its rejection. Discard only a bounded, explicitly sized body. + if self.command not in ('GET','HEAD') and not getattr(self,'body_consumed',False): + self.body_consumed=True + try: + length=int(self.headers.get('Content-Length','0')) + if 0 Date: Fri, 16 Oct 2026 01:20:34 -0400 Subject: [PATCH 14/24] Match the shared Lighting workspace and blind settings --- README.md | 11 +++-- browser/guide.html | 2 +- browser/index.html | 2 +- browser/room-content.json | 92 ++++++++++++++++++++++++++++++++++++++- browser/room.css | 9 ++++ browser/room.js | 72 ++++++++++++++++++++---------- browser/tools.html | 2 +- 7 files changed, 160 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1597ce8..299790d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # SCR room browser interface -A browser companion to the touch panel, with the same Home controls, white light, -scenes/channels, instruction workflow, preferences, guide and help. The panel owns +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. @@ -16,7 +17,11 @@ 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. Blinds remain disabled; Experiment mode does not +lighting from both interfaces. All three original blind actions are visible; +movement is a separate saved setting, disabled by default, and requires a connected +controller. None is installed in Beta. 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. The website bundles the same Cantarell family used by the panel, so its layout diff --git a/browser/guide.html b/browser/guide.html index 154598b..fed5f0a 100644 --- a/browser/guide.html +++ b/browser/guide.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/browser/index.html b/browser/index.html index 154598b..fed5f0a 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/browser/room-content.json b/browser/room-content.json index 8fa1a4b..ed24f52 100644 --- a/browser/room-content.json +++ b/browser/room-content.json @@ -71,6 +71,26 @@ } } ], + "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", @@ -640,16 +660,84 @@ "warning": "#f2f2f2" } }, + "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 → White light adjusts warmth and level. Scenes & channels offers the room’s saved lighting mixtures.\n\nA highlighted button shows the last confirmed setting. If a result is uncertain, stop and ask the room host." + "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 Beta’s starting lighting scene. Use Home in the Legacy bar to return.\n\nBlinds are currently disabled. Room tools → Screen can hide their Home section." + "text": "Home always returns to everyday lighting controls. Opening a page does not change the lights.\n\nLegacy opens the original interface after restoring Beta’s 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", diff --git a/browser/room.css b/browser/room.css index 3b1960a..653b66e 100644 --- a/browser/room.css +++ b/browser/room.css @@ -13,3 +13,12 @@ pre code{font-family:ui-monospace,'DejaVu Sans Mono',Consolas,monospace} .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}} diff --git a/browser/room.js b/browser/room.js index 17d3690..da3bc08 100644 --- a/browser/room.js +++ b/browser/room.js @@ -23,11 +23,11 @@ function command(action,args={},revision=state?.revision){ .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-white':'home'));} +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; const root=$('app');root.replaceChildren(); - for(const a of document.querySelectorAll('.header a'))a.setAttribute('aria-current',a.hash.slice(1)===view?'page':'false'); + 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); @@ -37,13 +37,13 @@ function feedback(parent){const box=el('div',undefined,'feedback');const current 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,disabled] of [['Lights','Whole room',false],['Blinds','Unavailable',true]]){ - const group=el('div',undefined,'essential-group'+(disabled?' disabled-group':''));group.id=disabled?'blind-group':'light-group'; + for(const [title,caption,isBlind] of [['Lights','Whole room',false],['Blinds','Whole room',true]]){ + const group=el('div',undefined,'essential-group');group.id=isBlind?'blind-group':'light-group'; group.append(el('h2',title),el('p',caption,'muted')); - for(const label of disabled?['Open slats','Close & lower']:['Lights on','Lights off']){ - const action=label==='Lights on'?'on':'off';const b=button(label,()=>command(action),{icon:disabled?null:action==='on'?'white':'off',action:disabled?null:action}); - if(disabled)b.disabled=true;group.append(b); - }group.append(el('small',disabled?'Disabled':'Standard room lighting'));groups.append(group); + 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}); + group.append(b); + }const note=el('small',isBlind?'Disabled in settings':'Standard room lighting');if(isBlind)note.id='blind-status';group.append(note);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')); @@ -52,23 +52,20 @@ function home(root){ 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-white','tools'),link('Demo & guide','guide','guide'));root.append(nav); + 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=[['White light','white'],['Scenes & channels','scenes'],['Experiment mode','experiment'],['Appearance','appearance'],['Date & weather','clock'],['Screen','screen'],['System & help','system'],['About','about']]; +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){ - if(!pages.some(p=>p[1]===page))page='white'; + 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);} shell.append(nav,detail);root.append(shell);detail.append(el('h2',pages.find(p=>p[1]===page)[0])); - if(page==='white'){ - detail.append(el('p','Selected values apply after your touch settles.','muted')); - range(detail,'Colour temperature','cct',1600,10000,100,'K');range(detail,'Light level','intensity',0,1900,25,'controller units');feedback(detail); - }else if(page==='scenes'){ - detail.append(el('p','Room scenes keep their Legacy names.','muted'));const row=el('div',undefined,'row'); - for(const name of ['Fid','Sat','Dul']){const b=button(name,()=>command('preset',{name}),{action:'preset'});b.dataset.preset=name;row.append(b);}detail.append(row); - 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 if(page==='experiment'){ + 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); @@ -88,10 +85,41 @@ function tools(root,page){ 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.','muted'));detail.append(el('h3','Touch panel display')); const row=el('div',undefined,'row');row.append(button('Clock & weather on panel',()=>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','Blinds on Home'));choices(detail,[['Show disabled','blinds_display','disabled'],['Hide','blinds_display','hidden']]); + detail.append(el('h3','Blind movement'));choices(detail,[['Disabled','blinds_enabled',false],['Enabled','blinds_enabled',true]]); + detail.append(el('p','Changing this setting does not move the blinds. A controller is required.','muted')); + detail.append(el('h3','Blinds on Home'));choices(detail,[['Show','blinds_display','disabled'],['Hide','blinds_display','hidden']]); }else if(page==='system')system(detail); else{detail.append(el('h3','Smart Conference Room'),el('p','Touch panel and browser · Beta mode','muted'),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('Fixture control not connected',()=>{}):button('Apply scene',()=>command('preset',{name:scene.id}),{action:'preset'}); + if(scene.id==='Gradient')b.disabled=true;else b.dataset.preset=scene.id; + card.append(el('h3',scene.label),el('p',scene.detail,'muted'),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);} + detail.append(el('p','Programme runner not connected · no programme is running','subtle')); + const actions=el('div',undefined,'row');for(const label of ['Start programme','Stop programme']){const b=button(label,()=>{});b.disabled=true;actions.append(b);}detail.append(actions); + }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')); + range(detail,'Colour temperature','cct',1600,10000,100,'K');range(detail,'Light level','intensity',0,1900,25,'controller units');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('output');output.id='value-'+key;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); @@ -133,8 +161,8 @@ function sync(){ const p=state?.preferences||{},s=state?.lights||{},available=state?.available===true; if(state?.palette)window.SCRDesign.apply(state.palette,p.resolved_theme); update('connection',!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');} - 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||(lighting?!state.can_control:session?!state.can_session:state.busy);} + 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?.detail||'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]')){input.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;update('value-'+k,value);}}} 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); diff --git a/browser/tools.html b/browser/tools.html index 154598b..fed5f0a 100644 --- a/browser/tools.html +++ b/browser/tools.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file From 87f7365688b2ceddc3d66e39ff42b5a38cdf9c69 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 01:42:57 -0400 Subject: [PATCH 15/24] Preserve semantic status accents in dark mode --- browser/design.js | 1 - 1 file changed, 1 deletion(-) diff --git a/browser/design.js b/browser/design.js index 60b4d06..32f7d55 100644 --- a/browser/design.js +++ b/browser/design.js @@ -5,5 +5,4 @@ window.SCRDesign={apply(palette,theme){ 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; - if(theme==='dark')for(const key of ['good','warning'])document.documentElement.style.setProperty('--'+key,palette.ink); }}; From 1c3efd8303acb0e53b3a8726837315308928a993 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 01:42:57 -0400 Subject: [PATCH 16/24] Document and copy individual help commands with shared styling --- README.md | 5 +- browser/room-content.json | 433 ++++++++++++++++++++++++++++++++++++-- browser/room.css | 12 ++ browser/room.js | 25 ++- 4 files changed, 450 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 299790d..853e458 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,10 @@ controller. None is installed in Beta. Gradient, Sun and Circ remain visible wit 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. +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. 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). diff --git a/browser/room-content.json b/browser/room-content.json index ed24f52..61e06fb 100644 --- a/browser/room-content.json +++ b/browser/room-content.json @@ -642,7 +642,9 @@ "pressed": "#dde8e8", "icon_colour": "#386a73", "good": "#23794c", - "warning": "#875709" + "warning": "#875709", + "weather_warm": "#a16a24", + "weather_cool": "#3b7590" }, "dark": { "bg": "#171717", @@ -651,15 +653,26 @@ "muted": "#bdbdbd", "control": "#292929", "border": "#454545", - "accent": "#dedede", - "selected_ink": "#171717", - "accent_pressed": "#c5c5c5", + "accent": "#76cfc5", + "selected_ink": "#172624", + "accent_pressed": "#a2dfd8", "pressed": "#414141", - "icon_colour": "#c5c5c5", - "good": "#f2f2f2", - "warning": "#f2f2f2" + "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", @@ -755,11 +768,17 @@ "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": [] + "command_tokens": [], + "reference": { + "machine": "", + "effect": "", + "documentation": null, + "steps": [] + } }, { "title": "Power", - "text": "An undervoltage warning means the Pi supply voltage is too low. This can slow the panel and affect storage reliability.\n\nCheck the supply, cable and connector. A separately powered LCD does not rule out a Pi power problem.\n\nDo not hide the warning or keep power-cycling to clear it. Shut down normally before changing 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": [ { @@ -774,12 +793,42 @@ "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\nuptime\nfree -m\ndf -h /\ntmux list-sessions\nsource /opt/ros/indigo/setup.bash\nsource ~/catkin_ws/devel/setup.bash\ntimeout 10 rosservice list", + "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", @@ -789,6 +838,10 @@ "text": "\n", "kind": "text" }, + { + "text": "\n", + "kind": "text" + }, { "text": "uptime", "kind": "command" @@ -797,6 +850,10 @@ "text": "\n", "kind": "text" }, + { + "text": "\n", + "kind": "text" + }, { "text": "free", "kind": "command" @@ -813,6 +870,10 @@ "text": "\n", "kind": "text" }, + { + "text": "\n", + "kind": "text" + }, { "text": "df", "kind": "command" @@ -837,6 +898,10 @@ "text": "\n", "kind": "text" }, + { + "text": "\n", + "kind": "text" + }, { "text": "tmux", "kind": "command" @@ -853,6 +918,10 @@ "text": "\n", "kind": "text" }, + { + "text": "\n", + "kind": "text" + }, { "text": "source", "kind": "command" @@ -869,6 +938,10 @@ "text": "\n", "kind": "text" }, + { + "text": "\n", + "kind": "text" + }, { "text": "source", "kind": "command" @@ -885,6 +958,10 @@ "text": "\n", "kind": "text" }, + { + "text": "\n", + "kind": "text" + }, { "text": "timeout", "kind": "command" @@ -913,12 +990,190 @@ "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\npython startROS.py\n\ncd ~/scr.status_display\npython startStatus.py", + "commands": "cd ~/catkin_ws/src/scr_control && python startROS.py\n\ncd ~/scr.status_display && python startStatus.py", "command_tokens": [ { "text": "cd", @@ -933,7 +1188,15 @@ "kind": "path" }, { - "text": "\n", + "text": " ", + "kind": "text" + }, + { + "text": "&&", + "kind": "operator" + }, + { + "text": " ", "kind": "text" }, { @@ -969,7 +1232,15 @@ "kind": "path" }, { - "text": "\n", + "text": " ", + "kind": "text" + }, + { + "text": "&&", + "kind": "operator" + }, + { + "text": " ", "kind": "text" }, { @@ -984,7 +1255,105 @@ "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", @@ -1003,7 +1372,37 @@ "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 index 653b66e..ce58212 100644 --- a/browser/room.css +++ b/browser/room.css @@ -22,3 +22,15 @@ button.selected:disabled,button.primary:disabled{color:var(--selected-ink)} #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)} +.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){.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}} diff --git a/browser/room.js b/browser/room.js index da3bc08..fe1feda 100644 --- a/browser/room.js +++ b/browser/room.js @@ -144,14 +144,25 @@ function system(parent){ 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 block=el('div',undefined,'command'),head=el('div',undefined,'command-head'),pre=el('pre'),code=el('code'); - for(const token of topic.command_tokens||[{text:topic.commands,kind:'text'}])code.append(el('span',token.text,'syntax-'+token.kind)); - pre.append(code);head.append(el('span',topic.title+' · commands'),button('Copy commands',async event=>{ - const b=event.currentTarget;try{await copy(topic.commands);b.textContent='Copied';}catch(_){b.textContent='Select text to copy';}setTimeout(()=>{b.textContent='Copy commands';},1800); - }));block.append(head,pre);$('help-detail').append(block); + 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);} } } -async function copy(value){if(navigator.clipboard&&window.isSecureContext)return navigator.clipboard.writeText(value);const input=el('textarea');input.value=value;input.className='visually-hidden';document.body.append(input);input.select();const ok=document.execCommand('copy');input.remove();if(!ok)throw Error('Copy unavailable');} +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 Beta light settings before handing over. Blind commands stay disabled in Beta; do not operate the original blind controls.','subtle')); @@ -186,7 +197,7 @@ function weather(){ const stats=[];if(!old){if(Number.isFinite(value.feels_like_f))stats.push('Feels like '+degree(value.feels_like_f));if(Number.isFinite(value.high_f))stats.push('High '+degree(value.high_f));if(Number.isFinite(value.low_f))stats.push('Low '+degree(value.low_f));if(Number.isFinite(value.rain_chance))stats.push('Rain today '+value.rain_chance+'%');}update('weather-stats',old?'Earlier reading: '+value.model_time.replace('T',' '):stats.join(' · ')); 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')){if($('weather-art').dataset.symbol!==name){$('weather-art').replaceChildren(icon('weather-'+name));$('weather-art').dataset.symbol=name;}} + 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 ${time Date: Fri, 16 Oct 2026 01:47:44 -0400 Subject: [PATCH 17/24] Keep navigation compact beside long help references --- browser/room.css | 5 ++++- browser/room.js | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/browser/room.css b/browser/room.css index ce58212..5f2a88f 100644 --- a/browser/room.css +++ b/browser/room.css @@ -32,5 +32,8 @@ button.selected:disabled,button.primary:disabled{color:var(--selected-ink)} .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){.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}} +@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}} diff --git a/browser/room.js b/browser/room.js index fe1feda..6c14abe 100644 --- a/browser/room.js +++ b/browser/room.js @@ -136,7 +136,7 @@ function guide(root,index){ 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); @@ -183,7 +183,7 @@ function sync(){ 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-health',h.error?'Resource reading unavailable':`Memory: ${h.ram_available??'—'} / ${h.ram_total??'—'} MiB free · CPU: ${h.temperature??'—'} °C · ${h.power??'Power unavailable'}`);update('room-health',state?.room_status||'No current room observation.'); + 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.'); weather(); } function minutes(value){return Math.floor(value/60).toString().padStart(2,'0')+':'+(value%60).toString().padStart(2,'0');} From 8391c82c044279958db624c9f63f24317bc8a860 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 02:09:15 -0400 Subject: [PATCH 18/24] Polish responsive lighting controls and blind lock clarity --- README.md | 8 ++++++- browser/guide.html | 2 +- browser/index.html | 2 +- browser/room.css | 8 +++++++ browser/room.js | 52 ++++++++++++++++++++++++++++++---------------- browser/tools.html | 2 +- 6 files changed, 52 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 853e458..77d5a17 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ 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 is a separate saved setting, disabled by default, and requires a connected +movement has a saved Locked/Unlocked setting, locked by default, and requires a connected controller. None is installed in Beta. 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 @@ -27,6 +27,12 @@ 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. +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). diff --git a/browser/guide.html b/browser/guide.html index fed5f0a..f695f6e 100644 --- a/browser/guide.html +++ b/browser/guide.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/browser/index.html b/browser/index.html index fed5f0a..f695f6e 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/browser/room.css b/browser/room.css index 5f2a88f..3d39087 100644 --- a/browser/room.css +++ b/browser/room.css @@ -37,3 +37,11 @@ button.selected:disabled,button.primary:disabled{color:var(--selected-ink)} .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} +@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 index 6c14abe..749a8a4 100644 --- a/browser/room.js +++ b/browser/room.js @@ -38,12 +38,14 @@ 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('div',undefined,'essential-group');group.id=isBlind?'blind-group':'light-group'; - group.append(el('h2',title),el('p',caption,'muted')); + 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}); - group.append(b); - }const note=el('small',isBlind?'Disabled in settings':'Standard room lighting');if(isBlind)note.id='blind-status';group.append(note);groups.append(group); + 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')); @@ -63,7 +65,8 @@ function tools(root,page){ 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);} - shell.append(nav,detail);root.append(shell);detail.append(el('h2',pages.find(p=>p[1]===page)[0])); + 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')); @@ -85,8 +88,9 @@ function tools(root,page){ 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.','muted'));detail.append(el('h3','Touch panel display')); const row=el('div',undefined,'row');row.append(button('Clock & weather on panel',()=>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 movement'));choices(detail,[['Disabled','blinds_enabled',false],['Enabled','blinds_enabled',true]]); - detail.append(el('p','Changing this setting does not move the blinds. A controller is required.','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{detail.append(el('h3','Smart Conference Room'),el('p','Touch panel and browser · Beta mode','muted'),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'));} @@ -101,31 +105,38 @@ function lighting(detail,tab){ 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('Fixture control not connected',()=>{}):button('Apply scene',()=>command('preset',{name:scene.id}),{action:'preset'}); - if(scene.id==='Gradient')b.disabled=true;else b.dataset.preset=scene.id; - card.append(el('h3',scene.label),el('p',scene.detail,'muted'),b);grid.append(card); + 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);} - detail.append(el('p','Programme runner not connected · no programme is running','subtle')); - const actions=el('div',undefined,'row');for(const label of ['Start programme','Stop programme']){const b=button(label,()=>{});b.disabled=true;actions.append(b);}detail.append(actions); + 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')); - range(detail,'Colour temperature','cct',1600,10000,100,'K');range(detail,'Light level','intensity',0,1900,25,'controller units');feedback(detail); + 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('output');output.id='value-'+key;const units=el('small',unit),steps=el('div',undefined,'steps'); + 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.textContent=input.value;} + function selected(){dragging=true;rangeRevision??=state?.revision;output.value=input.value;} input.oninput=selected;input.onchange=()=>{selected();commitRange(key);}; - for(const [label,delta] of (key.startsWith('channel')?[]:[['−',-step],['+',step]]))steps.append(button(label,()=>{input.value=Math.max(low,Math.min(high,Number(input.value)+delta));selected();commitRange(key);},{action:key.startsWith('channel')?'channels':'cct'})); + 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);} @@ -172,9 +183,13 @@ function sync(){ const p=state?.preferences||{},s=state?.lights||{},available=state?.available===true; if(state?.palette)window.SCRDesign.apply(state.palette,p.resolved_theme); update('connection',!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?.detail||'Controller not connected');} + 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]')){input.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;update('value-'+k,value);}}} + 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); @@ -204,4 +219,5 @@ let fetching=false; async function refresh(){if(fetching||document.hidden)return;fetching=true;try{const response=await fetch('/api/controller',{cache:'no-store',signal:AbortSignal.timeout(3500)});if(!response.ok)throw Error();state=await response.json();if(unknown&&state.receipts?.some(r=>r.id===unknown)){unknown=null;message('The panel received the command. Check its current confirmation below.');}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;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/tools.html b/browser/tools.html index fed5f0a..f695f6e 100644 --- a/browser/tools.html +++ b/browser/tools.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file From 745d8c3f4322bb14320c5e9f840afaf6ad93d6f2 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 03:04:41 -0400 Subject: [PATCH 19/24] Align weather readings and refresh shared night artwork --- README.md | 2 ++ browser/icons/cloud-moon-light.png | Bin 5902 -> 5621 bytes browser/icons/source.json | 2 +- browser/room-content.json | 1 + browser/room.css | 2 ++ browser/room.js | 9 ++++++--- 6 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 77d5a17..9757bcd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ 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. 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 diff --git a/browser/icons/cloud-moon-light.png b/browser/icons/cloud-moon-light.png index ca4cdce3d7789b7ae7d83aa3bdf1e672470ba03d..e3db8cbb74de978be1eb9ab649e9de0baae1d28f 100644 GIT binary patch literal 5621 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 literal 5902 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 diff --git a/browser/icons/source.json b/browser/icons/source.json index c637e6d..2e19032 100644 --- a/browser/icons/source.json +++ b/browser/icons/source.json @@ -44,5 +44,5 @@ "eye-off", "circle-question-mark" ], - "derivatives": "96px alpha masks for theme-coloured controls; 288px weather artwork preserves original Lucide paths with a thin outline and restrained day/night colours. No gradients or halos." + "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/room-content.json b/browser/room-content.json index 61e06fb..5f5dd91 100644 --- a/browser/room-content.json +++ b/browser/room-content.json @@ -489,6 +489,7 @@ { "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" } } diff --git a/browser/room.css b/browser/room.css index 3d39087..004596a 100644 --- a/browser/room.css +++ b/browser/room.css @@ -44,4 +44,6 @@ button.selected:disabled,button.primary:disabled{color:var(--selected-ink)} @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} +.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 index 749a8a4..a6e3e25 100644 --- a/browser/room.js +++ b/browser/room.js @@ -50,7 +50,9 @@ function home(root){ 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); - for(const [id,cls] of [['condition','condition'],['weather-stats','weather-stats']]){const p=el('p',undefined,cls);p.id=id;right.append(p);} + 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); @@ -205,11 +207,12 @@ function minutes(value){return Math.floor(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','—');if($('daylight'))$('daylight').hidden=true;return;} + if(!value){update('condition','Weather unavailable');update('temperature','—');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); - const stats=[];if(!old){if(Number.isFinite(value.feels_like_f))stats.push('Feels like '+degree(value.feels_like_f));if(Number.isFinite(value.high_f))stats.push('High '+degree(value.high_f));if(Number.isFinite(value.low_f))stats.push('Low '+degree(value.low_f));if(Number.isFinite(value.rain_chance))stats.push('Rain today '+value.rain_chance+'%');}update('weather-stats',old?'Earlier reading: '+value.model_time.replace('T',' '):stats.join(' · ')); + 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;}} From 5c857499421d3cea0c8562d3c2bbe951686a25e2 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 03:05:46 -0400 Subject: [PATCH 20/24] Clear old weather attribution when readings disappear --- browser/room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/browser/room.js b/browser/room.js index a6e3e25..82d032b 100644 --- a/browser/room.js +++ b/browser/room.js @@ -207,7 +207,7 @@ function minutes(value){return Math.floor(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','—');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;} + 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); From b0430974e0a0f16ebec29e7dc4ce3cb53f058757 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 13:36:56 -0400 Subject: [PATCH 21/24] Use one screen saver preview in display controls --- README.md | 3 +++ browser/room.js | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9757bcd..52db6f0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ 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. 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 diff --git a/browser/room.js b/browser/room.js index 82d032b..9153412 100644 --- a/browser/room.js +++ b/browser/room.js @@ -88,8 +88,8 @@ function tools(root,page){ 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.','muted'));detail.append(el('h3','Touch panel display')); - const row=el('div',undefined,'row');row.append(button('Clock & weather on panel',()=>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('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')); From 741d58b4f5715c70915ff28f101f1e3191b7a409 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 14:17:58 -0400 Subject: [PATCH 22/24] Confirm panel restart and shutdown from shared web controls --- README.md | 6 ++++ browser/room-content.json | 1 + browser/room.css | 1 + browser/room.js | 66 +++++++++++++++++++++++++++++++----- tests/test_browser_status.py | 17 ++++++++++ 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 52db6f0..8fdf392 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ Earlier forecasts are dated and hide current-day details until fresh data return 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 diff --git a/browser/room-content.json b/browser/room-content.json index 5f5dd91..4110af9 100644 --- a/browser/room-content.json +++ b/browser/room-content.json @@ -1,5 +1,6 @@ { "version": 1, + "app_version": "0.2.0-beta", "icons": { "tools": [ { diff --git a/browser/room.css b/browser/room.css index 004596a..f2e8993 100644 --- a/browser/room.css +++ b/browser/room.css @@ -44,6 +44,7 @@ button.selected:disabled,button.primary:disabled{color:var(--selected-ink)} @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 index 9153412..e4a2b8a 100644 --- a/browser/room.js +++ b/browser/room.js @@ -2,6 +2,7 @@ // 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;} @@ -11,21 +12,23 @@ function link(text,hash,kind){const a=el('a',text);a.href='#'+hash;if(kind)a.pre 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)){message('Command was rejected. Refresh the page before trying a new action.');return false;} - if(!reply){unknown=id;message('Command receipt unknown. Check the panel; do not repeat the action.');return false;} - if(!response.ok||!reply.ok){message(reply.detail||'Command was not accepted.');return false;} + 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('Command receipt unknown. Check the panel; do not repeat the action.');return false;}) + }).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; + 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)); @@ -95,7 +98,7 @@ function tools(root,page){ 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{detail.append(el('h3','Smart Conference Room'),el('p','Touch panel and browser · Beta mode','muted'),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'));} + 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 · Beta mode','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){ @@ -150,10 +153,28 @@ function guide(root,index){ } function system(parent){ const cards=el('div',undefined,'health-grid');for(const [id,title] of [['panel-health','Panel health'],['room-health','Room connection']]){const card=el('section',undefined,'card'),text=el(id==='panel-health'?'div':'p',undefined,'muted');text.id=id;if(id==='panel-health')for(const key of ['panel-memory','panel-storage','panel-power']){const line=el('p');line.id=key;text.append(line);}card.append(el('h3',title),text);cards.append(card);}parent.append(cards); + powerControls(parent); const row=el('label',undefined,'help-select');row.append(el('span','Help topic'));const select=el('select');select.setAttribute('aria-label','Help topic');select.id='help-topic';row.append(select);parent.append(row); const topics=state?.help||content.help;for(const [i,t] of topics.entries()){const option=el('option',t.title);option.value=i;select.append(option);}select.value=chosenHelp;select.onchange=()=>{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')); @@ -183,8 +204,10 @@ function legacy(root){root.append(el('h2','Legacy view'),el('p','Legacy is the o 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; + update('app-version','Version '+(state?.app_version||content.app_version||'Beta')); + 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?'Panel unavailable':state.mode==='original'?'Legacy':state.busy?'Sending':state.can_control?'Ready':s.state==='failed'?'Uncertain':'Controls unavailable'); + 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);} @@ -201,7 +224,7 @@ function sync(){ } 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.'); - weather(); + syncPower();weather(); } function minutes(value){return Math.floor(value/60).toString().padStart(2,'0')+':'+(value%60).toString().padStart(2,'0');} function weather(){ @@ -219,7 +242,32 @@ function weather(){ 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)){unknown=null;message('The panel received the command. Check its current confirmation below.');}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;sync();}finally{fetching=false;}} +async function refresh(){ + if(fetching||document.hidden)return; + fetching=true; + try{ + const response=await fetch('/api/controller',{cache:'no-store',signal:AbortSignal.timeout(3500)}); + if(!response.ok)throw Error(); + state=await response.json(); + const receipt=unknown&&state.receipts?.find(r=>r.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();}); diff --git a/tests/test_browser_status.py b/tests/test_browser_status.py index b228e56..fcc73bd 100644 --- a/tests/test_browser_status.py +++ b/tests/test_browser_status.py @@ -189,3 +189,20 @@ def exchange(self,value):self.calls.append(value);return {'ok':True,'code':202,' 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] From af68490132d5db023493e7b991d3b5822e229607 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 14:24:15 -0400 Subject: [PATCH 23/24] Show the shared numeric version throughout the interface --- README.md | 4 ++-- browser/diagnostics.html | 2 +- browser/guide.html | 2 +- browser/index.html | 2 +- browser/room-content.json | 6 +++--- browser/room.css | 2 +- browser/room.js | 8 +++++--- browser/styles.css | 6 +++--- browser/theme.js | 2 +- browser/tools.html | 2 +- 10 files changed, 19 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8fdf392..f082c4d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ 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 Beta. Gradient, Sun and Circ remain visible with +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, @@ -58,4 +58,4 @@ Panel-owned guide/help, design roles and Lucide paths are exported by 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. Beta is maintained by Diyanko Bhowmik. +in repository history. The interface is maintained by Diyanko Bhowmik. diff --git a/browser/diagnostics.html b/browser/diagnostics.html index 3414c43..8dd5815 100644 --- a/browser/diagnostics.html +++ b/browser/diagnostics.html @@ -1,7 +1,7 @@ Smart Conference Room · Diagnostics -
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Beta

Diagnostics for room staff

Read-only diagnostics
+
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

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…

diff --git a/browser/guide.html b/browser/guide.html index f695f6e..c8ef5a3 100644 --- a/browser/guide.html +++ b/browser/guide.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/browser/index.html b/browser/index.html index f695f6e..c8ef5a3 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/browser/room-content.json b/browser/room-content.json index 4110af9..8d74a27 100644 --- a/browser/room-content.json +++ b/browser/room-content.json @@ -1,6 +1,6 @@ { "version": 1, - "app_version": "0.2.0-beta", + "app_version": "0.2.0", "icons": { "tools": [ { @@ -752,7 +752,7 @@ { "title": "Room controls", "icon": "home", - "text": "Home always returns to everyday lighting controls. Opening a page does not change the lights.\n\nLegacy opens the original interface after restoring Beta’s starting lighting scene. Use Home in the Legacy bar to return.\n\nLift blinds, Open slats and Close & lower are on Home. Room tools → Screen & controls saves whether blind movement is enabled and whether the controls are shown. Movement is disabled by default and also requires a connected controller." + "text": "Home always returns to everyday lighting controls. Opening a page does not change the lights.\n\nLegacy opens the original interface 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", @@ -762,7 +762,7 @@ { "title": "How it connects", "icon": "health", - "text": "The touch panel and trusted-network website share Beta controls. Legacy’s original controls remain on the touch panel.\n\nThe lighting badge shows this panel’s connection: Ready, Sending, Offline or Uncertain. Tap it for System & help.\n\nWeather is an outdoor model estimate, separate from indoor measurements. Source details are under Room tools → Date & weather." + "text": "The touch panel 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": [ diff --git a/browser/room.css b/browser/room.css index f2e8993..c02c51e 100644 --- a/browser/room.css +++ b/browser/room.css @@ -1,5 +1,5 @@ :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} +*{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}.version-badge{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} diff --git a/browser/room.js b/browser/room.js index e4a2b8a..3b65cf3 100644 --- a/browser/room.js +++ b/browser/room.js @@ -98,7 +98,7 @@ function tools(root,page){ 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 · Beta mode','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'));} + 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){ @@ -199,12 +199,14 @@ async function copy(value){ } 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 Beta light settings before handing over. Blind commands stay disabled in Beta; do not operate the original blind controls.','subtle')); + 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; - update('app-version','Version '+(state?.app_version||content.app_version||'Beta')); + const version=String(state?.app_version||content.app_version||'').match(/^\d+\.\d+\.\d+/)?.[0]||''; + for(const badge of document.querySelectorAll('.version-badge')){badge.textContent=version;badge.hidden=!version;} + 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'); diff --git a/browser/styles.css b/browser/styles.css index 70f3fba..d74476c 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -22,7 +22,7 @@ a:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } .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; } +.version-badge { 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); } @@ -122,8 +122,8 @@ main { padding-block: 26px; } body{background:var(--bg,#fff)} [data-theme=dark] .warm{color:var(--ink)} -.beta{background:var(--soft);color:var(--muted)} +.version-badge{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} +.header,.title-line{align-items:flex-end}.heading{padding-top:0}.version-badge{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 index 14009ea..6c61144 100644 --- a/browser/theme.js +++ b/browser/theme.js @@ -1,3 +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(_){}} +(()=>{let fallback='';const version=value=>{const number=String(value||fallback).match(/^\d+\.\d+\.\d+/)?.[0]||'';for(const badge of document.querySelectorAll('.version-badge')){badge.textContent=number;badge.hidden=!number;}};fetch('/room-content.json').then(reply=>reply.json()).then(value=>{fallback=value.app_version||'';version();}).catch(()=>{});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);version(value.app_version);}catch(_){}} refresh();setInterval(refresh,15000);})(); diff --git a/browser/tools.html b/browser/tools.html index f695f6e..c8ef5a3 100644 --- a/browser/tools.html +++ b/browser/tools.html @@ -1 +1 @@ -Smart Conference Room · Beta
LESA

Smart Conference Room

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

Smart Conference Room

Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file From 2c0a66f0ab91a600c91f811dfd64f36273202ba3 Mon Sep 17 00:00:00 2001 From: Diyanko Bhowmik Date: Fri, 16 Oct 2026 14:40:10 -0400 Subject: [PATCH 24/24] Keep Beta status in headers and version details in About --- browser/diagnostics.html | 2 +- browser/guide.html | 2 +- browser/index.html | 2 +- browser/room.css | 2 +- browser/room.js | 1 - browser/styles.css | 6 +++--- browser/theme.js | 2 +- browser/tools.html | 2 +- 8 files changed, 9 insertions(+), 10 deletions(-) diff --git a/browser/diagnostics.html b/browser/diagnostics.html index 8dd5815..3414c43 100644 --- a/browser/diagnostics.html +++ b/browser/diagnostics.html @@ -1,7 +1,7 @@ Smart Conference Room · Diagnostics -
LESA — Lighting Enabled Systems & Applications

Smart Conference Room

Diagnostics for room staff

Read-only 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…

diff --git a/browser/guide.html b/browser/guide.html index c8ef5a3..bfee5d1 100644 --- a/browser/guide.html +++ b/browser/guide.html @@ -1 +1 @@ -Smart Conference Room
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/browser/index.html b/browser/index.html index c8ef5a3..bfee5d1 100644 --- a/browser/index.html +++ b/browser/index.html @@ -1 +1 @@ -Smart Conference Room
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
Shared with the touch panel · Recorder not connected
\ No newline at end of file diff --git a/browser/room.css b/browser/room.css index c02c51e..f2e8993 100644 --- a/browser/room.css +++ b/browser/room.css @@ -1,5 +1,5 @@ :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}.version-badge{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} +*{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} diff --git a/browser/room.js b/browser/room.js index 3b65cf3..b5b91fe 100644 --- a/browser/room.js +++ b/browser/room.js @@ -205,7 +205,6 @@ function update(id,text){if($(id)&&$(id).textContent!==String(text))$(id).textCo 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]||''; - for(const badge of document.querySelectorAll('.version-badge')){badge.textContent=version;badge.hidden=!version;} 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); diff --git a/browser/styles.css b/browser/styles.css index d74476c..70f3fba 100644 --- a/browser/styles.css +++ b/browser/styles.css @@ -22,7 +22,7 @@ a:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } .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; } -.version-badge { color: var(--accent); background: #edf1f4; border-radius: 4px; padding: 3px 9px; font-size: .95rem; } +.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); } @@ -122,8 +122,8 @@ main { padding-block: 26px; } body{background:var(--bg,#fff)} [data-theme=dark] .warm{color:var(--ink)} -.version-badge{background:var(--soft);color:var(--muted)} +.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}.version-badge{border-radius:0} +.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 index 6c61144..14009ea 100644 --- a/browser/theme.js +++ b/browser/theme.js @@ -1,3 +1,3 @@ /* Extra diagnostics remain observation-only and share the panel's visual roles. */ -(()=>{let fallback='';const version=value=>{const number=String(value||fallback).match(/^\d+\.\d+\.\d+/)?.[0]||'';for(const badge of document.querySelectorAll('.version-badge')){badge.textContent=number;badge.hidden=!number;}};fetch('/room-content.json').then(reply=>reply.json()).then(value=>{fallback=value.app_version||'';version();}).catch(()=>{});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);version(value.app_version);}catch(_){}} +(()=>{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 index c8ef5a3..bfee5d1 100644 --- a/browser/tools.html +++ b/browser/tools.html @@ -1 +1 @@ -Smart Conference Room
LESA

Smart Conference Room

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

Smart Conference Room

Beta
Connecting…
\ No newline at end of file