Files
snek/dashboard/app.py
T
tarcourt a77776d6d8 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>
2026-07-03 17:43:11 +02:00

185 lines
5.0 KiB
Python

"""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)