a77776d6d8
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>
87 lines
2.8 KiB
Python
87 lines
2.8 KiB
Python
"""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))
|