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,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py sensit_decoder.py ./
|
||||
COPY static/ ./static/
|
||||
|
||||
ENV DB_PATH=/data/messages.db
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
flask==3.0.3
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Sens'it Discovery payload decoder — all 6 modes."""
|
||||
|
||||
MODES = {
|
||||
0: "Standby / Button",
|
||||
1: "Temperature",
|
||||
2: "Light",
|
||||
3: "Door",
|
||||
4: "Vibration",
|
||||
5: "Magnet",
|
||||
}
|
||||
|
||||
|
||||
def decode(payload_hex: str) -> dict:
|
||||
"""
|
||||
Decode a 4-byte Sens'it Discovery payload.
|
||||
|
||||
Format (per Sens'it Discovery v3.1.0+ spec):
|
||||
Byte 0: bits 7-3 = battery level, bits 2-0 = reserved (0b110)
|
||||
Byte 1: bits 7-3 = mode, bits 2-1 = mode-specific / spare, bit 0 = button flag
|
||||
Byte 2-3: mode-specific data
|
||||
|
||||
Battery formula: V = (raw × 0.05) + 2.7
|
||||
"""
|
||||
payload_hex = payload_hex.strip().lower().replace("0x", "").replace(" ", "")
|
||||
if len(payload_hex) != 8:
|
||||
return {"error": f"payload must be 4 bytes (8 hex chars), got '{payload_hex}'"}
|
||||
|
||||
try:
|
||||
b0, b1, b2, b3 = (int(payload_hex[i : i + 2], 16) for i in range(0, 8, 2))
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# Common fields
|
||||
battery_raw = (b0 >> 3) & 0x1F
|
||||
battery_volts = round(battery_raw * 0.05 + 2.7, 2)
|
||||
reserved = b0 & 0x07
|
||||
mode = (b1 >> 3) & 0x1F
|
||||
button_alert = bool(b1 & 0x01)
|
||||
|
||||
result = {
|
||||
"raw": payload_hex,
|
||||
"battery": battery_volts,
|
||||
"battery_raw": battery_raw,
|
||||
"reserved_ok": reserved == 0b110,
|
||||
"mode": MODES.get(mode, f"Unknown ({mode})"),
|
||||
"mode_id": mode,
|
||||
"button_alert": button_alert,
|
||||
}
|
||||
|
||||
# Mode-specific decoding
|
||||
if mode == 1: # Temperature
|
||||
temp_msb = (b1 >> 2) & 0x01
|
||||
temp_raw = (temp_msb << 8) | b2
|
||||
result["temperature_c"] = round((temp_raw - 200) / 8, 2)
|
||||
result["humidity_pct"] = round(b3 / 2, 1)
|
||||
|
||||
elif mode == 2: # Light
|
||||
brightness_msb = (b1 >> 2) & 0x01
|
||||
brightness_raw = (brightness_msb << 8) | b2
|
||||
result["brightness_lux"] = round(brightness_raw / 96, 2)
|
||||
|
||||
elif mode == 3: # Door
|
||||
door_status_bits = (b1 >> 1) & 0x03
|
||||
door_map = {0: "closed", 1: "opened", 2: "opening_alert", 3: "closing_alert"}
|
||||
result["door_status"] = door_map.get(door_status_bits, "unknown")
|
||||
result["event_count"] = (b2 << 8) | b3
|
||||
|
||||
elif mode == 4: # Vibration
|
||||
vib_status_bits = (b1 >> 1) & 0x03
|
||||
vib_map = {0: "no_vibration", 1: "vibration_detected"}
|
||||
result["vibration_status"] = vib_map.get(vib_status_bits, "unknown")
|
||||
result["event_count"] = (b2 << 8) | b3
|
||||
|
||||
elif mode == 5: # Magnet
|
||||
mag_status_bits = (b1 >> 1) & 0x03
|
||||
mag_map = {0: "no_magnet", 1: "magnet_detected"}
|
||||
result["magnet_status"] = mag_map.get(mag_status_bits, "unknown")
|
||||
result["event_count"] = (b2 << 8) | b3
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Sanity checks with the payloads we captured
|
||||
for h in ("b60dc86e", "a60dc271", "b6043081"):
|
||||
print(h, "→", decode(h))
|
||||
@@ -0,0 +1,210 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>SNEK Dashboard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
html,body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
.fade-in { animation: fade 0.4s ease-out; }
|
||||
@keyframes fade { from { opacity:0; transform: translateY(4px); } to { opacity:1; transform: translateY(0); } }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
||||
<div class="max-w-6xl mx-auto p-4 md:p-8">
|
||||
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl md:text-3xl font-bold">🛰️ SNEK Dashboard</h1>
|
||||
<p class="text-slate-400 text-sm">Sens'it Discovery — live decoding</p>
|
||||
</div>
|
||||
<div id="conn-badge" class="text-xs px-3 py-1 rounded-full bg-slate-800 text-slate-400">connecting…</div>
|
||||
</header>
|
||||
|
||||
<!-- Devices -->
|
||||
<section class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8" id="devices"></section>
|
||||
|
||||
<!-- Chart -->
|
||||
<section class="bg-slate-900 rounded-2xl p-4 md:p-6 mb-8 border border-slate-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">Historique</h2>
|
||||
<select id="chart-device" class="bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-sm"></select>
|
||||
</div>
|
||||
<canvas id="chart" height="120"></canvas>
|
||||
</section>
|
||||
|
||||
<!-- Messages log -->
|
||||
<section class="bg-slate-900 rounded-2xl p-4 md:p-6 border border-slate-800">
|
||||
<h2 class="text-lg font-semibold mb-4">Messages</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-slate-400 text-left">
|
||||
<tr>
|
||||
<th class="pb-2 pr-3">Time</th>
|
||||
<th class="pb-2 pr-3">Device</th>
|
||||
<th class="pb-2 pr-3">Mode</th>
|
||||
<th class="pb-2 pr-3">Data</th>
|
||||
<th class="pb-2 pr-3">RSSI</th>
|
||||
<th class="pb-2 pr-3">Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="msgs" class="divide-y divide-slate-800"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const state = { messagesByDevice: {}, chart: null, selectedDevice: null };
|
||||
|
||||
const fmtTime = ts => new Date(ts * 1000).toLocaleString('fr-FR');
|
||||
const fmtShortTime = ts => new Date(ts * 1000).toLocaleTimeString('fr-FR');
|
||||
|
||||
const modeIcon = mode => ({
|
||||
'Temperature': '🌡️',
|
||||
'Light': '💡',
|
||||
'Door': '🚪',
|
||||
'Vibration': '📳',
|
||||
'Magnet': '🧲',
|
||||
'Standby / Button': '⏸️',
|
||||
}[mode] || '❓');
|
||||
|
||||
function summaryLine(d) {
|
||||
if (!d) return '-';
|
||||
const parts = [];
|
||||
if ('temperature_c' in d) parts.push(`${d.temperature_c}°C`);
|
||||
if ('humidity_pct' in d) parts.push(`${d.humidity_pct}%`);
|
||||
if ('brightness_lux' in d) parts.push(`${d.brightness_lux} lux`);
|
||||
if ('door_status' in d) parts.push(`porte: ${d.door_status}`);
|
||||
if ('vibration_status' in d) parts.push(`vibration: ${d.vibration_status}`);
|
||||
if ('magnet_status' in d) parts.push(`aimant: ${d.magnet_status}`);
|
||||
if ('event_count' in d && d.mode_id > 2) parts.push(`events: ${d.event_count}`);
|
||||
if (parts.length === 0) parts.push('heartbeat');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function renderDevices() {
|
||||
const el = document.getElementById('devices');
|
||||
el.innerHTML = '';
|
||||
const sel = document.getElementById('chart-device');
|
||||
const currentSel = sel.value;
|
||||
sel.innerHTML = '';
|
||||
Object.entries(state.messagesByDevice).forEach(([dev, msgs]) => {
|
||||
const last = msgs[0];
|
||||
const d = last.decoded || {};
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-slate-900 rounded-2xl p-4 border border-slate-800 fade-in';
|
||||
card.innerHTML = `
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<div class="text-xs text-slate-500">${dev.toUpperCase()}</div>
|
||||
<div class="text-sm font-semibold">${modeIcon(d.mode)} ${d.mode || 'Unknown'}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-slate-500">Battery</div>
|
||||
<div class="text-sm font-mono">${d.battery ?? '?'} V</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-blue-400 mb-1">${summaryLine(d)}</div>
|
||||
<div class="text-xs text-slate-500">${fmtTime(last.ts)}</div>
|
||||
${last.rssi != null ? `<div class="text-xs text-slate-500 mt-1">RSSI ${last.rssi.toFixed?.(1) ?? last.rssi} dBm</div>` : ''}
|
||||
`;
|
||||
el.appendChild(card);
|
||||
|
||||
const opt = document.createElement('option');
|
||||
opt.value = dev; opt.textContent = dev.toUpperCase();
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (currentSel && state.messagesByDevice[currentSel]) sel.value = currentSel;
|
||||
if (!state.selectedDevice && sel.options.length) state.selectedDevice = sel.value;
|
||||
renderChart();
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
const el = document.getElementById('msgs');
|
||||
const all = Object.values(state.messagesByDevice).flat()
|
||||
.sort((a,b) => b.ts - a.ts).slice(0, 50);
|
||||
el.innerHTML = all.map(m => `
|
||||
<tr class="hover:bg-slate-800/50 fade-in">
|
||||
<td class="py-2 pr-3 text-slate-400">${fmtShortTime(m.ts)}</td>
|
||||
<td class="py-2 pr-3 font-mono text-blue-400">${m.device.toUpperCase()}</td>
|
||||
<td class="py-2 pr-3">${modeIcon(m.decoded?.mode)} ${m.decoded?.mode || '?'}</td>
|
||||
<td class="py-2 pr-3 text-slate-300">${summaryLine(m.decoded)}</td>
|
||||
<td class="py-2 pr-3 text-slate-500">${m.rssi?.toFixed?.(0) ?? '-'}</td>
|
||||
<td class="py-2 pr-3 text-slate-500 font-mono text-xs">${m.raw}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
const dev = state.selectedDevice || document.getElementById('chart-device').value;
|
||||
if (!dev) return;
|
||||
const msgs = (state.messagesByDevice[dev] || []).slice().reverse();
|
||||
const labels = msgs.map(m => fmtShortTime(m.ts));
|
||||
const temps = msgs.map(m => m.decoded?.temperature_c ?? null);
|
||||
const humi = msgs.map(m => m.decoded?.humidity_pct ?? null);
|
||||
|
||||
const ctx = document.getElementById('chart').getContext('2d');
|
||||
if (state.chart) state.chart.destroy();
|
||||
state.chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'Temperature °C', data: temps, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.15)', yAxisID: 'y', tension: 0.3, fill: true, spanGaps: true },
|
||||
{ label: 'Humidity %', data: humi, borderColor: '#34d399', backgroundColor: 'rgba(52,211,153,0.15)', yAxisID: 'y1', tension: 0.3, fill: true, spanGaps: true },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { labels: { color: '#e2e8f0' } } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
|
||||
y: { ticks: { color: '#60a5fa' }, grid: { color: '#1e293b' }, position: 'left', title: { text:'°C', display:true, color:'#60a5fa' } },
|
||||
y1: { ticks: { color: '#34d399' }, grid: { drawOnChartArea: false }, position: 'right', title: { text:'%', display:true, color:'#34d399' } },
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const r = await fetch('/api/messages?limit=200');
|
||||
const msgs = await r.json();
|
||||
msgs.forEach(m => {
|
||||
if (!state.messagesByDevice[m.device]) state.messagesByDevice[m.device] = [];
|
||||
state.messagesByDevice[m.device].push(m);
|
||||
});
|
||||
Object.values(state.messagesByDevice).forEach(a => a.sort((x,y) => y.ts - x.ts));
|
||||
renderDevices();
|
||||
renderMessages();
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
const badge = document.getElementById('conn-badge');
|
||||
const es = new EventSource('/events');
|
||||
es.onopen = () => { badge.textContent = 'live'; badge.className = 'text-xs px-3 py-1 rounded-full bg-emerald-900/50 text-emerald-400'; };
|
||||
es.onerror = () => { badge.textContent = 'reconnecting…'; badge.className = 'text-xs px-3 py-1 rounded-full bg-red-900/50 text-red-400'; };
|
||||
es.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type !== 'message') return;
|
||||
if (!state.messagesByDevice[msg.device]) state.messagesByDevice[msg.device] = [];
|
||||
state.messagesByDevice[msg.device].unshift(msg);
|
||||
state.messagesByDevice[msg.device] = state.messagesByDevice[msg.device].slice(0, 200);
|
||||
renderDevices();
|
||||
renderMessages();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById('chart-device').addEventListener('change', (e) => {
|
||||
state.selectedDevice = e.target.value;
|
||||
renderChart();
|
||||
});
|
||||
|
||||
bootstrap().then(connectSSE);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user