From 1549d4a73e1ef50b6da9cbc0210194469ec5ce37 Mon Sep 17 00:00:00 2001 From: gitomarcourt Date: Fri, 3 Jul 2026 18:02:44 +0200 Subject: [PATCH] feat: Sens'it v2 decoder + auto-detection v2/v3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sens'it v2 has a totally different payload layout than Discovery v3: - Bit order LSB→MSB (was MSB→LSB in v3) - Mode in bits 0-2 (was bits 7-3 in v3) - Battery split across bytes 0 & 1 (was 5 contiguous bits in v3) - Temperature always sent (in T° MSB byte 1) even outside temp mode Auto-detection uses byte 0 reserved bits: 0b110 = v3, otherwise = v2. Frontend updated to render new fields: move_events, reed_events, fw_major/minor. Co-Authored-By: Claude Opus 4.7 --- dashboard/sensit_decoder.py | 172 +++++++++++++++++++++++------------- dashboard/static/index.html | 6 ++ 2 files changed, 118 insertions(+), 60 deletions(-) diff --git a/dashboard/sensit_decoder.py b/dashboard/sensit_decoder.py index 5b4bf28..067caf5 100644 --- a/dashboard/sensit_decoder.py +++ b/dashboard/sensit_decoder.py @@ -1,6 +1,13 @@ -"""Sens'it Discovery payload decoder — all 6 modes.""" +"""Sens'it payload decoder — supports both v2 and Discovery (v3). -MODES = { +Auto-detects the device generation by inspecting the reserved bits of byte 0: +- v3 Discovery: byte 0 bits 2-0 == 0b110 (fixed marker) +- v2 : otherwise +""" + +# --- Discovery v3 (Sens'it 3.1.0+) --------------------------------------------- + +V3_MODES = { 0: "Standby / Button", 1: "Temperature", 2: "Light", @@ -10,77 +17,122 @@ MODES = { } +def _decode_v3(b0: int, b1: int, b2: int, b3: int) -> dict: + battery_raw = (b0 >> 3) & 0x1F + mode = (b1 >> 3) & 0x1F + result = { + "format": "sensit_v3", + "battery": round(battery_raw * 0.05 + 2.7, 2), + "battery_raw": battery_raw, + "reserved_ok": True, + "mode": V3_MODES.get(mode, f"Unknown ({mode})"), + "mode_id": mode, + "button_alert": bool(b1 & 0x01), + } + + if mode == 1: # Temperature + Humidity + temp_raw = (((b1 >> 2) & 0x01) << 8) | b2 + result["temperature_c"] = round((temp_raw - 200) / 8, 2) + result["humidity_pct"] = round(b3 / 2, 1) + elif mode == 2: # Light + brightness_raw = (((b1 >> 2) & 0x01) << 8) | b2 + result["brightness_lux"] = round(brightness_raw / 96, 2) + elif mode == 3: + result["door_status"] = {0: "closed", 1: "opened", 2: "opening_alert", 3: "closing_alert"}.get((b1 >> 1) & 0x03, "unknown") + result["event_count"] = (b2 << 8) | b3 + elif mode == 4: + result["vibration_status"] = {0: "no_vibration", 1: "vibration_detected"}.get((b1 >> 1) & 0x03, "unknown") + result["event_count"] = (b2 << 8) | b3 + elif mode == 5: + result["magnet_status"] = {0: "no_magnet", 1: "magnet_detected"}.get((b1 >> 1) & 0x03, "unknown") + result["event_count"] = (b2 << 8) | b3 + return result + + +# --- Sens'it v2 ---------------------------------------------------------------- + +V2_MODES = { + 0: "Button", + 1: "Temperature", + 2: "Light", + 3: "Door", + 4: "Move", + 5: "Reed Switch", +} +V2_TIMEFRAME = {0: "10 min", 1: "1 h", 2: "6 days", 3: "24 h"} +V2_TYPE = {0: "regular", 1: "button_call", 2: "alert", 3: "new_mode"} +V2_LIGHT_MULT = {0: 1, 1: 8, 2: 64, 3: 2014} + + +def _decode_v2(b0: int, b1: int, b2: int, b3: int) -> dict: + mode = b0 & 0x07 + timeframe = (b0 >> 3) & 0x03 + msg_type = (b0 >> 5) & 0x03 + battery_msb = (b0 >> 7) & 0x01 + + temp_msb = b1 & 0x0F + battery_lsb = (b1 >> 4) & 0x0F + battery_raw = (battery_msb << 4) | battery_lsb + + result = { + "format": "sensit_v2", + "battery": round(battery_raw * 0.05 + 2.7, 2), + "battery_raw": battery_raw, + "mode": V2_MODES.get(mode, f"Unknown ({mode})"), + "mode_id": mode, + "timeframe": V2_TIMEFRAME.get(timeframe, str(timeframe)), + "type": V2_TYPE.get(msg_type, str(msg_type)), + "button_alert": msg_type == 1, + } + + if mode == 2: # Light — byte 3 not used for temp + light_value = b2 & 0x3F + light_mult = V2_LIGHT_MULT[(b2 >> 6) & 0x03] + result["brightness_lux"] = round(light_mult * light_value * 0.01, 2) + elif mode == 3: # Door — byte 3 reserved for config + pass + else: + # Classic modes: byte 2 = T° LSB (6 bits) + reed switch (1 bit) + unused + temp_lsb = b2 & 0x3F + temp_raw = (temp_msb << 6) | temp_lsb + result["temperature_c"] = round((temp_raw - 200) / 8, 2) + result["reed_switch"] = bool((b2 >> 6) & 0x01) + + # Byte 3 depends on mode + if mode == 0: # Button — software version + result["fw_major"] = (b3 >> 4) & 0x0F + result["fw_minor"] = b3 & 0x0F + elif mode == 1: # Temperature — humidity + result["humidity_pct"] = round(b3 * 0.5, 1) + elif mode == 4: # Move — event count / status + result["move_events"] = b3 + elif mode == 5: # Reed Switch — event count + result["reed_events"] = b3 + else: + result["byte3_raw"] = b3 + return result + + +# --- Entry point --------------------------------------------------------------- + + 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 + Decode a 4-byte Sens'it payload. Auto-detects v2 vs Discovery v3. """ 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 - + result = (_decode_v3 if (b0 & 0x07) == 0b110 else _decode_v2)(b0, b1, b2, b3) + result["raw"] = payload_hex return result if __name__ == "__main__": - # Sanity checks with the payloads we captured - for h in ("b60dc86e", "a60dc271", "b6043081"): + for h in ("b60dc86e", "a60dc271", "a87611db", "a8750adb", "a87407db"): print(h, "→", decode(h)) diff --git a/dashboard/static/index.html b/dashboard/static/index.html index 1b9d831..cb4e77d 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -67,8 +67,11 @@ const modeIcon = mode => ({ 'Light': '💡', 'Door': '🚪', 'Vibration': '📳', + 'Move': '📳', 'Magnet': '🧲', + 'Reed Switch': '🧲', 'Standby / Button': '⏸️', + 'Button': '🔘', }[mode] || '❓'); function summaryLine(d) { @@ -81,6 +84,9 @@ function summaryLine(d) { 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 ('move_events' in d) parts.push(`move: ${d.move_events}`); + if ('reed_events' in d) parts.push(`reed: ${d.reed_events}`); + if ('fw_major' in d) parts.push(`fw v${d.fw_major}.${d.fw_minor}`); if (parts.length === 0) parts.push('heartbeat'); return parts.join(' · '); }