diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..bc2fa67 --- /dev/null +++ b/dashboard/Dockerfile @@ -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"] diff --git a/dashboard/app.py b/dashboard/app.py new file mode 100644 index 0000000..86064a7 --- /dev/null +++ b/dashboard/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) diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt new file mode 100644 index 0000000..0647450 --- /dev/null +++ b/dashboard/requirements.txt @@ -0,0 +1 @@ +flask==3.0.3 diff --git a/dashboard/sensit_decoder.py b/dashboard/sensit_decoder.py new file mode 100644 index 0000000..5b4bf28 --- /dev/null +++ b/dashboard/sensit_decoder.py @@ -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)) diff --git a/dashboard/static/index.html b/dashboard/static/index.html new file mode 100644 index 0000000..1b9d831 --- /dev/null +++ b/dashboard/static/index.html @@ -0,0 +1,210 @@ + + +
+ + +Sens'it Discovery — live decoding
+| Time | +Device | +Mode | +Data | +RSSI | +Payload | +
|---|