From ac829eacb0ae3953fcd5c8971cb0faa78957016d Mon Sep 17 00:00:00 2001 From: Adrian Groh Date: Mon, 2 Feb 2026 22:40:58 +0100 Subject: [PATCH] Add support for Fossil Hybrid HR --- main.py | 251 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 205 insertions(+), 46 deletions(-) diff --git a/main.py b/main.py index 86d3917..e0f52ff 100644 --- a/main.py +++ b/main.py @@ -127,6 +127,7 @@ class GadgetbridgeMQTT: self.device_alias = "Unknown" self.device_id = None self.device_mac = None + self.device_type = "unknown" self.mqtt_client = None self.last_publish_time = 0 self.last_db_mtime = 0 @@ -222,11 +223,31 @@ class GadgetbridgeMQTT: self.mqtt_client.loop_stop() self.mqtt_client.disconnect() + def detect_device_type(self, cursor, device_id): + """Detect if device is Xiaomi or Hybrid (Fossil) based on which table has data for this device""" + try: + # Check which table actually has data for this device_id + cursor.execute("SELECT COUNT(*) FROM HYBRID_HRACTIVITY_SAMPLE WHERE DEVICE_ID = ?", (device_id,)) + hybrid_count = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ?", (device_id,)) + xiaomi_count = cursor.fetchone()[0] + + logger.info(f"Device type detection: HYBRID samples={hybrid_count}, XIAOMI samples={xiaomi_count}") + + if hybrid_count > 0: + return 'hybrid' + elif xiaomi_count > 0: + return 'xiaomi' + except Exception as e: + logger.debug(f"Device type detection error: {e}") + return 'unknown' + def get_device_info(self, cursor): try: cursor.execute(""" SELECT d._id, d.ALIAS, d.NAME, d.IDENTIFIER FROM DEVICE d - WHERE (LOWER(d.NAME) LIKE '%band%' OR LOWER(d.NAME) LIKE '%watch%') + WHERE (LOWER(d.NAME) LIKE '%band%' OR LOWER(d.NAME) LIKE '%watch%' OR LOWER(d.NAME) LIKE '%fossil%' OR LOWER(d.NAME) LIKE '%hybrid%') ORDER BY d._id DESC LIMIT 1 """) row = cursor.fetchone() @@ -234,7 +255,9 @@ class GadgetbridgeMQTT: device_id = row[0] device_alias = row[1] if row[1] else row[2] device_mac = row[3] - logger.info(f"Selected device: ID={device_id}, Name={device_alias}, MAC={device_mac}") + device_type = self.detect_device_type(cursor, device_id) + logger.info(f"Selected device: ID={device_id}, Name={device_alias}, MAC={device_mac}, Type={device_type}") + self.device_type = device_type return device_id, device_alias, device_mac except Exception as e: logger.error(f"Error getting device info: {e}") @@ -267,24 +290,61 @@ class GadgetbridgeMQTT: 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 + if self.device_type == 'xiaomi': + 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 + elif self.device_type == 'hybrid': + # Hybrid/Fossil doesn't have dedicated sleep stage table + pass + else: + # Try Xiaomi table + 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: + pass except Exception as e: logger.debug(f"Sleep stage query failed: {e}") # Recent HR (5min now, tighter) 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 - 300)) # 5 minutes + if self.device_type == 'xiaomi': + 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 - 300)) + elif self.device_type == 'hybrid': + cursor.execute(""" + SELECT AVG(HEART_RATE) FROM HYBRID_HRACTIVITY_SAMPLE + WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255 + AND TIMESTAMP >= ? + """, (self.device_id, now_ts - 300)) + else: + # Generic fallback + 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 - 300)) + except: + cursor.execute(""" + SELECT AVG(HEART_RATE) FROM HYBRID_HRACTIVITY_SAMPLE + WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255 + AND TIMESTAMP >= ? + """, (self.device_id, now_ts - 300)) row = cursor.fetchone() if row and row[0]: avg_recent_hr = row[0] @@ -294,19 +354,53 @@ class GadgetbridgeMQTT: # Resting HR 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,)) + if self.device_type == 'xiaomi': + 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,)) + elif self.device_type == 'hybrid': + # Hybrid doesn't have daily summary, calculate from HR samples + cursor.execute(""" + SELECT AVG(HEART_RATE) FROM HYBRID_HRACTIVITY_SAMPLE + WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255 + ORDER BY TIMESTAMP DESC LIMIT 100 + """, (self.device_id,)) + else: + 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,)) + except: + cursor.execute(""" + SELECT AVG(HEART_RATE) FROM HYBRID_HRACTIVITY_SAMPLE + WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255 + ORDER BY TIMESTAMP DESC LIMIT 100 + """, (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}") - # Steps, battery, etc. (unchanged) + # Steps, battery, etc. try: - cursor.execute("SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, day_start_ts, now_ts)) + if self.device_type == 'xiaomi': + cursor.execute("SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, day_start_ts, now_ts)) + elif self.device_type == 'hybrid': + # Debug: Check what data exists + cursor.execute("SELECT COUNT(*), MIN(TIMESTAMP), MAX(TIMESTAMP) FROM HYBRID_HRACTIVITY_SAMPLE WHERE DEVICE_ID = ?", (self.device_id,)) + debug_row = cursor.fetchone() + if debug_row: + logger.info(f"Hybrid Debug: {debug_row[0]} total samples, timestamp range: {debug_row[1]} to {debug_row[2]}") + logger.info(f"Querying steps between {day_start_ts} and {now_ts}") + cursor.execute("SELECT SUM(STEPS) FROM HYBRID_HRACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, day_start_ts, now_ts)) + else: + try: + cursor.execute("SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, day_start_ts, now_ts)) + except: + cursor.execute("SELECT SUM(STEPS) FROM HYBRID_HRACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, day_start_ts, now_ts)) data["daily_steps"] = cursor.fetchone()[0] or 0 except Exception as e: logger.debug(f"Daily steps query failed: {e}") @@ -317,7 +411,16 @@ class GadgetbridgeMQTT: week_start_time = datetime.combine(week_start, datetime.min.time()).replace(hour=4) if now.date().weekday() == 0 and now.hour < 4: week_start_time -= timedelta(days=7) - cursor.execute("SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, int(week_start_time.timestamp()), now_ts)) + week_start_ts = int(week_start_time.timestamp()) + if self.device_type == 'xiaomi': + cursor.execute("SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, week_start_ts, now_ts)) + elif self.device_type == 'hybrid': + cursor.execute("SELECT SUM(STEPS) FROM HYBRID_HRACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, week_start_ts, now_ts)) + else: + try: + cursor.execute("SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, week_start_ts, now_ts)) + except: + cursor.execute("SELECT SUM(STEPS) FROM HYBRID_HRACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, week_start_ts, now_ts)) data["weekly_steps"] = cursor.fetchone()[0] or 0 except Exception as e: logger.debug(f"Weekly steps query failed: {e}") @@ -331,35 +434,76 @@ class GadgetbridgeMQTT: logger.debug(f"Battery query failed: {e}") try: - cursor.execute("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,)) + if self.device_type == 'xiaomi': + cursor.execute("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,)) + elif self.device_type == 'hybrid': + # Debug: Check for HR data + cursor.execute("SELECT COUNT(*), MAX(TIMESTAMP) FROM HYBRID_HRACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND HEART_RATE > 0", (self.device_id,)) + debug_row = cursor.fetchone() + if debug_row: + logger.info(f"Hybrid HR Debug: {debug_row[0]} HR samples, latest timestamp: {debug_row[1]}") + cursor.execute("SELECT HEART_RATE, TIMESTAMP FROM HYBRID_HRACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255 ORDER BY TIMESTAMP DESC LIMIT 1", (self.device_id,)) + else: + try: + cursor.execute("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,)) + except: + cursor.execute("SELECT HEART_RATE, TIMESTAMP FROM HYBRID_HRACTIVITY_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] - if row[1] > self.last_data_timestamp: - self.last_data_timestamp = row[1] + # Hybrid/Fossil uses seconds like Xiaomi + hr_timestamp = row[1] + if hr_timestamp > self.last_data_timestamp: + self.last_data_timestamp = hr_timestamp + else: + logger.info("No heart rate data found") except Exception as e: - logger.debug(f"Heart rate query failed: {e}") + logger.error(f"Heart rate query failed: {e}") 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", (self.device_id, day_midnight * 1000)) - row = cursor.fetchone() - if row: - if row[0]: data["hr_resting"] = row[0] - if row[1]: data["hr_max"] = row[1] - if row[2]: data["hr_avg"] = row[2] - if row[3]: data["calories"] = row[3] + if self.device_type == 'xiaomi': + 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", (self.device_id, day_midnight * 1000)) + row = cursor.fetchone() + if row: + if row[0]: data["hr_resting"] = row[0] + if row[1]: data["hr_max"] = row[1] + if row[2]: data["hr_avg"] = row[2] + if row[3]: data["calories"] = row[3] + elif self.device_type == 'hybrid': + # Calculate stats from HR samples and get calories for today + cursor.execute("SELECT MIN(HEART_RATE), MAX(HEART_RATE), AVG(HEART_RATE), SUM(CALORIES) FROM HYBRID_HRACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ?", (self.device_id, day_midnight)) + row = cursor.fetchone() + if row: + if row[0] and row[0] > 0: data["hr_resting"] = row[0] + if row[1] and row[1] < 255: data["hr_max"] = row[1] + if row[2]: data["hr_avg"] = int(row[2]) + if row[3]: data["calories"] = row[3] except Exception as e: logger.debug(f"Daily summary query failed: {e}") # Enhanced Sleep Data try: day_ago_ts_ms = (int(time.time()) - 24 * 3600) * 1000 - cursor.execute(""" - SELECT TIMESTAMP, WAKEUP_TIME, TOTAL_DURATION, - DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION - FROM XIAOMI_SLEEP_TIME_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? - ORDER BY TIMESTAMP DESC - """, (self.device_id, day_ago_ts_ms)) + if self.device_type == 'xiaomi': + cursor.execute(""" + SELECT TIMESTAMP, WAKEUP_TIME, TOTAL_DURATION, + DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION + FROM XIAOMI_SLEEP_TIME_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? + ORDER BY TIMESTAMP DESC + """, (self.device_id, day_ago_ts_ms)) + elif self.device_type == 'hybrid': + # Hybrid doesn't have dedicated sleep tables, skip for now + cursor.execute("SELECT NULL, NULL, NULL, NULL, NULL, NULL WHERE 1=0") + else: + try: + cursor.execute(""" + SELECT TIMESTAMP, WAKEUP_TIME, TOTAL_DURATION, + DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION + FROM XIAOMI_SLEEP_TIME_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? + ORDER BY TIMESTAMP DESC + """, (self.device_id, day_ago_ts_ms)) + except: + cursor.execute("SELECT NULL, NULL, NULL, NULL, NULL, NULL WHERE 1=0") sessions = cursor.fetchall() total_sleep_min = 0 @@ -386,12 +530,26 @@ class GadgetbridgeMQTT: data["sleep_duration"] = round(total_sleep_min / 60.0, 2) # Current session - cursor.execute(""" - 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 LIMIT 1 - """, (self.device_id, day_ago_ts_ms, now_ms)) + if self.device_type == 'xiaomi': + cursor.execute(""" + 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 LIMIT 1 + """, (self.device_id, day_ago_ts_ms, now_ms)) + elif self.device_type == 'hybrid': + # No sleep session data for Hybrid + cursor.execute("SELECT NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL WHERE 1=0") + else: + try: + cursor.execute(""" + 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 LIMIT 1 + """, (self.device_id, day_ago_ts_ms, now_ms)) + except: + cursor.execute("SELECT NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL WHERE 1=0") row = cursor.fetchone() if row: (total_duration, is_awake_flag, wakeup_raw, sleep_start_ms, deep_dur, light_dur, rem_dur, awake_dur) = row @@ -426,9 +584,10 @@ class GadgetbridgeMQTT: data["avg_recent_hr"] = round(avg_recent_hr, 1) if avg_recent_hr else None # Debug logging + stage_info = f"{stage_code}@{stage_age_minutes:.0f}min" if stage_timestamp_ms else 'none' + recent_hr_val = f"{avg_recent_hr:.0f}" if avg_recent_hr else 'N/A' logger.info(f"Sleep debug: in_session={in_sleep_session}, is_awake={is_awake}, " - f"stage={stage_code}@{stage_age_minutes:.0f}min if stage_timestamp_ms else 'none'}, " - f"hr_stage={hr_stage}, recent_hr={avg_recent_hr:.0f}, resting={resting_hr}") + f"stage={stage_info}, hr_stage={hr_stage}, recent_hr={recent_hr_val}, resting={resting_hr}") else: data["is_awake"] = True