From 40260afeeb2b116eb9b6b84a3a93d1475f39e1c5 Mon Sep 17 00:00:00 2001 From: timo Date: Sat, 1 Aug 2026 12:07:22 +0200 Subject: [PATCH] initial commit --- README.md | 34 +++++++ app.py | 117 +++++++++++++++++++++ requirements.txt | 2 + static/app.css | 174 ++++++++++++++++++++++++++++++++ static/app.js | 137 +++++++++++++++++++++++++ static/favicon.ico | Bin 0 -> 15406 bytes systemd/signal-light-ui.service | 22 ++++ templates/index.tpl | 76 ++++++++++++++ 8 files changed, 562 insertions(+) create mode 100644 README.md create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/app.css create mode 100644 static/app.js create mode 100644 static/favicon.ico create mode 100644 systemd/signal-light-ui.service create mode 100644 templates/index.tpl 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 0000000000000000000000000000000000000000..3401479f722ca9fe8c19c5c945d84cf6d5f88d6b GIT binary patch literal 15406 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 literal 0 HcmV?d00001 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.

+
+ +
+
+ + +
+ + + +