diff --git a/main.py b/main.py index 74e5424..3bcec55 100644 --- a/main.py +++ b/main.py @@ -30,6 +30,7 @@ except ImportError: CONFIG_FILE = os.path.expanduser("~/.config/gadgetbridge_mqtt/config.json") GB_EXPORT_DIR = "/storage/emulated/0/Documents/GB_Export" PUBLISH_INTERVAL = 300 # 5 minutes +STALE_THRESHOLD_SECONDS = 1200 # 20 minutes (Force reconnect if no new data for this long) # --- Logging --- logging.basicConfig( @@ -76,6 +77,32 @@ def trigger_bluetooth_connect(device_mac): return send_gadgetbridge_intent(action, extra) +def trigger_bluetooth_reconnect(device_mac): + """ + Force a full disconnect cycle. + Used when data is stale to clear 'zombie' connection states. + Note: We only Disconnect here. The subsequent normal sync cycle + will handle the Connect, effectively completing the reset. + """ + if not device_mac: + logger.warning("No device MAC address available for reconnection") + return False + + logger.warning("Initiating Force Disconnect (Zombie Recovery)...") + + # 1. Force Disconnect + send_gadgetbridge_intent( + "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_DISCONNECT", + f"-e EXTRA_DEVICE_ADDRESS '{device_mac}'" + ) + + # Wait for the stack to clear + logger.info("Waiting 15s for Bluetooth stack to clear...") + time.sleep(15) + + return True + + def trigger_gadgetbridge_sync(device_mac=None): """Trigger Gadgetbridge to sync data from band and export database. @@ -126,6 +153,9 @@ class GadgetbridgeMQTT: self.last_db_mtime = 0 self.running = True + # New: Track data freshness for watchdog + self.last_data_timestamp = 0 + # Register signal handlers for graceful shutdown signal.signal(signal.SIGTERM, self._signal_handler) signal.signal(signal.SIGINT, self._signal_handler) @@ -365,19 +395,20 @@ class GadgetbridgeMQTT: logger.debug(f"Battery query failed: {e}") # Latest Heart Rate (filtered by device) + # --- MODIFIED: Capture TIMESTAMP for watchdog --- try: cursor.execute( - "SELECT HEART_RATE FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255 ORDER BY TIMESTAMP DESC LIMIT 1", + "SELECT HEART_RATE, TIMESTAMP FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255 ORDER BY TIMESTAMP DESC LIMIT 1", (self.device_id,) ) row = cursor.fetchone() if row: data["heart_rate"] = row[0] + self.last_data_timestamp = row[1] # <--- NEW: Update timestamp except Exception as e: logger.debug(f"Heart rate query failed: {e}") # Daily Summary Data (filtered by device) - # Note: XIAOMI_DAILY_SUMMARY_SAMPLE uses MILLISECONDS timestamps try: cursor.execute( "SELECT HR_RESTING, HR_MAX, HR_AVG, CALORIES FROM XIAOMI_DAILY_SUMMARY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? ORDER BY TIMESTAMP DESC LIMIT 1", @@ -393,13 +424,8 @@ class GadgetbridgeMQTT: logger.debug(f"Daily summary query failed: {e}") # Sleep Data (filtered by device) - # Note: XIAOMI_SLEEP_TIME_SAMPLE uses MILLISECONDS timestamps try: # SLEEP DURATION: Sum consecutive sleep sessions until there's a 2h+ gap - # This groups the main sleep session with any brief wakeups/naps - # Only resets when a truly new sleep period begins - - # Get all recent sessions (last 24h) day_ago_ts_ms = (int(time.time()) - 24 * 3600) * 1000 cursor.execute( """ @@ -412,7 +438,6 @@ class GadgetbridgeMQTT: (self.device_id, day_ago_ts_ms) ) - # Group sessions that are within 2 hours of each other sessions = cursor.fetchall() total_sleep_min = 0 last_wakeup_ms = None @@ -420,13 +445,11 @@ class GadgetbridgeMQTT: for sess_row in sessions: sess_start, sess_wake, sess_total, sess_deep, sess_light, sess_rem = sess_row - # If this session ended more than 2h before the next one started, stop if last_wakeup_ms is not None: gap_hours = (last_wakeup_ms - sess_wake) / 1000 / 3600 if sess_wake else 999 if gap_hours > 2: - break # This is a separate sleep period, don't include + break - # Calculate session duration sess_min = 0 if sess_deep or sess_light or sess_rem: sess_min = (sess_deep or 0) + (sess_light or 0) + (sess_rem or 0) @@ -434,13 +457,12 @@ class GadgetbridgeMQTT: sess_min = sess_total total_sleep_min += sess_min - last_wakeup_ms = sess_start # Track when this session started (for gap calc) + last_wakeup_ms = sess_start if total_sleep_min > 0: data["sleep_duration"] = round(total_sleep_min / 60.0, 2) # IS_AWAKE / SLEEP_STAGE: Use the MOST RECENT session (within 24h) - day_ago_ts_ms = (int(time.time()) - 24 * 3600) * 1000 cursor.execute( """ SELECT TOTAL_DURATION, IS_AWAKE, WAKEUP_TIME, TIMESTAMP, @@ -457,8 +479,6 @@ class GadgetbridgeMQTT: (total_duration, is_awake_flag, wakeup_raw, sleep_start_ms, deep_dur, light_dur, rem_dur, awake_dur) = row - # 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 @@ -477,8 +497,6 @@ class GadgetbridgeMQTT: ) data["is_awake"] = is_awake - # Report sleep stage - Xiaomi codes: 2=deep, 3=light, 4=REM, 5=awake - # Stage mapping for Xiaomi devices (verified from database) stage_names = { 0: "not_sleep", 1: "unknown", @@ -490,20 +508,16 @@ class GadgetbridgeMQTT: if stage_code is not None and stage_timestamp_ms is not None: stage_age_minutes = (now_ms - stage_timestamp_ms) / 1000 / 60 - # Report stage if in sleep session or stage is recent (within 2 hours) if in_sleep_session or stage_age_minutes <= 120: 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 data["sleep_stage"] = "not_sleep" data["sleep_stage_code"] = 0 else: - # No stage data data["sleep_stage"] = "not_sleep" if is_awake else "unknown" 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" @@ -619,6 +633,27 @@ class GadgetbridgeMQTT: return False + def check_and_recover_connection(self): + """ + WATCHDOG: Checks if data is stale (>20 mins) and forces Bluetooth restart if so. + """ + if self.last_data_timestamp == 0: + return # No data seen yet, skip check + + current_ts = int(time.time()) + time_diff = current_ts - self.last_data_timestamp + + if time_diff > STALE_THRESHOLD_SECONDS: + logger.warning(f"Data stale ({time_diff}s > {STALE_THRESHOLD_SECONDS}s). Triggering recovery...") + if self.device_mac: + # 1. Disconnect and wait + trigger_bluetooth_reconnect(self.device_mac) + + # 2. Reset timestamp so we don't loop immediately + self.last_data_timestamp = current_ts + else: + logger.warning("Cannot recover: No device MAC available") + def run(self): """Main loop""" export_dir = self.config.get("export_dir", GB_EXPORT_DIR) @@ -645,8 +680,12 @@ class GadgetbridgeMQTT: # Check if it's time to trigger sync and publish if current_time - self.last_publish_time >= interval: try: + # 1. WATCHDOG (New) + self.check_and_recover_connection() + logger.info("Triggering Gadgetbridge sync...") - # Use device MAC for Bluetooth reconnection if available + # 2. SYNC (Original - this handles the CONNECT logic) + # If watchdog ran, this provides the "Reconnect" part of the cycle trigger_gadgetbridge_sync(self.device_mac) # Wait for export to complete