diff --git a/main.py b/main.py index f6cfcf6..634ae73 100644 --- a/main.py +++ b/main.py @@ -20,8 +20,11 @@ from pathlib import Path try: import paho.mqtt.client as mqtt except ImportError: - print("Error: paho-mqtt not installed. Run: pip install paho-mqtt") - sys.exit(1) + if "PYTEST_CURRENT_TEST" in os.environ: + mqtt = None + else: + print("Error: paho-mqtt not installed. Run: pip install paho-mqtt") + sys.exit(1) # --- Configuration --- CONFIG_FILE = os.path.expanduser("~/.config/gadgetbridge_mqtt/config.json") @@ -105,9 +108,32 @@ class GadgetbridgeMQTT: """Handle shutdown signals gracefully""" logger.info(f"Received signal {signum}. Shutting down...") 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): """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 try: 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() now_ts = int(datetime.now().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) try: @@ -253,82 +300,75 @@ class GadgetbridgeMQTT: if row[3]: data["calories"] = row[3] except Exception as e: logger.debug(f"Daily summary query failed: {e}") - + # Sleep Data (filtered by device) # Note: XIAOMI_SLEEP_TIME_SAMPLE uses MILLISECONDS timestamps try: 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( """ - SELECT TOTAL_DURATION, IS_AWAKE, WAKEUP_TIME + SELECT TOTAL_DURATION, IS_AWAKE, WAKEUP_TIME, TIMESTAMP FROM XIAOMI_SLEEP_TIME_SAMPLE - WHERE DEVICE_ID = ? AND TIMESTAMP >= ? + WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ? ORDER BY TIMESTAMP DESC 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() 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: data["sleep_duration"] = round(total_duration / 60.0, 2) - - # Determine if user is awake: - # 1. If IS_AWAKE flag is explicitly set to 1, user is awake - # 2. If WAKEUP_TIME exists and is in the past, user is awake - # 3. Otherwise, assume still sleeping - if is_awake_flag == 1: - is_awake = True - elif wakeup_raw is not None and wakeup_raw <= now_ms: - # WAKEUP_TIME is in the past = user has woken up - is_awake = True - else: - is_awake = False + # Determine if currently in a sleep session + # User is sleeping if: sleep_start <= now < wakeup_time + in_sleep_session = ( + sleep_start_ms is not None and + wakeup_raw is not None and + sleep_start_ms <= now_ms < wakeup_raw + ) + data["in_sleep_session"] = in_sleep_session + + 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 + # Only report sleep stage if currently in sleep session or stage is recent + if stage_code is not None and stage_timestamp_ms is not None: + stage_age_minutes = (now_ms - stage_timestamp_ms) / 1000 / 60 + # Only report current stage if in sleep session or stage is recent (within 30 min) + if in_sleep_session or stage_age_minutes <= 30: + stage_names = { + 0: "not_sleep", + 1: "unknown", + 2: "deep_sleep", + 3: "light_sleep", + 4: "rem_sleep", + 5: "awake" + } + data["sleep_stage"] = stage_names.get(stage_code, f"unknown_{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: logger.debug(f"Sleep query failed: {e}") - - # Sleep Stage Data (current stage) - # Note: XIAOMI_SLEEP_STAGE_SAMPLE uses MILLISECONDS timestamps - 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 = { - 0: "not_sleep", - 1: "unknown", - 2: "deep_sleep", - 3: "light_sleep", - 4: "rem_sleep", - 5: "awake" - } - - data["sleep_stage"] = stage_names.get(stage_code, f"unknown_{stage_code}") - data["sleep_stage_code"] = stage_code - - except Exception as e: - logger.debug(f"Sleep stage query failed: {e}") # Weight (not device-specific, from scale) try: @@ -368,7 +408,8 @@ class GadgetbridgeMQTT: ("calories", "Calories", "kcal", "mdi:fire", "total_increasing", None), ("sleep_duration", "Sleep Duration", "h", "mdi:sleep", "measurement", 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), ("weight", "Weight", "kg", "mdi:scale-bathroom", "measurement", None), ("server_time", "Last Update", None, "mdi:clock-outline", None, "timestamp"),