commit 40260afeeb2b116eb9b6b84a3a93d1475f39e1c5 Author: timo Date: Sat Aug 1 12:07:22 2026 +0200 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..4936aea --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Bottle Signal-Light UI + +A Bottle-based browser interface for the supplied FastAPI signal-light service. + +## Features + +- Mutually exclusive green, yellow, and red controls +- Independent alert buzzer on/off control +- Demo mode on/off control +- All-off command +- Automatic status polling every 1.5 seconds +- Server-side proxy, so the browser does not need CORS access to FastAPI + +## Run + +```bash +cd signal_light_ui +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +export SIGNAL_LIGHT_API_URL=http://127.0.0.1:8000 +python app.py +``` + +Open `http://:8080`. + +## Configuration + +- `SIGNAL_LIGHT_API_URL` — FastAPI base URL, default `http://127.0.0.1:8000` +- `SIGNAL_LIGHT_TIMEOUT` — upstream timeout in seconds, default `3` +- `BOTTLE_HOST` — Bottle listen address, default `0.0.0.0` +- `BOTTLE_PORT` — Bottle port, default `8080` +- `BOTTLE_DEBUG` — set to `true` for debug/reloader diff --git a/app.py b/app.py new file mode 100644 index 0000000..77e450c --- /dev/null +++ b/app.py @@ -0,0 +1,117 @@ +import os +from typing import Any, Dict, Tuple + +import requests +from bottle import Bottle, HTTPError, TEMPLATE_PATH, request, response, run, static_file, template + +APP_DIR = os.path.dirname(os.path.abspath(__file__)) +TEMPLATE_PATH.insert(0, os.path.join(APP_DIR, "templates")) + +API_BASE_URL = os.getenv("SIGNAL_LIGHT_API_URL", "http://127.0.0.1:8000").rstrip("/") +REQUEST_TIMEOUT_SECONDS = float(os.getenv("SIGNAL_LIGHT_TIMEOUT", "3")) + +app = Bottle() + + +@app.hook("after_request") +def add_security_headers() -> None: + response.set_header("X-Content-Type-Options", "nosniff") + response.set_header("X-Frame-Options", "DENY") + response.set_header("Referrer-Policy", "no-referrer") + + +@app.get("/") +def index(): + return template("index", api_base_url=API_BASE_URL) + +@app.get("/favicon.ico") +def serve_favicon(): + return static_file("favicon.ico", root=os.path.join(APP_DIR, "static")) + + +@app.get("/static/") +def serve_static(filepath: str): + return static_file(filepath, root=os.path.join(APP_DIR, "static")) + + +def call_signal_api(method: str, path: str) -> Tuple[Dict[str, Any], int]: + """Call the signal-light service and normalize errors for the browser UI.""" + url = f"{API_BASE_URL}{path}" + + try: + upstream = requests.request( + method=method, + url=url, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException as exc: + return { + "ok": False, + "error": "Signal-light service is unavailable", + "detail": str(exc), + "upstream_url": url, + }, 502 + + try: + payload = upstream.json() + except ValueError: + payload = { + "ok": False, + "error": "Signal-light service returned a non-JSON response", + "detail": upstream.text[:500], + } + + if not isinstance(payload, dict): + payload = {"data": payload} + + payload.setdefault("ok", upstream.ok) + return payload, upstream.status_code + + +def proxy(method: str, path: str): + payload, status_code = call_signal_api(method, path) + response.content_type = "application/json" + response.status = status_code + return payload + + +@app.get("/ui-api/status") +def ui_status(): + return proxy("GET", "/status") + + +@app.post("/ui-api/color/") +def ui_color(color: str): + if color not in {"green", "yellow", "red"}: + raise HTTPError(400, "Unsupported color") + return proxy("POST", f"/{color}") + + +@app.post("/ui-api/alert/") +def ui_alert(state: str): + if state == "on": + return proxy("POST", "/alert") + if state == "off": + return proxy("POST", "/alert/off") + raise HTTPError(400, "Unsupported alert state") + + +@app.post("/ui-api/demo/") +def ui_demo(state: str): + if state == "on": + return proxy("POST", "/demo") + if state == "off": + return proxy("POST", "/demo/off") + raise HTTPError(400, "Unsupported demo state") + + +@app.post("/ui-api/off") +def ui_off(): + return proxy("POST", "/off") + + +if __name__ == "__main__": + host = os.getenv("BOTTLE_HOST", "0.0.0.0") + port = int(os.getenv("BOTTLE_PORT", "8080")) + debug = os.getenv("BOTTLE_DEBUG", "false").lower() == "true" + run(app=app, host=host, port=port, debug=debug, reloader=debug) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..33ccdb1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +bottle==0.13.2 +requests==2.32.3 diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..f063c9c --- /dev/null +++ b/static/app.css @@ -0,0 +1,174 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #0b0f14; + color: #f4f7fb; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(70, 120, 255, .14), transparent 32rem), + #0b0f14; +} + +button { font: inherit; } + +.shell { + width: min(980px, calc(100% - 32px)); + margin: 0 auto; + padding: 48px 0 64px; +} + +.header, .section-title, .control-card, .footer-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.header { margin-bottom: 24px; } + +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 6px; font-size: clamp(2rem, 5vw, 3.8rem); } +h2 { margin-bottom: 8px; } +p { color: #a9b4c2; line-height: 1.55; } + +.eyebrow { + margin-bottom: 8px; + color: #7ea2ff; + font-size: .76rem; + font-weight: 800; + letter-spacing: .14em; + text-transform: uppercase; +} + +.subtitle { margin-bottom: 0; } +code { color: #d7e2ff; } + +.panel { + border: 1px solid #26313d; + border-radius: 24px; + background: rgba(20, 27, 35, .88); + box-shadow: 0 24px 80px rgba(0, 0, 0, .28); + padding: 28px; +} + +.badge { + flex: 0 0 auto; + border-radius: 999px; + padding: 9px 14px; + font-size: .86rem; + font-weight: 800; +} +.badge--checking { background: #40391e; color: #f8d56b; } +.badge--online { background: #163b2a; color: #72e2a8; } +.badge--offline { background: #4a2025; color: #ff9da7; } + +.section-title strong { + border-radius: 999px; + background: #1a222d; + padding: 9px 14px; + text-transform: capitalize; +} + +.traffic-light { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 28px; +} + +.lamp { + border: 1px solid #303b47; + border-radius: 20px; + background: #111820; + color: #dfe6ef; + cursor: pointer; + padding: 24px 12px 18px; + transition: transform .16s ease, border-color .16s ease, background .16s ease; +} +.lamp:hover { transform: translateY(-2px); border-color: #627086; } +.lamp:focus-visible, .toggle:focus-visible, .danger-button:focus-visible { + outline: 3px solid #7ea2ff; + outline-offset: 3px; +} +.lamp span { + display: block; + width: clamp(76px, 13vw, 130px); + aspect-ratio: 1; + margin: 0 auto 18px; + border-radius: 50%; + background: #242c35; + box-shadow: inset 0 0 30px rgba(0,0,0,.8); + opacity: .24; +} +.lamp.is-active { background: #17202a; border-color: #738197; } +.lamp.is-active span { opacity: 1; } +.lamp--red.is-active span { background: #ff3b4f; box-shadow: 0 0 42px rgba(255,59,79,.75); } +.lamp--yellow.is-active span { background: #ffd23f; box-shadow: 0 0 42px rgba(255,210,63,.72); } +.lamp--green.is-active span { background: #31df78; box-shadow: 0 0 42px rgba(49,223,120,.72); } +.lamp:disabled, .toggle:disabled, .danger-button:disabled { cursor: wait; opacity: .62; } + +.controls-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + margin-top: 20px; +} +.control-card p { margin-bottom: 0; } + +.toggle { + min-width: 94px; + border: 0; + background: transparent; + color: #dbe4ee; + cursor: pointer; +} +.toggle__track { + display: block; + width: 66px; + height: 36px; + margin: 0 auto 8px; + border-radius: 999px; + background: #3a4552; + padding: 4px; + transition: background .18s ease; +} +.toggle__thumb { + display: block; + width: 28px; + height: 28px; + border-radius: 50%; + background: white; + transition: transform .18s ease; +} +.toggle[aria-checked="true"] .toggle__track { background: #2dca70; } +.toggle[aria-checked="true"] .toggle__thumb { transform: translateX(30px); } + +.footer-actions { margin-top: 20px; } +.danger-button { + border: 1px solid #a8404c; + border-radius: 14px; + background: #511e25; + color: #ffdfe2; + cursor: pointer; + font-weight: 800; + padding: 13px 18px; +} +.message { margin: 0; text-align: right; } +.message.is-error { color: #ff9da7; } + +@media (max-width: 700px) { + .shell { padding-top: 24px; } + .header, .section-title, .control-card, .footer-actions { align-items: flex-start; } + .header, .control-card, .footer-actions { flex-direction: column; } + .controls-grid { grid-template-columns: 1fr; } + .traffic-light { gap: 10px; } + .panel { padding: 20px; border-radius: 18px; } + .lamp { padding-inline: 6px; } + .message { text-align: left; } +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..992bfdf --- /dev/null +++ b/static/app.js @@ -0,0 +1,137 @@ +const state = { + activeColor: null, + alertActive: false, + demoActive: false, + busy: false, +}; + +const lamps = [...document.querySelectorAll(".lamp")]; +const alertToggle = document.getElementById("alertToggle"); +const demoToggle = document.getElementById("demoToggle"); +const allOffButton = document.getElementById("allOffButton"); +const activeColorText = document.getElementById("activeColorText"); +const alertToggleLabel = document.getElementById("alertToggleLabel"); +const demoToggleLabel = document.getElementById("demoToggleLabel"); +const connectionBadge = document.getElementById("connectionBadge"); +const message = document.getElementById("message"); + +function setMessage(text, isError = false) { + message.textContent = text; + message.classList.toggle("is-error", isError); +} + +function setConnection(online) { + connectionBadge.textContent = online ? "API online" : "API offline"; + connectionBadge.className = `badge ${online ? "badge--online" : "badge--offline"}`; +} + +function render() { + lamps.forEach((lamp) => { + const active = !state.demoActive && lamp.dataset.color === state.activeColor; + lamp.classList.toggle("is-active", active); + lamp.setAttribute("aria-pressed", String(active)); + lamp.disabled = state.busy; + }); + + activeColorText.textContent = state.demoActive + ? "Demo running" + : (state.activeColor || "Off"); + + alertToggle.setAttribute("aria-checked", String(state.alertActive)); + alertToggleLabel.textContent = state.alertActive ? "On" : "Off"; + alertToggle.disabled = state.busy; + + demoToggle.setAttribute("aria-checked", String(state.demoActive)); + demoToggleLabel.textContent = state.demoActive ? "On" : "Off"; + demoToggle.disabled = state.busy; + + allOffButton.disabled = state.busy; +} + +function applyApiState(payload) { + if (Object.hasOwn(payload, "active_color")) state.activeColor = payload.active_color; + if (Object.hasOwn(payload, "alert_active")) state.alertActive = Boolean(payload.alert_active); + if (Object.hasOwn(payload, "demo_active")) state.demoActive = Boolean(payload.demo_active); +} + +async function apiCall(path, options = {}) { + const result = await fetch(path, { + method: options.method || "GET", + headers: { "Accept": "application/json" }, + }); + const payload = await result.json().catch(() => ({})); + if (!result.ok) { + throw new Error(payload.error || payload.detail || `Request failed (${result.status})`); + } + return payload; +} + +async function runAction(action, successMessage) { + if (state.busy) return; + state.busy = true; + render(); + setMessage("Sending command…"); + + try { + const payload = await action(); + applyApiState(payload); + setConnection(true); + setMessage(successMessage); + } catch (error) { + setConnection(false); + setMessage(error.message, true); + } finally { + state.busy = false; + render(); + } +} + +lamps.forEach((lamp) => { + lamp.addEventListener("click", () => { + const color = lamp.dataset.color; + runAction( + () => apiCall(`/ui-api/color/${color}`, { method: "POST" }), + `${color[0].toUpperCase()}${color.slice(1)} light activated.`, + ); + }); +}); + +alertToggle.addEventListener("click", () => { + const nextState = state.alertActive ? "off" : "on"; + runAction( + () => apiCall(`/ui-api/alert/${nextState}`, { method: "POST" }), + `Alert turned ${nextState}.`, + ); +}); + +demoToggle.addEventListener("click", () => { + const nextState = state.demoActive ? "off" : "on"; + runAction( + () => apiCall(`/ui-api/demo/${nextState}`, { method: "POST" }), + `Demo mode turned ${nextState}.`, + ); +}); + +allOffButton.addEventListener("click", () => { + runAction( + () => apiCall("/ui-api/off", { method: "POST" }), + "All outputs turned off.", + ); +}); + +async function refreshStatus({ quiet = false } = {}) { + if (state.busy) return; + try { + const payload = await apiCall("/ui-api/status"); + applyApiState(payload); + setConnection(true); + if (!quiet) setMessage("Status synchronized."); + render(); + } catch (error) { + setConnection(false); + if (!quiet) setMessage(error.message, true); + } +} + +refreshStatus(); +setInterval(() => refreshStatus({ quiet: true }), 1500); diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..3401479 Binary files /dev/null and b/static/favicon.ico differ diff --git a/systemd/signal-light-ui.service b/systemd/signal-light-ui.service new file mode 100644 index 0000000..e20beb5 --- /dev/null +++ b/systemd/signal-light-ui.service @@ -0,0 +1,22 @@ +# /etc/systemd/system/signal-light-ui.service + +[Unit] +Description=Signal Light Bottle UI +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=pi +WorkingDirectory=/opt/signal-light-ui + +# REST API endpoint +Environment=SIGNAL_LIGHT_API_URL=http://127.0.0.1:8000 + +ExecStart=/home/pi/signal_light_ui/.venv/bin/python app.py + +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/templates/index.tpl b/templates/index.tpl new file mode 100644 index 0000000..97b088a --- /dev/null +++ b/templates/index.tpl @@ -0,0 +1,76 @@ + + + + + + Signal Light Control + + + +
+
+
+

Raspberry Pi Control Panel

+

Signal Light Controller

+

REST API: {{api_base_url}}

+
+
Checking…
+
+ +
+
+
+

Light color

+

Only one color can be active at a time.

+
+ Off +
+ +
+ + + +
+
+ +
+
+
+

Independent output

+

Alert buzzer

+

The buzzer blinks independently of the selected light.

+
+ +
+ +
+
+

Automatic sequence

+

Demo mode

+

Cycles green → yellow → red → yellow.

+
+ +
+
+ + +
+ + + +