diff --git a/README.md b/README.md
index ab20aeb..f082c4d 100644
--- a/README.md
+++ b/README.md
@@ -1,80 +1,61 @@
-# SCR room touch panel
-
-Touch controls for the LESA Smart Conference Room. The original application and
-the newer Beta client have separate entrypoints and share the existing room
-services. Beta does not yet cover every original feature.
-
-## Choose the version
-
-| Version | Source and setup |
-|---|---|
-| Original application | [main.py](main.py); requires the installed custom ROS clients and room environment |
-| Earlier Beta baseline | [app/scr_touch_panel.py](app/scr_touch_panel.py) on `main`; installed launcher expects Ubuntu/Python 2.7 |
-| Fresh Pi OS and current Beta | [Branch README](https://github.com/LESA-RPI/scr.touch_panel/blob/experiment/fresh-pi-setup-20261013/README.md); Python 3 setup and newer interface under review in [PR #1](https://github.com/LESA-RPI/scr.touch_panel/pull/1) |
-
-The newer interface calls the original application **Legacy**. This baseline
-uses **Original**. Check the cabinet's installed release before maintenance;
-the default branch does not identify what is running on a device.
-
-## Try the baseline without room hardware
-
-Use desktop Python 3.11 or newer with Tk available. `python -m tkinter` checks
-Tk. On Linux, install distribution Tk and virtual-environment packages if needed.
-
-```sh
-git clone https://github.com/LESA-RPI/scr.touch_panel.git
-cd scr.touch_panel
-python3 -m venv .venv
-. .venv/bin/activate
-python -m pip install -r app/requirements.txt
-python -B app/scr_touch_panel.py --windowed --duration 600 --theme light
-```
-
-On Windows, use `python` in place of `python3` and activate with
-`.venv\Scripts\Activate.ps1`. This preview moves no room devices and starts no
-recording. Desktop requirements are separate from the installed Ubuntu stack.
-For fresh Raspberry Pi OS, use the branch guide above rather than this launcher.
-
-## Operate the baseline
-
-In this version, Settings returns to Original controls and its touch strip
-returns to Beta. Credits are in Settings. Lighting sliders send automatically;
-confirmation means controller readback, not measured physical brightness.
-Opening Original or exiting waits for restoration of Beta's starting light scene.
-If the result is uncertain, stop changing lights and coordinate with the room host.
-
-Practice annotations have `recording: false`; they do not start a sensor recorder.
-System reports panel readings and advertised services, not physical sensor health.
-A stationary two-second press leaves the black screen; its backlight stays on.
-The updated branch has different navigation and additional features.
-
-## Install, maintain or restore
-
-| Need | Reference |
-|---|---|
-| Connect lighting and establish ownership | [Connection checks](WORKFLOWS.md#before-connected-control) |
-| Switch modes or recover an uncertain result | [Switching and recovery](WORKFLOWS.md#switching-and-recovery) |
-| Set up autostart or roll back | [Startup and rollback](WORKFLOWS.md#startup-and-rollback) |
-| Restore hardware or provision a replacement | [Backup and rebuild](WORKFLOWS.md#backup-and-rebuild) |
-| Add a profile or experiment adapter | [Interface contracts](docs/interface.md) |
-
-The installed launcher expects the older Python 2.7/Tk/Pillow environment and
-custom ROS workspace. Desktop Python 3 preview support does not migrate that
-launcher. Existing room servers remain separate dependencies; upgrading the
-panel does not require replacing them. Coordinate ownership before live control.
-
-Git preserves source, not a complete system. Obtain private configuration and
-recovery bundles from the room custodian. Check manifests, scope and read-error
-records: an incomplete image is not an exact clone. Keep captures, credentials,
-participant data and host settings outside this public repository.
-
-Run `python -m pytest tests` with pytest installed. Baseline checks cover profiles,
-lighting restoration and uncertain results. Earlier bounded cabinet trials do not
-qualify another release, replacement hardware or prolonged reliability. Preserve
-release hashes and original contributors when cleaning public documentation.
-
-General panel code belongs here; Project-X owns its optional recorder integration.
-Browser/tablet clients are planned, not implemented.
-
-Beta: **Diyanko Bhowmik**, with Codex assistance. Original contributors remain in
-Git history. The supplied LESA logo is retained unchanged.
+# SCR room browser interface
+
+A browser companion to the touch panel, with the same Home controls, one Lighting
+workspace (White light, Scenes, Programmes and Channels), instruction workflow,
+preferences, guide and help. The panel owns
+all commands. Diagnostics remain observation-only. Legacy opens the original
+application on the cabinet; its original actuator controls stay there.
+
+Browser control is opt-in: pass `--panel-control-socket` to `browser/serve.py`, or
+set `SCR_WEB_CONTROL_SOCKET` for `browser/start.sh`, pointing to the panel’s
+private socket. Enable its matching `--control-socket` / `SCR_CONTROL_SOCKET`.
+Both run as the same user. Keep the explicit bind address and client-subnet
+allowlist. Everyone on that configured subnet can use controls; there is no
+separate staff login. Without a bridge, controls disable and guide/help remain
+available. No browser hardware driver is imported.
+
+Commands use the existing panel queue, require a fresh revision and are never
+automatically retried. Confirmed readback alone selects Lights on/off. Pending,
+uncertain or disconnected states clear that selection. Running instructions block
+lighting from both interfaces. All three original blind actions are visible;
+movement has a saved Locked/Unlocked setting, locked by default, and requires a connected
+controller. None is installed in the new interface. Gradient, Sun and Circ remain visible with
+disconnected Apply/Start controls until their adapters are integrated. Empty Legacy
+placeholders and the redundant Enable Sliders gate are omitted. Experiment mode does not
+start a recorder. Service-start/restart references are monospace text with Copy,
+never executable buttons. Each documented step explains the computer, purpose
+and expected result, with its own Copy button and a Copy all option for the topic.
+Dark backgrounds stay neutral gray; controls, weather and status use restrained
+teal, warm and green/amber accents shared with the panel.
+Weather readings use aligned labels and values, with dashes for missing readings.
+Earlier forecasts are dated and hide current-day details until fresh data returns.
+Screen & controls can preview the touch panel's screen saver. The idle timer and
+preview open the same screen; its first touch returns to Home without activating
+a control. Date & weather contains clock-format and temperature preferences.
+System & help can restart or shut down the touch panel when its power controls
+are available. Each request requires a confirmation and an acknowledgement that
+recordings and experiments have stopped. The panel validates ownership, restores
+lighting and saves its state before the operating-system action. These controls
+do not stop other room computers or recorders. A restart or shutdown disconnects
+this website; unknown requests are never retried automatically.
+Desktop layouts place white-light controls side by side. Landscape tablets keep
+large controls; phones put lights first with expandable blinds and a compact
+Room tools section selector. Numeric light
+values can be typed or adjusted with sliders. Enter or leaving a valid field
+applies the selection; Escape cancels. Empty or out-of-range values never dispatch.
+Polling preserves an edit, and the panel still validates its original revision.
+The website bundles the same Cantarell family used by the panel, so its layout
+does not depend on a visitor's installed fonts. Unmodified font files retain
+their [SIL Open Font License](browser/fonts/LICENSE).
+
+Run `python -m pytest tests`. HTTP tests use loopback mock services and cover Host,
+Origin, nonce, subnet, bounded command bodies, static allowlists and read-only
+diagnostics. Browser interaction and native Pi qualification are separate checks.
+Do not merge the draft experiment branch without researcher approval.
+
+Panel-owned guide/help, design roles and Lucide paths are exported by
+`app/export_room_content.py` in the panel branch to `browser/room-content.json`.
+Live controller state supplies the same content and preferences. Preserve source
+provenance when rebuilding browser assets. The artwork notices remain in
+[browser/icons/LICENSE](browser/icons/LICENSE). Original contributors are retained
+in repository history. The interface is maintained by Diyanko Bhowmik.
diff --git a/WORKFLOWS.md b/WORKFLOWS.md
index 3c6b44c..b396bd0 100644
--- a/WORKFLOWS.md
+++ b/WORKFLOWS.md
@@ -114,6 +114,121 @@ Theme times use the panel process's configured timezone. Auto changes wait for
appearance, date/time, schedule and screen rows. Font and study defaults are launch
configuration; no separate settings service is needed.
+## Read-only browser status
+
+The browser status server is independent of Tk, ROS client bindings and Project-X.
+It uses Python 3.11's standard library and the existing LESA logo. It imports no
+control adapter and exposes no actuator, recorder or maintenance command route.
+
+```sh
+# Local-only review; no room queries.
+python -B browser/serve.py
+
+# Room host: substitute verified private interface/master addresses.
+python -B browser/serve.py --bind PANEL_PRIVATE_IP --allow-clients ROOM_SUBNET_CIDR --port 8768 \
+ --label "Cabinet Pi" --master http://ROOM_PRIVATE_IP:11311 \
+ --weather-latitude 42.7284 --weather-longitude -73.6918
+```
+
+The server binds one explicit private IPv4 address and accepts socket peers only
+from the explicit trusted client subnet. Non-loopback hosting requires that subnet;
+forwarding headers do not grant access. Open the
+printed literal-IP URL; Host/Origin checks intentionally refuse other hostnames.
+No router forwarding, firewall changes or public hosting are part of this setup.
+There is no sign-in: allowed subnet clients can view the limited status payload.
+The touch-panel Pi hosts this trial, accessible from the room's SCR_AP LAN. Keep
+it on that trusted network. A subnet cannot identify a Wi-Fi SSID: other devices
+on the same wired LAN also have access. Router guest isolation remains the network
+owner's responsibility. This does not secure existing ROS services.
+
+One sampler refreshes host resources and the ROS master's `getSystemState` graph
+every 30 seconds, independent of visitor count. It registers no node, starts no
+producer and calls no device services. Omit `--master` for host-only operation.
+The graph response is capped at 256 KiB; its subprocess ends after four seconds.
+HTTP requests have a four-second absolute deadline and a two-second idle timeout.
+Requests serve only an in-memory snapshot and explicitly listed static assets.
+No directory listing, arbitrary file access, CORS or write endpoints are provided.
+
+Overview has no technical status; `/diagnostics` shows host resources and room
+registrations, and `/guide` explains use and failures. They share fixed navigation,
+the same cached API and a white surface. There are no appearance switches or storage
+cards. A diagnostic view is an audience choice, not a staff permission boundary.
+Host readings describe the server computer, not every room computer. Diagnostics
+names that source explicitly; a host-only preview is labelled as such.
+The supplied main logo is unchanged; its blank margins are trimmed only by layout.
+The clock
+shows Troy's America/New_York time using the viewing device's clock. Service cards
+show advertised/partly advertised/not advertised registrations, not physical health
+or recording state. After failure, previous observations retain their original
+age. Samples older than 90 seconds are labelled stale. Visible pages poll every
+30 seconds; hidden tabs pause requests. Snapshot data includes no raw recordings,
+participant information, service-provider names, paths or private master address.
+
+### Independent website startup
+
+Home matches the cabinet's everyday layout, with stacked lights/blinds labels
+beside time and weather. All four website action buttons are disabled and have
+no handlers. Advanced · diagnostics opens the existing read-only diagnostics;
+Demo & guide opens the room guide. There are no new actuator endpoints or roles.
+
+Retain each published release under `~/scr-browser-status/releases/COMMIT` and
+point `~/scr-browser-status/current` to the selected release. This is separate from
+`~/scr-panel/current`. Stop an earlier standalone website only after checking its
+exact command; never kill the panel or room processes to free the website port.
+
+Create `~/.config/scr-browser-status/deployment.env` with mode 0600 and verified
+values. Keep deployment addresses outside this public repository:
+
+```ini
+SCR_WEB_BIND=PANEL_PRIVATE_IP
+SCR_WEB_CLIENTS=ROOM_SUBNET_CIDR
+SCR_WEB_PORT=8768
+SCR_WEB_LABEL="Cabinet Pi"
+SCR_WEB_MASTER=http://ROOM_PRIVATE_IP:11311
+SCR_WEB_LATITUDE=42.7284
+SCR_WEB_LONGITUDE=-73.6918
+```
+
+Install the included unit and enable this user to run services before login:
+
+```sh
+mkdir -p ~/.config/systemd/user
+cp ~/scr-browser-status/current/browser/scr-browser-status.service ~/.config/systemd/user/
+loginctl enable-linger "$USER"
+systemctl --user daemon-reload
+systemctl --user enable --now scr-browser-status.service
+systemctl --user status scr-browser-status.service
+```
+
+Enabling linger may require administrator approval on another installation.
+Confirm `loginctl show-user "$USER" -p Linger` reports `yes`. The existing panel
+desktop autostart stays intact. The website retries after 15 seconds if its bind
+address is not ready during boot; missing source data is reported as unavailable.
+Initial weather/graph sampling can delay HTTP readiness by about 14 seconds.
+If DHCP changes the Pi address, update the private environment and restart this
+website service. Do not change room network settings for this application.
+
+Use `systemctl --user restart scr-browser-status.service` for a website-only reload
+and `journalctl --user -u scr-browser-status.service -n 30` for errors. Roll back with
+`systemctl --user disable --now scr-browser-status.service`; an earlier release can
+be selected by repointing only the website's `current` symlink. Disable linger only
+if this installation enabled it and no other user services need it. No lighting
+restoration is needed because the website owns no lights. Verify a service restart
+first; qualify a physical boot later when someone is in the room.
+
+Outdoor weather reuses `app/weather.py`, the panel's validated Open-Meteo helper.
+The sampler refreshes at most once per 15 minutes, with an eight-second subprocess
+deadline and 32 KiB response limit. Failures retain the last successful values
+and their observation age; old or failed data is labelled and its daylight bar
+hidden. Coordinates above describe Troy, not indoor room conditions. No generated
+values are substituted when weather is unavailable. It is independent of room ROS.
+
+Keep presentation dependent on `docs/interface.md`, not on ROS service paths. The
+server's host, graph and weather functions are the current source boundary; the
+browser uses the normalized versioned JSON and freshness fields. A future backend
+can replace a source behind that boundary without rewriting each view. Add an
+adapter only for a real new source; a plugin framework is not needed for this trial.
+
## Backup and rebuild
The Git repository preserves panel source. It does not preserve the installed
diff --git a/app/weather.py b/app/weather.py
new file mode 100644
index 0000000..e3a08d8
--- /dev/null
+++ b/app/weather.py
@@ -0,0 +1,142 @@
+# -*- coding: utf-8 -*-
+"""Optional Open-Meteo model weather. Called as a bounded background helper."""
+from __future__ import print_function
+import argparse
+import datetime
+import json
+import math
+try:
+ from urllib.request import urlopen
+ from urllib.parse import urlencode
+except ImportError:
+ from urllib2 import urlopen
+ from urllib import urlencode
+
+
+def summary(data):
+ current, daily = data['current'], data['daily']
+ code = current['weather_code']
+ descriptions = {0: 'Clear', 1: 'Mostly clear', 2: 'Partly cloudy', 3: 'Overcast',
+ 45: 'Fog', 48: 'Freezing fog'}
+ condition = descriptions.get(code)
+ if condition is None:
+ if code in (51, 53, 55, 56, 57):
+ condition = 'Drizzle'
+ elif code in (61, 63, 65, 66, 67, 80, 81, 82):
+ condition = 'Rain'
+ elif code in (71, 73, 75, 77, 85, 86):
+ condition = 'Snow'
+ elif code in (95, 96, 99):
+ condition = 'Thunderstorms'
+ else:
+ condition = 'Conditions unavailable'
+ value = {'temperature_f': current['temperature_2m'], 'condition': condition,
+ 'code': code, 'sunrise': daily['sunrise'][0], 'sunset': daily['sunset'][0],
+ 'model_time': current['time']}
+ if current.get('apparent_temperature') is not None:
+ value['feels_like_f'] = current['apparent_temperature']
+ for source, key in [('temperature_2m_max', 'high_f'), ('temperature_2m_min', 'low_f'),
+ ('precipitation_probability_max', 'rain_chance')]:
+ if daily.get(source) and daily[source][0] is not None:
+ value[key] = daily[source][0]
+ return validate(value)
+
+
+def local_time(text):
+ return datetime.datetime.strptime(text[:16], '%Y-%m-%dT%H:%M')
+
+
+def validate(value):
+ if not isinstance(value, dict):
+ raise ValueError('Invalid weather reply')
+ temperature = float(value['temperature_f'])
+ if math.isnan(temperature) or math.isinf(temperature):
+ raise ValueError('Invalid weather temperature')
+ text_types = (str,) if str is type(u'') else (basestring,)
+ for key in ('condition', 'sunrise', 'sunset', 'model_time'):
+ if not isinstance(value[key], text_types) or not 1 <= len(value[key]) <= 100:
+ raise ValueError('Invalid weather text')
+ for key in ('sunrise', 'sunset', 'model_time'):
+ local_time(value[key])
+ if local_time(value['sunrise']) >= local_time(value['sunset']):
+ raise ValueError('Invalid daylight interval')
+ code = int(value['code'])
+ result = {'temperature_f': temperature, 'temperature_c': (temperature - 32) * 5.0 / 9,
+ 'condition': value['condition'], 'code': code, 'sunrise': value['sunrise'],
+ 'sunset': value['sunset'], 'model_time': value['model_time']}
+ for key in ('feels_like_f', 'high_f', 'low_f', 'rain_chance'):
+ if value.get(key) is None:
+ continue
+ extra = float(value[key])
+ if math.isnan(extra) or math.isinf(extra) or isinstance(value[key], bool):
+ raise ValueError('Invalid weather detail')
+ if key == 'rain_chance' and not 0 <= extra <= 100:
+ raise ValueError('Invalid precipitation probability')
+ result[key] = extra
+ if key.endswith('_f'):
+ result[key[:-1] + 'c'] = (extra - 32) * 5.0 / 9
+ if 'high_f' in result and 'low_f' in result and result['low_f'] > result['high_f']:
+ raise ValueError('Invalid daily temperature range')
+ return result
+
+
+def details(value, unit='f', earlier=False):
+ """Compact optional forecast details; never present old daily values as today's."""
+ if earlier:
+ return 'Earlier weather: ' + value['model_time'].replace('T', ' ')
+ lines = []
+ if 'feels_like_' + unit in value:
+ lines.append(u'Feels like %.0f° %s' % (value['feels_like_' + unit], unit.upper()))
+ daily = []
+ for label, key in [('High', 'high_'), ('Low', 'low_')]:
+ if key + unit in value:
+ daily.append(label + (u' %.0f°' % value[key + unit]))
+ if 'rain_chance' in value:
+ daily.append('Rain today %.0f%%' % value['rain_chance'])
+ if daily:
+ lines.append(u' · '.join(daily))
+ return '\n'.join(lines)
+
+
+def daylight(value, now):
+ rise, setting = local_time(value['sunrise']), local_time(value['sunset'])
+ if now.date() != rise.date():
+ return None
+ fraction = max(0, min(1, (now - rise).total_seconds() / (setting - rise).total_seconds()))
+ if now >= setting:
+ label = 'After sunset · night'
+ else:
+ target, event = (rise, 'rises') if now < rise else (setting, 'sets')
+ minutes = int(math.ceil((target - now).total_seconds() / 60))
+ hours, minutes = divmod(minutes, 60)
+ label = 'Sun %s in %s' % (event, ('%d h %02d min' % (hours, minutes)) if hours else '%d min' % minutes)
+ return {'fraction': fraction, 'label': label, 'now': now.strftime('%H:%M'),
+ 'sunrise': rise.strftime('%H:%M'), 'sunset': setting.strftime('%H:%M')}
+
+
+def fetch(latitude, longitude, timezone):
+ url = 'https://api.open-meteo.com/v1/forecast?' + urlencode({
+ 'latitude': latitude, 'longitude': longitude, 'timezone': timezone,
+ 'current': 'temperature_2m,weather_code,apparent_temperature',
+ 'daily': 'sunrise,sunset,temperature_2m_max,temperature_2m_min,precipitation_probability_max',
+ 'temperature_unit': 'fahrenheit', 'forecast_days': 1})
+ response = urlopen(url, timeout=5)
+ try:
+ body = response.read(32769)
+ finally:
+ response.close()
+ if len(body) > 32768:
+ raise ValueError('Weather response too large')
+ return summary(json.loads(body.decode('utf-8')))
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('latitude', type=float)
+ parser.add_argument('longitude', type=float)
+ parser.add_argument('timezone')
+ args = parser.parse_args()
+ try:
+ print(json.dumps(fetch(args.latitude, args.longitude, args.timezone)))
+ except Exception:
+ print(json.dumps({'error': 'Weather unavailable; the room controls still work.'}))
diff --git a/browser/controller.py b/browser/controller.py
new file mode 100644
index 0000000..af4ee19
--- /dev/null
+++ b/browser/controller.py
@@ -0,0 +1,21 @@
+"""Trusted-network gateway to the panel's private socket, never a second driver."""
+import json
+import socket
+
+
+class Controller:
+ def __init__(self,path): self.path=path
+
+ def exchange(self,value):
+ raw=json.dumps(value,allow_nan=False).encode()+b'\n'
+ if len(raw)>4096:raise ValueError('Command too large')
+ with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as client:
+ client.settimeout(2.8)
+ client.connect(self.path);client.sendall(raw)
+ with client.makefile('rb') as stream:reply=stream.readline(65537)
+ if len(reply)>65536 or not reply.endswith(b'\n'):raise ValueError('Invalid panel reply')
+ return json.loads(reply)
+
+ def read(self):
+ try:return self.exchange({'op':'read'})
+ except (OSError,ValueError):return {'available':False,'detail':'Panel controller unavailable. Controls are disabled.'}
diff --git a/browser/design.js b/browser/design.js
new file mode 100644
index 0000000..32f7d55
--- /dev/null
+++ b/browser/design.js
@@ -0,0 +1,8 @@
+/* Role names and values are supplied by the panel's design_tokens.py. */
+window.SCRDesign={apply(palette,theme){
+ if(!palette)return;
+ for(const [key,value] of Object.entries(palette))document.documentElement.style.setProperty('--'+key.replaceAll('_','-'),value);
+ for(const [alias,key] of [['soft','control'],['line','border']])document.documentElement.style.setProperty('--'+alias,palette[key]);
+ document.documentElement.style.colorScheme=theme==='dark'?'dark':'light';
+ document.documentElement.dataset.theme=theme;
+}};
diff --git a/browser/diagnostics.html b/browser/diagnostics.html
new file mode 100644
index 0000000..3414c43
--- /dev/null
+++ b/browser/diagnostics.html
@@ -0,0 +1,19 @@
+
+
Smart Conference Room · Diagnostics
+
+
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.
\ No newline at end of file
diff --git a/browser/icons/LICENSE b/browser/icons/LICENSE
new file mode 100644
index 0000000..718bb3f
--- /dev/null
+++ b/browser/icons/LICENSE
@@ -0,0 +1,43 @@
+ISC License
+
+Copyright (c) 2026 Lucide Icons and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+---
+
+The following Lucide icons are derived from the Feather project:
+
+airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
+
+The MIT License (MIT) (for the icons listed above)
+
+Copyright (c) 2013-present Cole Bemis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/browser/icons/book-open.svg b/browser/icons/book-open.svg
new file mode 100644
index 0000000..25dbd7a
--- /dev/null
+++ b/browser/icons/book-open.svg
@@ -0,0 +1,14 @@
+
diff --git a/browser/icons/circle-question-mark-light.png b/browser/icons/circle-question-mark-light.png
new file mode 100644
index 0000000..d9dc143
Binary files /dev/null and b/browser/icons/circle-question-mark-light.png differ
diff --git a/browser/icons/cloud-fog-light.png b/browser/icons/cloud-fog-light.png
new file mode 100644
index 0000000..e2a035c
Binary files /dev/null and b/browser/icons/cloud-fog-light.png differ
diff --git a/browser/icons/cloud-light.png b/browser/icons/cloud-light.png
new file mode 100644
index 0000000..4964b2e
Binary files /dev/null and b/browser/icons/cloud-light.png differ
diff --git a/browser/icons/cloud-lightning-light.png b/browser/icons/cloud-lightning-light.png
new file mode 100644
index 0000000..83e16f6
Binary files /dev/null and b/browser/icons/cloud-lightning-light.png differ
diff --git a/browser/icons/cloud-moon-light.png b/browser/icons/cloud-moon-light.png
new file mode 100644
index 0000000..e3db8cb
Binary files /dev/null and b/browser/icons/cloud-moon-light.png differ
diff --git a/browser/icons/cloud-rain-light.png b/browser/icons/cloud-rain-light.png
new file mode 100644
index 0000000..561db4f
Binary files /dev/null and b/browser/icons/cloud-rain-light.png differ
diff --git a/browser/icons/cloud-snow-light.png b/browser/icons/cloud-snow-light.png
new file mode 100644
index 0000000..02df3bd
Binary files /dev/null and b/browser/icons/cloud-snow-light.png differ
diff --git a/browser/icons/cloud-sun-light.png b/browser/icons/cloud-sun-light.png
new file mode 100644
index 0000000..24f468e
Binary files /dev/null and b/browser/icons/cloud-sun-light.png differ
diff --git a/browser/icons/lightbulb-off.svg b/browser/icons/lightbulb-off.svg
new file mode 100644
index 0000000..58ed144
--- /dev/null
+++ b/browser/icons/lightbulb-off.svg
@@ -0,0 +1,17 @@
+
diff --git a/browser/icons/lightbulb.svg b/browser/icons/lightbulb.svg
new file mode 100644
index 0000000..fcb3c40
--- /dev/null
+++ b/browser/icons/lightbulb.svg
@@ -0,0 +1,15 @@
+
diff --git a/browser/icons/moon-light.png b/browser/icons/moon-light.png
new file mode 100644
index 0000000..54c2ca8
Binary files /dev/null and b/browser/icons/moon-light.png differ
diff --git a/browser/icons/sliders-horizontal.svg b/browser/icons/sliders-horizontal.svg
new file mode 100644
index 0000000..51b938d
--- /dev/null
+++ b/browser/icons/sliders-horizontal.svg
@@ -0,0 +1,21 @@
+
diff --git a/browser/icons/source.json b/browser/icons/source.json
new file mode 100644
index 0000000..2e19032
--- /dev/null
+++ b/browser/icons/source.json
@@ -0,0 +1,48 @@
+{
+ "project": "Lucide",
+ "revision": "7b0196e249ca09ca27c584c497aca9a8cab8a7f5",
+ "source": "https://github.com/lucide-icons/lucide",
+ "license": "ISC; includes Feather notices",
+ "icons": [
+ "house",
+ "lightbulb",
+ "lightbulb-off",
+ "blinds",
+ "sliders-horizontal",
+ "flask-conical",
+ "activity",
+ "clock",
+ "circle-check",
+ "circle-slash",
+ "layers",
+ "book-open",
+ "info",
+ "sun-moon",
+ "calendar-days",
+ "monitor",
+ "arrow-left",
+ "external-link",
+ "chevron-left",
+ "chevron-right",
+ "sun",
+ "moon",
+ "cloud",
+ "cloud-sun",
+ "cloud-moon",
+ "cloud-rain",
+ "cloud-snow",
+ "cloud-lightning",
+ "cloud-fog",
+ "sunrise",
+ "sunset",
+ "droplets",
+ "thermometer",
+ "play",
+ "pause",
+ "monitor-off",
+ "terminal",
+ "eye-off",
+ "circle-question-mark"
+ ],
+ "derivatives": "96px alpha masks for theme-coloured controls; 288px weather artwork uses Lucide paths with a thin outline and restrained day/night colours. The cloud-moon crescent is repositioned to meet the foreground cloud. No gradients or halos."
+}
diff --git a/browser/icons/sun-light.png b/browser/icons/sun-light.png
new file mode 100644
index 0000000..5084ea4
Binary files /dev/null and b/browser/icons/sun-light.png differ
diff --git a/browser/index.html b/browser/index.html
new file mode 100644
index 0000000..bfee5d1
--- /dev/null
+++ b/browser/index.html
@@ -0,0 +1 @@
+Smart Conference Room
\ No newline at end of file
diff --git a/docs/interface.md b/docs/interface.md
index 1a7dcc9..60a8fcb 100644
--- a/docs/interface.md
+++ b/docs/interface.md
@@ -1,8 +1,8 @@
# Panel interfaces, version 1
-These contracts serve the existing Tk client and optional SCR light helper.
-They are deliberately small. No registry, plugin loader, network API or recorder
-service is implemented.
+These contracts serve the existing Tk client, optional SCR light helper and the
+read-only browser status page. No registry, plugin loader, browser control API or
+recorder service is implemented.
## Study profile
@@ -75,3 +75,41 @@ service graph through one XML-RPC call, then exits without a ROS node or fixture
queries. The UI bounds that subprocess, refreshes only while System is visible
and labels the result as advertised service availability. It does not query TOF,
blinds, HVAC or acquisition data, and does not assess IRMA recorder health.
+
+## Browser status, version 1
+
+`GET /api/status` returns `version: 1`, `read_only: true`, a public `host_label`,
+UTC `sampled_at`, monotonic `sample_age_s`, `host` resources and a `room` observation.
+The endpoint reads a cached snapshot; it never initiates a room query.
+
+Overview, Diagnostics and Guide share this contract. They do not depend on ROS
+paths or client libraries. Diagnostics is currently readable by anyone on the
+trusted LAN; choosing a view does not grant a role or permission to control devices.
+
+Host fields are `memory_available_mib`, `memory_total_mib`, `disk_available_gib`,
+`disk_used_percent`, `uptime_s`, `temperature_c` and `power`. Unsupported readings
+are null; power is `unavailable`, `no_warning`, `warning_earlier` or `warning_now`.
+These resources belong to the server host. Power reflects Pi undervoltage flags,
+not a physical voltage measurement.
+
+Room `state` is `unconfigured`, `available` or `unavailable`. `observed_at` and
+`observation_age_s` identify the last successful service-graph read; both are null
+until one succeeds. The fixed service groups have `name` and `state`: `advertised`,
+`partial` or `not_advertised`. A failed read retains the previous observations and
+their age; clients must not display them as current availability. Host/graph
+observations older than 90 seconds are stale. Clock time is not sensor timing.
+
+Optional `weather` has `state` (`unconfigured`, `available`, `unavailable`),
+`observed_at`, monotonic `observation_age_s` and `data`. Data uses the shared
+`app/weather.py` fields: `temperature_f`, `temperature_c`, `condition`, `code`,
+`sunrise`, `sunset` and `model_time`. Provider times are dated local Troy times;
+`observed_at` is UTC receipt time, not an acquisition timestamp. Weather failures
+retain values and their original age. Clients label failed/old values and hide
+daylight for another date or observations older than 30 minutes. Weather is model
+data independent of indoor sensors. Older clients may ignore this additional field.
+
+The sampler makes only `getSystemState` requests to a privately configured master.
+POST/PUT/PATCH/DELETE/OPTIONS return 405; other routes are an explicit static-asset
+allowlist. Host/Origin checks and same-origin browser requests provide a limited
+visitor boundary, not authentication. This API has no experimental metadata,
+device state, actuator commands, control ownership or staff role implementation.
diff --git a/tests/test_browser_status.py b/tests/test_browser_status.py
new file mode 100644
index 0000000..fcc73bd
--- /dev/null
+++ b/tests/test_browser_status.py
@@ -0,0 +1,208 @@
+"""Read-only boundary and freshness checks; no room hardware is used."""
+import http.client
+import importlib.util
+import json
+from pathlib import Path
+import socket
+import threading
+import time
+from unittest.mock import patch
+from xmlrpc.server import SimpleXMLRPCServer
+
+import pytest
+
+spec = importlib.util.spec_from_file_location('room_status', Path(__file__).parents[1] / 'browser/serve.py')
+status = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(status)
+
+
+def test_graph_calls_only_master_state_and_preserves_unknown_results():
+ methods = []
+ master = SimpleXMLRPCServer(('127.0.0.1', 0), logRequests=False)
+ def graph(caller):
+ methods.append(('getSystemState', caller))
+ return [1, '', [[], [], [[name, ['existing-server']] for name in status.GROUPS['Lighting']]]]
+ master.register_function(graph, 'getSystemState')
+ thread = threading.Thread(target=master.serve_forever, daemon=True)
+ thread.start()
+ try:
+ result = status.service_graph('http://127.0.0.1:%d' % master.server_address[1])
+ assert methods == [('getSystemState', 'scr_browser_status')]
+ assert result[0] == {'name': 'Lighting', 'state': 'advertised'}
+ assert result[1]['state'] == 'not_advertised'
+ finally:
+ master.shutdown()
+ master.server_close()
+ thread.join(timeout=2)
+
+
+def test_failure_retains_original_observation_age():
+ snapshot = status.Snapshot('Cabinet Pi', 'http://127.0.0.1:11311')
+ with patch.object(status, 'host_status', return_value={}), patch.object(status, 'bounded_graph', return_value=[{'name': 'Lighting', 'state': 'advertised'}]):
+ snapshot.collect()
+ snapshot.graph_time -= 100
+ with patch.object(status, 'host_status', return_value={}), patch.object(status, 'bounded_graph', side_effect=OSError):
+ snapshot.collect()
+ value = snapshot.read()
+ assert value['room']['state'] == 'unavailable'
+ assert value['room']['services'][0]['state'] == 'advertised'
+ assert value['room']['observation_age_s'] >= 100
+ assert value['sample_age_s'] < 1
+
+
+@pytest.fixture
+def server():
+ snapshot = status.Snapshot('Test host')
+ with patch.object(status, 'host_status', return_value={}):
+ snapshot.collect()
+ instance = status.RoomServer(('127.0.0.1', 0), status.Handler)
+ instance.allowed_clients = status.client_network('127.0.0.0/8')
+ instance.expected_host = '%s:%d' % instance.server_address
+ instance.snapshot = snapshot
+ thread = threading.Thread(target=instance.serve_forever, daemon=True)
+ thread.start()
+ yield instance
+ instance.shutdown()
+ instance.server_close()
+ thread.join(timeout=2)
+
+
+def request(server, method, path, headers=None, body=None):
+ connection = http.client.HTTPConnection(*server.server_address, timeout=7)
+ try:
+ connection.request(method, path, body=body, headers=headers or {})
+ response = connection.getresponse()
+ return response.status, response.read(), dict(response.headers)
+ finally:
+ connection.close()
+
+
+def test_http_boundary_and_cache(server):
+ with patch.object(server.snapshot, 'collect', side_effect=AssertionError('HTTP must not poll room')):
+ code, body, headers = request(server, 'GET', '/api/status')
+ assert code == 200 and json.loads(body)['read_only'] is True
+ assert headers['Cache-Control'] == 'no-store'
+ assert 'Access-Control-Allow-Origin' not in headers
+ assert "frame-ancestors 'none'" in headers['Content-Security-Policy']
+ code, image, headers = request(server, 'GET', '/logo.jpg')
+ assert code == 200 and image.startswith(b'\xff\xd8')
+ assert headers['Content-Type'] == 'image/jpeg'
+ code, image, headers = request(server, 'GET', '/icons/cloud-moon-light.png')
+ assert code == 200 and image.startswith(b'\x89PNG\r\n\x1a\n')
+ assert headers['Content-Type'] == 'image/png'
+ code, icon, headers = request(server, 'GET', '/icons/lightbulb.svg')
+ assert code == 200 and b'