diff --git a/main.py b/main.py index 7b4295b..c7e5c1a 100644 --- a/main.py +++ b/main.py @@ -51,21 +51,46 @@ def load_config(): return json.load(f) -def send_gadgetbridge_intent(action): +def send_gadgetbridge_intent(action, extra_args=""): """Send broadcast intent to Gadgetbridge via Termux API""" - cmd = f"am broadcast -a {action} -p nodomain.freeyourgadget.gadgetbridge" + cmd = f"am broadcast -a {action} {extra_args} -p nodomain.freeyourgadget.gadgetbridge" logger.info(f"Sending intent: {action}") result = os.system(cmd) if result != 0: logger.warning(f"Intent may have failed (exit code: {result})") + return result == 0 -def trigger_gadgetbridge_sync(): - """Trigger Gadgetbridge to sync data from band and export database""" - # First sync data from the band +def trigger_bluetooth_connect(device_mac): + """Force Gadgetbridge to connect to the device via Bluetooth. + + This fixes the 'disconnected' state that can occur when the band + loses connection during sleep or when the phone is idle. + """ + if not device_mac: + logger.warning("No device MAC address configured, skipping Bluetooth connect") + return False + + action = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_CONNECT" + extra = f"-e EXTRA_DEVICE_ADDRESS '{device_mac}'" + return send_gadgetbridge_intent(action, extra) + + +def trigger_gadgetbridge_sync(device_mac=None): + """Trigger Gadgetbridge to sync data from band and export database. + + If device_mac is provided, first forces a Bluetooth reconnection. + """ + # Step 1: Force Bluetooth connection (fixes disconnected state) + if device_mac: + trigger_bluetooth_connect(device_mac) + time.sleep(10) # Wait for Bluetooth connection to establish + + # Step 2: Sync activity data from band to phone send_gadgetbridge_intent("nodomain.freeyourgadget.gadgetbridge.command.ACTIVITY_SYNC") - time.sleep(5) # Give it time to sync - # Then trigger database export + time.sleep(10) # Wait for sync to complete + + # Step 3: Trigger database export send_gadgetbridge_intent("nodomain.freeyourgadget.gadgetbridge.command.TRIGGER_EXPORT") @@ -95,6 +120,7 @@ class GadgetbridgeMQTT: self.device_name = "fitness_tracker" # MQTT identifier (always fitness_tracker) self.device_alias = "Unknown" # Actual device name for display self.device_id = None # Track device ID for filtering queries + self.device_mac = None # Track device MAC for Bluetooth reconnection self.mqtt_client = None self.last_publish_time = 0 self.last_db_mtime = 0 @@ -110,15 +136,19 @@ class GadgetbridgeMQTT: self.running = False @staticmethod - def _compute_awake(is_awake_flag, wakeup_raw, stage_code, stage_timestamp_ms, now_ms): + def _compute_awake(is_awake_flag, wakeup_raw, stage_code, stage_timestamp_ms, now_ms, avg_recent_hr=None, resting_hr=None): """Decide awake state using most reliable signals first. Priority: 1. If past wakeup time -> awake (session ended) - 2. If is_awake_flag set -> awake - 3. If recent sleep stage shows awake -> awake - 4. If recent sleep stage shows sleep (deep/light/REM) -> sleeping - 5. Default: awake + 2. If is_awake_flag explicitly set to 1 -> awake + 3. If recent sleep stage shows awake (code 5) -> awake + 4. If recent sleep stage shows sleep (2=deep, 3=light, 4=REM) -> sleeping + 5. If we're within a sleep session (before wakeup time) -> sleeping + 6. Use HR as fallback: HR < (resting_hr + 10) suggests sleeping + 7. Default: awake + + Stage codes for Xiaomi: 2=deep, 3=light, 4=REM, 5=awake """ # If we're past the wakeup time, the session is over -> awake if wakeup_raw is not None and wakeup_raw <= now_ms: @@ -127,16 +157,26 @@ class GadgetbridgeMQTT: if is_awake_flag == 1: return True - recent_stage = stage_timestamp_ms is not None and now_ms - stage_timestamp_ms <= 30 * 60 * 1000 + # Check if stage data is recent (within 2 hours for better coverage) + recent_stage = stage_timestamp_ms is not None and now_ms - stage_timestamp_ms <= 2 * 60 * 60 * 1000 if recent_stage and stage_code is not None: - if stage_code == 5: # AWAKE + if stage_code == 5: # AWAKE stage return True - if stage_code in (2, 3, 4): # Deep, Light, REM + if stage_code in (2, 3, 4): # Deep(2), Light(3), REM(4) return False - # stage_code 0/1 fall through - return False + # If within a sleep session (wakeup time is in the future), likely sleeping + if wakeup_raw is not None and wakeup_raw > now_ms: + return False + + # Use HR as fallback indicator - low HR suggests sleeping + # Use resting HR + 10 as threshold, or default to 65 if no resting HR available + hr_threshold = (resting_hr + 10) if resting_hr else 65 + if avg_recent_hr is not None and avg_recent_hr < hr_threshold: + return False + + return True def connect_mqtt(self): """Connect to MQTT broker""" @@ -178,12 +218,12 @@ class GadgetbridgeMQTT: self.mqtt_client.disconnect() def get_device_info(self, cursor): - """Get device ID and alias from database - picks device with most recent activity""" + """Get device ID, alias, and MAC from database - picks device with most recent activity""" try: # Find the device with the most recent activity data # This ensures we get the currently active band, not an old one cursor.execute(""" - SELECT d._id, d.ALIAS, d.NAME FROM DEVICE d + SELECT d._id, d.ALIAS, d.NAME, d.IDENTIFIER FROM DEVICE d WHERE (LOWER(d.NAME) LIKE '%band%' OR LOWER(d.NAME) LIKE '%watch%') ORDER BY d._id DESC LIMIT 1 @@ -193,11 +233,12 @@ class GadgetbridgeMQTT: device_id = row[0] # Get actual device name for display device_alias = row[1] if row[1] else row[2] # Use ALIAS, fallback to NAME - logger.info(f"Selected device: ID={device_id}, Name={device_alias}") - return device_id, device_alias + device_mac = row[3] # MAC address / IDENTIFIER + logger.info(f"Selected device: ID={device_id}, Name={device_alias}, MAC={device_mac}") + return device_id, device_alias, device_mac except Exception as e: logger.error(f"Error getting device info: {e}") - return None, "Unknown" + return None, "Unknown", None def get_day_start_timestamp(self): """Get timestamp for start of current day (4am)""" @@ -245,6 +286,44 @@ class GadgetbridgeMQTT: except Exception as e: logger.debug(f"Sleep stage query failed: {e}") + # Query average recent HR (last 10 minutes) for sleep detection fallback + avg_recent_hr = None + try: + cursor.execute( + """ + SELECT AVG(HEART_RATE) + FROM XIAOMI_ACTIVITY_SAMPLE + WHERE DEVICE_ID = ? + AND HEART_RATE > 0 AND HEART_RATE < 255 + AND TIMESTAMP >= ? + """, + (self.device_id, now_ts - 600) # Last 10 minutes + ) + row = cursor.fetchone() + if row and row[0]: + avg_recent_hr = row[0] + except Exception as e: + logger.debug(f"Recent HR query failed: {e}") + + # Query resting HR for dynamic sleep threshold + resting_hr = None + try: + cursor.execute( + """ + SELECT HR_RESTING + FROM XIAOMI_DAILY_SUMMARY_SAMPLE + WHERE DEVICE_ID = ? AND HR_RESTING > 0 + ORDER BY TIMESTAMP DESC + LIMIT 1 + """, + (self.device_id,) + ) + row = cursor.fetchone() + if row and row[0]: + resting_hr = row[0] + except Exception as e: + logger.debug(f"Resting HR query failed: {e}") + # Daily Steps (filtered by device) try: cursor.execute( @@ -317,7 +396,8 @@ class GadgetbridgeMQTT: cursor.execute( """ - SELECT TOTAL_DURATION, IS_AWAKE, WAKEUP_TIME, TIMESTAMP + SELECT TOTAL_DURATION, IS_AWAKE, WAKEUP_TIME, TIMESTAMP, + DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION, AWAKE_DURATION FROM XIAOMI_SLEEP_TIME_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ? ORDER BY TIMESTAMP DESC @@ -327,10 +407,55 @@ class GadgetbridgeMQTT: ) row = cursor.fetchone() if row: - total_duration, is_awake_flag, wakeup_raw, sleep_start_ms = row + (total_duration, is_awake_flag, wakeup_raw, sleep_start_ms, + deep_dur, light_dur, rem_dur, awake_dur) = row - if total_duration is not None: - data["sleep_duration"] = round(total_duration / 60.0, 2) + # Calculate sleep duration using multiple strategies + actual_sleep_min = 0 + + # Strategy 1: Use breakdown sum if available (most accurate) + if deep_dur or light_dur or rem_dur: + actual_sleep_min = (deep_dur or 0) + (light_dur or 0) + (rem_dur or 0) + + # Strategy 2: Calculate from stage data (count sleep stage entries) + # This works even when TOTAL_DURATION is incomplete + if actual_sleep_min == 0 and sleep_start_ms and wakeup_raw: + try: + cursor.execute( + """ + SELECT + MIN(TIMESTAMP) as first_stage, + MAX(TIMESTAMP) as last_stage, + COUNT(*) as stage_count, + SUM(CASE WHEN STAGE IN (2,3,4) THEN 1 ELSE 0 END) as sleep_count + FROM XIAOMI_SLEEP_STAGE_SAMPLE + WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ? + """, + (self.device_id, sleep_start_ms, wakeup_raw) + ) + stage_row = cursor.fetchone() + if stage_row and stage_row[0] and stage_row[1]: + # Calculate duration from stage time span + stage_span_min = (stage_row[1] - stage_row[0]) / 1000 / 60 + if stage_span_min > 30: # At least 30 min of stage data + actual_sleep_min = stage_span_min + except Exception as e: + logger.debug(f"Stage duration calculation failed: {e}") + + # Strategy 3: Use TOTAL_DURATION if available + if actual_sleep_min == 0 and total_duration: + actual_sleep_min = total_duration + + # Strategy 4: Calculate from session time range (least accurate) + if actual_sleep_min < 30 and sleep_start_ms and wakeup_raw: + session_minutes = (wakeup_raw - sleep_start_ms) / 1000 / 60 + # Only use if session looks reasonable (1-14 hours) + if 60 <= session_minutes <= 840: + # Estimate ~10% awake time + actual_sleep_min = session_minutes * 0.9 + + if actual_sleep_min > 0: + data["sleep_duration"] = round(actual_sleep_min / 60.0, 2) # Determine if currently in a sleep session # User is sleeping if: sleep_start <= now < wakeup_time @@ -347,28 +472,36 @@ class GadgetbridgeMQTT: stage_code=stage_code, stage_timestamp_ms=stage_timestamp_ms, now_ms=now_ms, + avg_recent_hr=avg_recent_hr, + resting_hr=resting_hr, ) data["is_awake"] = is_awake - # Only report sleep stage if currently in sleep session or stage is recent + # 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", + 2: "deep_sleep", + 3: "light_sleep", + 4: "rem_sleep", + 5: "awake" + } + 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" - } + # 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 - report as awake/not sleeping + # 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 @@ -465,8 +598,8 @@ class GadgetbridgeMQTT: conn = sqlite3.connect(self.db_path, timeout=10.0) cursor = conn.cursor() - # Get device ID and actual name - self.device_id, self.device_alias = self.get_device_info(cursor) + # Get device ID, name, and MAC + self.device_id, self.device_alias, self.device_mac = self.get_device_info(cursor) if not self.device_id: logger.warning("No fitness device found in database") @@ -513,10 +646,11 @@ class GadgetbridgeMQTT: if current_time - self.last_publish_time >= interval: try: logger.info("Triggering Gadgetbridge sync...") - trigger_gadgetbridge_sync() + # Use device MAC for Bluetooth reconnection if available + trigger_gadgetbridge_sync(self.device_mac) # Wait for export to complete - time.sleep(10) + time.sleep(5) # Find latest database with retry self.db_path = find_latest_db(export_dir) @@ -534,8 +668,10 @@ class GadgetbridgeMQTT: try: conn = sqlite3.connect(self.db_path, timeout=5.0) cursor = conn.cursor() - self.device_id, self.device_alias = self.get_device_info(cursor) + self.device_id, self.device_alias, self.device_mac = self.get_device_info(cursor) conn.close() + if self.device_mac: + logger.info(f"Device MAC for reconnection: {self.device_mac}") except Exception as e: logger.warning(f"Could not read device info: {e}") self.publish_discovery()