diff --git a/CLAUDE.md b/CLAUDE.md index af02905..c2cfe03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,8 @@ All detectors support: `AGGREGATOR_URL`, `CHECK_INTERVAL`, `THRESHOLD_WARNING`, | `/notify` | POST | Notification with optional customization (see below) | | `/sleep` | POST | Enter sleep mode | | `/wake` | POST | Exit sleep mode | -| `/status` | GET | Current state JSON | +| `/stream` | GET | SSE stream — pushes state JSON on every change (used by the frontend) | +| `/status` | GET | Current state JSON (one-shot query) | | `/events` | GET | List active events | | `/docs` | GET | Interactive API documentation (Swagger UI) | diff --git a/README.md b/README.md index 2d45b43..805770b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Turn an old phone (with its OLED screen) into a glanceable ambient display for y - **OLED-optimized** — Pure black background, saves battery - **Glanceable** — Know your server's status from across the room +- **Instant updates** — SSE stream pushes state changes the moment they happen - **Extensible** — Add custom detectors for any metric - **Personality** — Rotating expressions, celebration animations, sleep mode - **Sound effects** — Optional audio cues for state changes (tap to enable) @@ -215,18 +216,19 @@ Navigate with `↑↓` or `Tab`, press `Enter` to fire, `Q` to quit. A toast con ## API Reference -| Endpoint | Method | Description | -| --------- | ------ | ------------------------------------------------ | -| `/` | GET | Web UI | -| `/status` | GET | Current state as JSON | -| `/events` | GET | List all active events | -| `/event` | POST | Register an event | -| `/clear` | POST | Clear an event by ID | -| `/clear-all` | POST | Clear all active events | -| `/notify` | POST | Simple notification `{"message": "", "duration": 5}` | -| `/sleep` | POST | Enter sleep mode | -| `/wake` | POST | Exit sleep mode | -| `/docs` | GET | Interactive API documentation (Swagger UI) | +| Endpoint | Method | Description | +| ------------ | ------ | ----------------------------------------------------- | +| `/` | GET | Web UI | +| `/stream` | GET | SSE stream — pushes state JSON on every change | +| `/status` | GET | Current state as JSON (one-shot query) | +| `/events` | GET | List all active events | +| `/event` | POST | Register an event | +| `/clear` | POST | Clear an event by ID | +| `/clear-all` | POST | Clear all active events | +| `/notify` | POST | Simple notification `{"message": "", "duration": 5}` | +| `/sleep` | POST | Enter sleep mode | +| `/wake` | POST | Exit sleep mode | +| `/docs` | GET | Interactive API documentation (Swagger UI) | Full API documentation available at [/docs](http://localhost:5100/docs) or in [openapi.yaml](openapi.yaml). diff --git a/TODO.md b/TODO.md index 7d66d51..f6674c7 100644 --- a/TODO.md +++ b/TODO.md @@ -4,8 +4,6 @@ Feature ideas for future work, roughly in priority order. ## REST API improvements -- **`/status` SSE stream** — replace frontend polling with a Server-Sent Events - endpoint so the display reacts instantly and the 2s polling overhead disappears - **Notification queue** — buffer rapid `/notify` calls and auto-advance through them instead of clobbering; important when HA fires several events at once - **Sticky notifications** — a `sticky: true` flag on `/notify` to keep a diff --git a/aggregator.py b/aggregator.py index 86c9281..18483f2 100644 --- a/aggregator.py +++ b/aggregator.py @@ -5,12 +5,13 @@ A lightweight event broker that manages priority-based system status. import json import os +import queue import random import threading import time from datetime import datetime from pathlib import Path -from flask import Flask, request, jsonify, send_from_directory +from flask import Flask, request, jsonify, send_from_directory, Response, stream_with_context app = Flask(__name__, static_folder=".") ROOT_DIR = Path(__file__).parent @@ -60,6 +61,23 @@ current_optimal_animation = OPTIMAL_EMOTES[0][1] # Notify counter for unique IDs _notify_counter = 0 +# SSE subscribers: one queue per connected client +_subscribers: set = set() +_subscribers_lock = threading.Lock() + + +def broadcast(state_json: str): + """Push state JSON to all connected SSE clients.""" + with _subscribers_lock: + dead = [] + for q in _subscribers: + try: + q.put_nowait(state_json) + except queue.Full: + dead.append(q) + for q in dead: + _subscribers.discard(q) + # Sleep mode is_sleeping = False SLEEP_EMOTE = "( -_-)zzZ" @@ -156,13 +174,14 @@ def get_current_state(): def write_status(): - """Write current state to status.json.""" + """Write current state to status.json and push to SSE subscribers.""" state = get_current_state() try: with open(STATUS_FILE, "w") as f: json.dump(state, f, indent="\t") except OSError as e: print(f"[ERROR] Failed to write status file: {e}") + broadcast(json.dumps(state)) return state @@ -351,6 +370,38 @@ def list_events(): return jsonify({"events": dict(active_events)}), 200 +@app.route("/stream") +def stream(): + """Server-Sent Events stream. Pushes state JSON on every change.""" + q = queue.Queue(maxsize=10) + with _subscribers_lock: + _subscribers.add(q) + + def generate(): + try: + # Send current state immediately on connect + yield f"data: {json.dumps(get_current_state())}\n\n" + while True: + try: + data = q.get(timeout=30) + yield f"data: {data}\n\n" + except queue.Empty: + # Keepalive comment to prevent proxy timeouts + yield ": keepalive\n\n" + finally: + with _subscribers_lock: + _subscribers.discard(q) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", # Disable nginx buffering if proxied + }, + ) + + @app.route("/docs") def docs(): """Serve interactive API documentation via Swagger UI.""" diff --git a/index.html b/index.html index 823c7f3..517c029 100644 --- a/index.html +++ b/index.html @@ -216,8 +216,7 @@ diff --git a/kao.py b/kao.py index e99a8db..e692d5b 100644 --- a/kao.py +++ b/kao.py @@ -47,6 +47,8 @@ class KaoManager: # Build environment proc_env = os.environ.copy() + # Ensure the project root is on PYTHONPATH so detectors can import each other + proc_env["PYTHONPATH"] = str(self.base_dir) + os.pathsep + proc_env.get("PYTHONPATH", "") if env: proc_env.update(env) diff --git a/openapi.yaml b/openapi.yaml index 8066b9d..cceb092 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -14,7 +14,7 @@ info: ## TTL/Heartbeat Pattern Events can have a TTL (time-to-live) that auto-expires them. Detectors typically send heartbeat events that expire if not refreshed, indicating loss of communication. - version: 2.2.0 + version: 2.3.0 license: name: MIT @@ -183,12 +183,41 @@ paths: schema: $ref: "#/components/schemas/WakeResponse" + /stream: + get: + summary: SSE stream of state updates + description: | + Server-Sent Events stream that pushes the current state as JSON whenever + it changes. The frontend connects here instead of polling `/status`. + + Each event is a `data:` line containing a JSON-encoded Status object, + followed by a blank line. A `: keepalive` comment is sent every 30 + seconds to prevent proxy timeouts. The current state is pushed + immediately on connection. + + The browser's `EventSource` API handles automatic reconnection if the + connection drops. + operationId: getStream + responses: + "200": + description: Event stream + content: + text/event-stream: + schema: + type: string + description: | + Newline-delimited SSE events. `data:` lines contain a + JSON-encoded Status object. Lines beginning with `:` are + keepalive comments and can be ignored. + example: "data: {\"current_state\": \"optimal\", ...}\n\n" + /status: get: summary: Get current status description: | Returns the current display state including the active emote, color, - animation, and list of active events. The frontend polls this endpoint. + animation, and list of active events. Prefer `/stream` for live + displays; use this endpoint for one-shot queries. operationId: getStatus responses: "200":