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:
2026-07-03 17:43:11 +02:00
parent c7ab02cc59
commit a77776d6d8
6 changed files with 570 additions and 0 deletions
+14
View File
@@ -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"]
+184
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
flask==3.0.3
+86
View File
@@ -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))
+210
View File
@@ -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>