feat: Sens'it v2 decoder + auto-detection v2/v3
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 <noreply@anthropic.com>
This commit is contained in:
+112
-60
@@ -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",
|
0: "Standby / Button",
|
||||||
1: "Temperature",
|
1: "Temperature",
|
||||||
2: "Light",
|
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:
|
def decode(payload_hex: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Decode a 4-byte Sens'it Discovery payload.
|
Decode a 4-byte Sens'it payload. Auto-detects v2 vs Discovery v3.
|
||||||
|
|
||||||
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(" ", "")
|
payload_hex = payload_hex.strip().lower().replace("0x", "").replace(" ", "")
|
||||||
if len(payload_hex) != 8:
|
if len(payload_hex) != 8:
|
||||||
return {"error": f"payload must be 4 bytes (8 hex chars), got '{payload_hex}'"}
|
return {"error": f"payload must be 4 bytes (8 hex chars), got '{payload_hex}'"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
b0, b1, b2, b3 = (int(payload_hex[i : i + 2], 16) for i in range(0, 8, 2))
|
b0, b1, b2, b3 = (int(payload_hex[i : i + 2], 16) for i in range(0, 8, 2))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
# Common fields
|
result = (_decode_v3 if (b0 & 0x07) == 0b110 else _decode_v2)(b0, b1, b2, b3)
|
||||||
battery_raw = (b0 >> 3) & 0x1F
|
result["raw"] = payload_hex
|
||||||
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Sanity checks with the payloads we captured
|
for h in ("b60dc86e", "a60dc271", "a87611db", "a8750adb", "a87407db"):
|
||||||
for h in ("b60dc86e", "a60dc271", "b6043081"):
|
|
||||||
print(h, "→", decode(h))
|
print(h, "→", decode(h))
|
||||||
|
|||||||
@@ -67,8 +67,11 @@ const modeIcon = mode => ({
|
|||||||
'Light': '💡',
|
'Light': '💡',
|
||||||
'Door': '🚪',
|
'Door': '🚪',
|
||||||
'Vibration': '📳',
|
'Vibration': '📳',
|
||||||
|
'Move': '📳',
|
||||||
'Magnet': '🧲',
|
'Magnet': '🧲',
|
||||||
|
'Reed Switch': '🧲',
|
||||||
'Standby / Button': '⏸️',
|
'Standby / Button': '⏸️',
|
||||||
|
'Button': '🔘',
|
||||||
}[mode] || '❓');
|
}[mode] || '❓');
|
||||||
|
|
||||||
function summaryLine(d) {
|
function summaryLine(d) {
|
||||||
@@ -81,6 +84,9 @@ function summaryLine(d) {
|
|||||||
if ('vibration_status' in d) parts.push(`vibration: ${d.vibration_status}`);
|
if ('vibration_status' in d) parts.push(`vibration: ${d.vibration_status}`);
|
||||||
if ('magnet_status' in d) parts.push(`aimant: ${d.magnet_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 ('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');
|
if (parts.length === 0) parts.push('heartbeat');
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user