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:
@@ -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) |
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
24
index.html
24
index.html
@@ -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
2
kao.py
@@ -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)
|
||||||
|
|
||||||
|
|||||||
33
openapi.yaml
33
openapi.yaml
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user