Improve sleep state detection

This commit is contained in:
Oliver Großkloß 2025-12-09 15:40:54 +01:00
parent 592530aac2
commit 05fddea91d

127
main.py
View File

@ -20,6 +20,9 @@ from pathlib import Path
try: try:
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
except ImportError: except ImportError:
if "PYTEST_CURRENT_TEST" in os.environ:
mqtt = None
else:
print("Error: paho-mqtt not installed. Run: pip install paho-mqtt") print("Error: paho-mqtt not installed. Run: pip install paho-mqtt")
sys.exit(1) sys.exit(1)
@ -106,8 +109,31 @@ class GadgetbridgeMQTT:
logger.info(f"Received signal {signum}. Shutting down...") logger.info(f"Received signal {signum}. Shutting down...")
self.running = False self.running = False
@staticmethod
def _compute_awake(is_awake_flag, wakeup_raw, stage_code, stage_timestamp_ms, now_ms):
"""Decide awake state using most reliable signals first"""
recent_stage = stage_timestamp_ms is not None and now_ms - stage_timestamp_ms <= 30 * 60 * 1000
if recent_stage and stage_code is not None:
if stage_code == 5: # AWAKE
return True
if stage_code in (2, 3, 4): # Deep, Light, REM
return False
# stage_code 0/1 fall through to other signals
if is_awake_flag == 1:
return True
if wakeup_raw is not None and wakeup_raw <= now_ms:
return True
return False
def connect_mqtt(self): def connect_mqtt(self):
"""Connect to MQTT broker""" """Connect to MQTT broker"""
if mqtt is None:
logger.error("paho-mqtt not available; cannot publish")
return False
# Use callback API version 2 to avoid deprecation warning # Use callback API version 2 to avoid deprecation warning
try: try:
self.mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) self.mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
@ -188,6 +214,27 @@ class GadgetbridgeMQTT:
day_start_ts = self.get_day_start_timestamp() day_start_ts = self.get_day_start_timestamp()
now_ts = int(datetime.now().timestamp()) now_ts = int(datetime.now().timestamp())
day_midnight = self.get_day_midnight_timestamp() day_midnight = self.get_day_midnight_timestamp()
now_ms = int(time.time() * 1000)
# Query sleep stage FIRST (needed for is_awake calculation)
stage_code = None
stage_timestamp_ms = None
try:
cursor.execute(
"""
SELECT STAGE, TIMESTAMP
FROM XIAOMI_SLEEP_STAGE_SAMPLE
WHERE DEVICE_ID = ?
ORDER BY TIMESTAMP DESC
LIMIT 1
""",
(self.device_id,)
)
row = cursor.fetchone()
if row:
stage_code, stage_timestamp_ms = row
except Exception as e:
logger.debug(f"Sleep stage query failed: {e}")
# Daily Steps (filtered by device) # Daily Steps (filtered by device)
try: try:
@ -258,63 +305,47 @@ class GadgetbridgeMQTT:
# Note: XIAOMI_SLEEP_TIME_SAMPLE uses MILLISECONDS timestamps # Note: XIAOMI_SLEEP_TIME_SAMPLE uses MILLISECONDS timestamps
try: try:
day_ago_ts_ms = (int(time.time()) - 24 * 3600) * 1000 # 24h ago in milliseconds day_ago_ts_ms = (int(time.time()) - 24 * 3600) * 1000 # 24h ago in milliseconds
now_ms = int(time.time()) * 1000 # Current time in milliseconds
cursor.execute( cursor.execute(
""" """
SELECT TOTAL_DURATION, IS_AWAKE, WAKEUP_TIME SELECT TOTAL_DURATION, IS_AWAKE, WAKEUP_TIME, TIMESTAMP
FROM XIAOMI_SLEEP_TIME_SAMPLE FROM XIAOMI_SLEEP_TIME_SAMPLE
WHERE DEVICE_ID = ? AND TIMESTAMP >= ? WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?
ORDER BY TIMESTAMP DESC ORDER BY TIMESTAMP DESC
LIMIT 1 LIMIT 1
""", """,
(self.device_id, day_ago_ts_ms) (self.device_id, day_ago_ts_ms, now_ms) # Only sessions that have started
) )
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
total_duration, is_awake_flag, wakeup_raw = row total_duration, is_awake_flag, wakeup_raw, sleep_start_ms = row
# Convert duration to hours
if total_duration is not None: if total_duration is not None:
data["sleep_duration"] = round(total_duration / 60.0, 2) data["sleep_duration"] = round(total_duration / 60.0, 2)
# Determine if user is awake: # Determine if currently in a sleep session
# 1. If IS_AWAKE flag is explicitly set to 1, user is awake # User is sleeping if: sleep_start <= now < wakeup_time
# 2. If WAKEUP_TIME exists and is in the past, user is awake in_sleep_session = (
# 3. Otherwise, assume still sleeping sleep_start_ms is not None and
if is_awake_flag == 1: wakeup_raw is not None and
is_awake = True sleep_start_ms <= now_ms < wakeup_raw
elif wakeup_raw is not None and wakeup_raw <= now_ms: )
# WAKEUP_TIME is in the past = user has woken up data["in_sleep_session"] = in_sleep_session
is_awake = True
else:
is_awake = False
is_awake = self._compute_awake(
is_awake_flag=is_awake_flag,
wakeup_raw=wakeup_raw,
stage_code=stage_code,
stage_timestamp_ms=stage_timestamp_ms,
now_ms=now_ms,
)
data["is_awake"] = is_awake data["is_awake"] = is_awake
except Exception as e: # Only report sleep stage if currently in sleep session or stage is recent
logger.debug(f"Sleep query failed: {e}") if stage_code is not None and stage_timestamp_ms is not None:
stage_age_minutes = (now_ms - stage_timestamp_ms) / 1000 / 60
# Sleep Stage Data (current stage) # Only report current stage if in sleep session or stage is recent (within 30 min)
# Note: XIAOMI_SLEEP_STAGE_SAMPLE uses MILLISECONDS timestamps if in_sleep_session or stage_age_minutes <= 30:
try:
cursor.execute(
"""
SELECT STAGE, TIMESTAMP
FROM XIAOMI_SLEEP_STAGE_SAMPLE
WHERE DEVICE_ID = ?
ORDER BY TIMESTAMP DESC
LIMIT 1
""",
(self.device_id,)
)
row = cursor.fetchone()
if row:
stage_code, stage_timestamp = row
# Sleep stage codes from Gadgetbridge SleepDetailsParser.java:
# 0: NOT_SLEEP, 1: N/A (unknown), 2: DEEP_SLEEP,
# 3: LIGHT_SLEEP, 4: REM_SLEEP, 5: AWAKE
stage_names = { stage_names = {
0: "not_sleep", 0: "not_sleep",
1: "unknown", 1: "unknown",
@ -323,12 +354,21 @@ class GadgetbridgeMQTT:
4: "rem_sleep", 4: "rem_sleep",
5: "awake" 5: "awake"
} }
data["sleep_stage"] = stage_names.get(stage_code, f"unknown_{stage_code}") data["sleep_stage"] = stage_names.get(stage_code, f"unknown_{stage_code}")
data["sleep_stage_code"] = stage_code data["sleep_stage_code"] = stage_code
else:
# Not in sleep session and stage is stale - report as awake/not sleeping
data["sleep_stage"] = "not_sleep"
data["sleep_stage_code"] = 0
else:
# No sleep data - user is awake
data["is_awake"] = True
data["in_sleep_session"] = False
data["sleep_stage"] = "not_sleep"
data["sleep_stage_code"] = 0
except Exception as e: except Exception as e:
logger.debug(f"Sleep stage query failed: {e}") logger.debug(f"Sleep query failed: {e}")
# Weight (not device-specific, from scale) # Weight (not device-specific, from scale)
try: try:
@ -368,7 +408,8 @@ class GadgetbridgeMQTT:
("calories", "Calories", "kcal", "mdi:fire", "total_increasing", None), ("calories", "Calories", "kcal", "mdi:fire", "total_increasing", None),
("sleep_duration", "Sleep Duration", "h", "mdi:sleep", "measurement", None), ("sleep_duration", "Sleep Duration", "h", "mdi:sleep", "measurement", None),
("is_awake", "Is Awake", None, "mdi:power-sleep", None, None), ("is_awake", "Is Awake", None, "mdi:power-sleep", None, None),
("sleep_stage", "Sleep Stage", None, "mdi:sleep-cycle", "measurement", None), ("in_sleep_session", "In Sleep Session", None, "mdi:bed", None, None),
("sleep_stage", "Sleep Stage", None, "mdi:sleep-cycle", None, None),
("sleep_stage_code", "Sleep Stage Code", None, "mdi:numeric", "measurement", None), ("sleep_stage_code", "Sleep Stage Code", None, "mdi:numeric", "measurement", None),
("weight", "Weight", "kg", "mdi:scale-bathroom", "measurement", None), ("weight", "Weight", "kg", "mdi:scale-bathroom", "measurement", None),
("server_time", "Last Update", None, "mdi:clock-outline", None, "timestamp"), ("server_time", "Last Update", None, "mdi:clock-outline", None, "timestamp"),