feat: web dashboard for live device data
Small companion service that receives SNEK callbacks, decodes Sens'it Discovery payloads for all 6 modes (Standby, Temperature, Light, Door, Vibration, Magnet), stores in SQLite, and serves a responsive dashboard. Backend (Python Flask): - POST /webhook: receive SNEK callback - GET /api/devices, /api/messages, /api/decode - GET /events: Server-Sent Events for live push - SQLite persistence at /data/messages.db Frontend: - Tailwind CSS via CDN, Chart.js for temp/humidity graph - Live updates via SSE - Device cards with icons per mode - Message log with human-readable summaries Deploy: - Separate deployment (snek-dashboard) with own PVC - LoadBalancer on 192.168.1.213:80 - SNEK callback URL: http://snek-dashboard.snek.svc.cluster.local/webhook Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""SNEK Dashboard — Flask backend receiving SNEK callbacks."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, request, send_from_directory, stream_with_context, Response
|
||||
|
||||
from sensit_decoder import decode as sensit_decode
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/messages.db")
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = Flask(__name__, static_folder="static", static_url_path="/static")
|
||||
|
||||
# --- SSE broadcast ---
|
||||
_subscribers: list[queue.Queue] = []
|
||||
_subscribers_lock = threading.Lock()
|
||||
|
||||
|
||||
def _broadcast(event: dict) -> None:
|
||||
payload = json.dumps(event)
|
||||
with _subscribers_lock:
|
||||
dead = []
|
||||
for q in _subscribers:
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except queue.Full:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
_subscribers.remove(q)
|
||||
|
||||
|
||||
# --- Database ---
|
||||
def _init_db() -> None:
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts REAL NOT NULL,
|
||||
device TEXT NOT NULL,
|
||||
raw TEXT NOT NULL,
|
||||
decoded TEXT NOT NULL,
|
||||
rssi REAL,
|
||||
snr REAL,
|
||||
seq_number INTEGER
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_device_ts ON messages(device, ts DESC)")
|
||||
conn.commit()
|
||||
|
||||
|
||||
_init_db()
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
@app.route("/")
|
||||
def index():
|
||||
return send_from_directory("static", "index.html")
|
||||
|
||||
|
||||
@app.route("/webhook", methods=["POST"])
|
||||
def webhook():
|
||||
"""
|
||||
SNEK callback endpoint.
|
||||
SNEK sends JSON with: device, time, data, rssi, snr, seqNumber, etc.
|
||||
"""
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
device = (body.get("device") or "").lower()
|
||||
raw = (body.get("data") or "").lower()
|
||||
ts = float(body.get("time") or time.time())
|
||||
rssi = body.get("rssi")
|
||||
snr = body.get("snr")
|
||||
seq_number = body.get("seqNumber")
|
||||
|
||||
if not device or not raw:
|
||||
return jsonify({"error": "missing device or data"}), 400
|
||||
|
||||
decoded = sensit_decode(raw)
|
||||
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO messages (ts, device, raw, decoded, rssi, snr, seq_number) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(ts, device, raw, json.dumps(decoded), rssi, snr, seq_number),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
_broadcast({
|
||||
"type": "message",
|
||||
"ts": ts,
|
||||
"device": device,
|
||||
"raw": raw,
|
||||
"decoded": decoded,
|
||||
"rssi": rssi,
|
||||
"snr": snr,
|
||||
"seqNumber": seq_number,
|
||||
})
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.route("/api/devices")
|
||||
def api_devices():
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
cur = conn.execute(
|
||||
"SELECT device, COUNT(*) c, MAX(ts) last_ts FROM messages GROUP BY device ORDER BY last_ts DESC"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return jsonify([{"device": r[0], "count": r[1], "last_ts": r[2]} for r in rows])
|
||||
|
||||
|
||||
@app.route("/api/messages")
|
||||
def api_messages():
|
||||
device = (request.args.get("device") or "").lower()
|
||||
limit = int(request.args.get("limit") or 100)
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
if device:
|
||||
cur = conn.execute(
|
||||
"SELECT ts, device, raw, decoded, rssi, snr, seq_number FROM messages "
|
||||
"WHERE device = ? ORDER BY ts DESC LIMIT ?",
|
||||
(device, limit),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"SELECT ts, device, raw, decoded, rssi, snr, seq_number FROM messages "
|
||||
"ORDER BY ts DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return jsonify([
|
||||
{
|
||||
"ts": r[0],
|
||||
"device": r[1],
|
||||
"raw": r[2],
|
||||
"decoded": json.loads(r[3]),
|
||||
"rssi": r[4],
|
||||
"snr": r[5],
|
||||
"seqNumber": r[6],
|
||||
}
|
||||
for r in rows
|
||||
])
|
||||
|
||||
|
||||
@app.route("/api/decode")
|
||||
def api_decode():
|
||||
"""Manual decode utility: /api/decode?hex=b60dc86e"""
|
||||
h = request.args.get("hex", "")
|
||||
return jsonify(sensit_decode(h))
|
||||
|
||||
|
||||
@app.route("/events")
|
||||
def events():
|
||||
"""SSE stream of live messages."""
|
||||
def stream():
|
||||
q: queue.Queue = queue.Queue(maxsize=100)
|
||||
with _subscribers_lock:
|
||||
_subscribers.append(q)
|
||||
try:
|
||||
yield "event: ping\ndata: connected\n\n"
|
||||
while True:
|
||||
try:
|
||||
payload = q.get(timeout=25)
|
||||
yield f"data: {payload}\n\n"
|
||||
except queue.Empty:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
with _subscribers_lock:
|
||||
if q in _subscribers:
|
||||
_subscribers.remove(q)
|
||||
|
||||
return Response(stream_with_context(stream()), mimetype="text/event-stream")
|
||||
|
||||
|
||||
@app.route("/healthz")
|
||||
def healthz():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=3000)
|
||||
Reference in New Issue
Block a user