Add support for Fossil Hybrid HR

This commit is contained in:
Adrian Groh 2026-02-02 22:40:58 +01:00
parent 00b21abc88
commit ac829eacb0
Signed by: Gobidev
GPG Key ID: 3AA3153E98B0D771

251
main.py
View File

@ -127,6 +127,7 @@ class GadgetbridgeMQTT:
self.device_alias = "Unknown" self.device_alias = "Unknown"
self.device_id = None self.device_id = None
self.device_mac = None self.device_mac = None
self.device_type = "unknown"
self.mqtt_client = None self.mqtt_client = None
self.last_publish_time = 0 self.last_publish_time = 0
self.last_db_mtime = 0 self.last_db_mtime = 0
@ -222,11 +223,31 @@ class GadgetbridgeMQTT:
self.mqtt_client.loop_stop() self.mqtt_client.loop_stop()
self.mqtt_client.disconnect() 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): def get_device_info(self, cursor):
try: try:
cursor.execute(""" cursor.execute("""
SELECT d._id, d.ALIAS, d.NAME, d.IDENTIFIER 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%') 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 ORDER BY d._id DESC LIMIT 1
""") """)
row = cursor.fetchone() row = cursor.fetchone()
@ -234,7 +255,9 @@ class GadgetbridgeMQTT:
device_id = row[0] device_id = row[0]
device_alias = row[1] if row[1] else row[2] device_alias = row[1] if row[1] else row[2]
device_mac = row[3] 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 return device_id, device_alias, device_mac
except Exception as e: except Exception as e:
logger.error(f"Error getting device info: {e}") logger.error(f"Error getting device info: {e}")
@ -267,24 +290,61 @@ class GadgetbridgeMQTT:
stage_code = None stage_code = None
stage_timestamp_ms = None stage_timestamp_ms = None
try: try:
cursor.execute(""" if self.device_type == 'xiaomi':
SELECT STAGE, TIMESTAMP FROM XIAOMI_SLEEP_STAGE_SAMPLE cursor.execute("""
WHERE DEVICE_ID = ? ORDER BY TIMESTAMP DESC LIMIT 1 SELECT STAGE, TIMESTAMP FROM XIAOMI_SLEEP_STAGE_SAMPLE
""", (self.device_id,)) WHERE DEVICE_ID = ? ORDER BY TIMESTAMP DESC LIMIT 1
row = cursor.fetchone() """, (self.device_id,))
if row: row = cursor.fetchone()
stage_code, stage_timestamp_ms = row 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: except Exception as e:
logger.debug(f"Sleep stage query failed: {e}") logger.debug(f"Sleep stage query failed: {e}")
# Recent HR (5min now, tighter) # Recent HR (5min now, tighter)
avg_recent_hr = None avg_recent_hr = None
try: try:
cursor.execute(""" if self.device_type == 'xiaomi':
SELECT AVG(HEART_RATE) FROM XIAOMI_ACTIVITY_SAMPLE cursor.execute("""
WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255 SELECT AVG(HEART_RATE) FROM XIAOMI_ACTIVITY_SAMPLE
AND TIMESTAMP >= ? WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255
""", (self.device_id, now_ts - 300)) # 5 minutes 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() row = cursor.fetchone()
if row and row[0]: if row and row[0]:
avg_recent_hr = row[0] avg_recent_hr = row[0]
@ -294,19 +354,53 @@ class GadgetbridgeMQTT:
# Resting HR # Resting HR
resting_hr = None resting_hr = None
try: try:
cursor.execute(""" if self.device_type == 'xiaomi':
SELECT HR_RESTING FROM XIAOMI_DAILY_SUMMARY_SAMPLE cursor.execute("""
WHERE DEVICE_ID = ? AND HR_RESTING > 0 ORDER BY TIMESTAMP DESC LIMIT 1 SELECT HR_RESTING FROM XIAOMI_DAILY_SUMMARY_SAMPLE
""", (self.device_id,)) 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() row = cursor.fetchone()
if row and row[0]: if row and row[0]:
resting_hr = row[0] resting_hr = row[0]
except Exception as e: except Exception as e:
logger.debug(f"Resting HR query failed: {e}") logger.debug(f"Resting HR query failed: {e}")
# Steps, battery, etc. (unchanged) # Steps, battery, etc.
try: 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 data["daily_steps"] = cursor.fetchone()[0] or 0
except Exception as e: except Exception as e:
logger.debug(f"Daily steps query failed: {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) week_start_time = datetime.combine(week_start, datetime.min.time()).replace(hour=4)
if now.date().weekday() == 0 and now.hour < 4: if now.date().weekday() == 0 and now.hour < 4:
week_start_time -= timedelta(days=7) 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 data["weekly_steps"] = cursor.fetchone()[0] or 0
except Exception as e: except Exception as e:
logger.debug(f"Weekly steps query failed: {e}") logger.debug(f"Weekly steps query failed: {e}")
@ -331,35 +434,76 @@ class GadgetbridgeMQTT:
logger.debug(f"Battery query failed: {e}") logger.debug(f"Battery query failed: {e}")
try: 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() row = cursor.fetchone()
if row: if row:
data["heart_rate"] = row[0] data["heart_rate"] = row[0]
if row[1] > self.last_data_timestamp: # Hybrid/Fossil uses seconds like Xiaomi
self.last_data_timestamp = row[1] 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: except Exception as e:
logger.debug(f"Heart rate query failed: {e}") logger.error(f"Heart rate query failed: {e}")
try: 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)) if self.device_type == 'xiaomi':
row = cursor.fetchone() 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))
if row: row = cursor.fetchone()
if row[0]: data["hr_resting"] = row[0] if row:
if row[1]: data["hr_max"] = row[1] if row[0]: data["hr_resting"] = row[0]
if row[2]: data["hr_avg"] = row[2] if row[1]: data["hr_max"] = row[1]
if row[3]: data["calories"] = row[3] 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: except Exception as e:
logger.debug(f"Daily summary query failed: {e}") logger.debug(f"Daily summary query failed: {e}")
# Enhanced Sleep Data # Enhanced Sleep Data
try: try:
day_ago_ts_ms = (int(time.time()) - 24 * 3600) * 1000 day_ago_ts_ms = (int(time.time()) - 24 * 3600) * 1000
cursor.execute(""" if self.device_type == 'xiaomi':
SELECT TIMESTAMP, WAKEUP_TIME, TOTAL_DURATION, cursor.execute("""
DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION SELECT TIMESTAMP, WAKEUP_TIME, TOTAL_DURATION,
FROM XIAOMI_SLEEP_TIME_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION
ORDER BY TIMESTAMP DESC FROM XIAOMI_SLEEP_TIME_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ?
""", (self.device_id, day_ago_ts_ms)) 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() sessions = cursor.fetchall()
total_sleep_min = 0 total_sleep_min = 0
@ -386,12 +530,26 @@ class GadgetbridgeMQTT:
data["sleep_duration"] = round(total_sleep_min / 60.0, 2) data["sleep_duration"] = round(total_sleep_min / 60.0, 2)
# Current session # Current session
cursor.execute(""" if self.device_type == 'xiaomi':
SELECT TOTAL_DURATION, IS_AWAKE, WAKEUP_TIME, TIMESTAMP, cursor.execute("""
DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION, AWAKE_DURATION SELECT TOTAL_DURATION, IS_AWAKE, WAKEUP_TIME, TIMESTAMP,
FROM XIAOMI_SLEEP_TIME_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ? DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION, AWAKE_DURATION
ORDER BY TIMESTAMP DESC LIMIT 1 FROM XIAOMI_SLEEP_TIME_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?
""", (self.device_id, day_ago_ts_ms, now_ms)) 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() row = cursor.fetchone()
if row: if row:
(total_duration, is_awake_flag, wakeup_raw, sleep_start_ms, deep_dur, light_dur, rem_dur, awake_dur) = 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 data["avg_recent_hr"] = round(avg_recent_hr, 1) if avg_recent_hr else None
# Debug logging # 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}, " 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"stage={stage_info}, hr_stage={hr_stage}, recent_hr={recent_hr_val}, resting={resting_hr}")
f"hr_stage={hr_stage}, recent_hr={avg_recent_hr:.0f}, resting={resting_hr}")
else: else:
data["is_awake"] = True data["is_awake"] = True