Bump to v2.3.0: replace polling with SSE stream, fix detector imports

- Add GET /stream SSE endpoint to aggregator.py; state is pushed
  instantly on every change instead of fetched every 2s
- Replace setInterval polling in index.html with EventSource;
  onerror shows the ( ?.?) face and auto-reconnect is handled by
  the browser natively
- Fix ModuleNotFoundError in detectors: inject project root into
  PYTHONPATH when launching subprocesses from kao.py
- Update openapi.yaml, CLAUDE.md, README.md with /stream endpoint
- Remove completed SSE item from TODO.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 17:25:15 -06:00
parent 9291066263
commit aaae20281d
7 changed files with 114 additions and 33 deletions

View File

@@ -87,7 +87,8 @@ All detectors support: `AGGREGATOR_URL`, `CHECK_INTERVAL`, `THRESHOLD_WARNING`,
| `/notify` | POST | Notification with optional customization (see below) | | `/notify` | POST | Notification with optional customization (see below) |
| `/sleep` | POST | Enter sleep mode | | `/sleep` | POST | Enter sleep mode |
| `/wake` | POST | Exit 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 | | `/events` | GET | List active events |
| `/docs` | GET | Interactive API documentation (Swagger UI) | | `/docs` | GET | Interactive API documentation (Swagger UI) |

View File

@@ -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 - **OLED-optimized** — Pure black background, saves battery
- **Glanceable** — Know your server's status from across the room - **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 - **Extensible** — Add custom detectors for any metric
- **Personality** — Rotating expressions, celebration animations, sleep mode - **Personality** — Rotating expressions, celebration animations, sleep mode
- **Sound effects** — Optional audio cues for state changes (tap to enable) - **Sound effects** — Optional audio cues for state changes (tap to enable)
@@ -216,9 +217,10 @@ Navigate with `↑↓` or `Tab`, press `Enter` to fire, `Q` to quit. A toast con
## API Reference ## API Reference
| Endpoint | Method | Description | | Endpoint | Method | Description |
| --------- | ------ | ------------------------------------------------ | | ------------ | ------ | ----------------------------------------------------- |
| `/` | GET | Web UI | | `/` | GET | Web UI |
| `/status` | GET | Current state as JSON | | `/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 | | `/events` | GET | List all active events |
| `/event` | POST | Register an event | | `/event` | POST | Register an event |
| `/clear` | POST | Clear an event by ID | | `/clear` | POST | Clear an event by ID |

View File

@@ -4,8 +4,6 @@ Feature ideas for future work, roughly in priority order.
## REST API improvements ## 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 - **Notification queue** — buffer rapid `/notify` calls and auto-advance through
them instead of clobbering; important when HA fires several events at once them instead of clobbering; important when HA fires several events at once
- **Sticky notifications** — a `sticky: true` flag on `/notify` to keep a - **Sticky notifications** — a `sticky: true` flag on `/notify` to keep a

View File

@@ -5,12 +5,13 @@ A lightweight event broker that manages priority-based system status.
import json import json
import os import os
import queue
import random import random
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path 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=".") app = Flask(__name__, static_folder=".")
ROOT_DIR = Path(__file__).parent ROOT_DIR = Path(__file__).parent
@@ -60,6 +61,23 @@ current_optimal_animation = OPTIMAL_EMOTES[0][1]
# Notify counter for unique IDs # Notify counter for unique IDs
_notify_counter = 0 _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 # Sleep mode
is_sleeping = False is_sleeping = False
SLEEP_EMOTE = "( -_-)zzZ" SLEEP_EMOTE = "( -_-)zzZ"
@@ -156,13 +174,14 @@ def get_current_state():
def write_status(): def write_status():
"""Write current state to status.json.""" """Write current state to status.json and push to SSE subscribers."""
state = get_current_state() state = get_current_state()
try: try:
with open(STATUS_FILE, "w") as f: with open(STATUS_FILE, "w") as f:
json.dump(state, f, indent="\t") json.dump(state, f, indent="\t")
except OSError as e: except OSError as e:
print(f"[ERROR] Failed to write status file: {e}") print(f"[ERROR] Failed to write status file: {e}")
broadcast(json.dumps(state))
return state return state
@@ -351,6 +370,38 @@ def list_events():
return jsonify({"events": dict(active_events)}), 200 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") @app.route("/docs")
def docs(): def docs():
"""Serve interactive API documentation via Swagger UI.""" """Serve interactive API documentation via Swagger UI."""

View File

@@ -216,8 +216,7 @@
<script> <script>
const emoteEl = document.getElementById("emote"); const emoteEl = document.getElementById("emote");
const messageEl = document.getElementById("message"); const messageEl = document.getElementById("message");
const POLL_INTERVAL = 2000; const VERSION = "v2.3.0";
const VERSION = "v2.2.0";
// Sound system // Sound system
let audioCtx = null; let audioCtx = null;
@@ -501,20 +500,21 @@
document.addEventListener("DOMContentLoaded", initAudio); document.addEventListener("DOMContentLoaded", initAudio);
} }
async function fetchStatus() { function connectStream() {
const es = new EventSource("/stream");
es.onmessage = (e) => {
try { try {
const response = await fetch("/status"); updateDisplay(JSON.parse(e.data));
if (!response.ok) throw new Error("Failed to fetch"); } catch (_) {}
const data = await response.json(); };
updateDisplay(data); es.onerror = () => {
} catch (err) { // Connection lost — EventSource will auto-reconnect
// Connection lost state
emoteEl.textContent = "( ?.?)"; emoteEl.textContent = "( ?.?)";
emoteEl.style.color = "#888888"; emoteEl.style.color = "#888888";
emoteEl.className = "searching"; emoteEl.className = "searching";
messageEl.style.color = "#888888"; messageEl.style.color = "#888888";
messageEl.textContent = ""; messageEl.textContent = "";
} };
} }
function updateDisplay(data) { function updateDisplay(data) {
@@ -541,9 +541,7 @@
} }
} }
// Initial fetch and start polling connectStream();
fetchStatus();
setInterval(fetchStatus, POLL_INTERVAL);
</script> </script>
</body> </body>
</html> </html>

2
kao.py
View File

@@ -47,6 +47,8 @@ class KaoManager:
# Build environment # Build environment
proc_env = os.environ.copy() 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: if env:
proc_env.update(env) proc_env.update(env)

View File

@@ -14,7 +14,7 @@ info:
## TTL/Heartbeat Pattern ## TTL/Heartbeat Pattern
Events can have a TTL (time-to-live) that auto-expires them. Detectors typically send 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. heartbeat events that expire if not refreshed, indicating loss of communication.
version: 2.2.0 version: 2.3.0
license: license:
name: MIT name: MIT
@@ -183,12 +183,41 @@ paths:
schema: schema:
$ref: "#/components/schemas/WakeResponse" $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: /status:
get: get:
summary: Get current status summary: Get current status
description: | description: |
Returns the current display state including the active emote, color, 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 operationId: getStatus
responses: responses:
"200": "200":