improve sleep detection & add hr based calculation

This commit is contained in:
Oliver 2026-01-15 12:05:20 +00:00
parent e11b4564f0
commit 00b21abc88

381
main.py
View File

@ -3,6 +3,7 @@
""" """
Gadgetbridge MQTT Publisher for Termux Gadgetbridge MQTT Publisher for Termux
Watches for Gadgetbridge exports and publishes sensor data to Home Assistant via MQTT Watches for Gadgetbridge exports and publishes sensor data to Home Assistant via MQTT
Updated with improved sleep detection and HR-derived sleep stage
""" """
import os import os
@ -63,11 +64,7 @@ def send_gadgetbridge_intent(action, extra_args=""):
def trigger_bluetooth_connect(device_mac): def trigger_bluetooth_connect(device_mac):
"""Force Gadgetbridge to connect to the device via Bluetooth. """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: if not device_mac:
logger.warning("No device MAC address configured, skipping Bluetooth connect") logger.warning("No device MAC address configured, skipping Bluetooth connect")
return False return False
@ -78,46 +75,29 @@ def trigger_bluetooth_connect(device_mac):
def trigger_bluetooth_reconnect(device_mac): def trigger_bluetooth_reconnect(device_mac):
""" """Force a full disconnect cycle for zombie recovery."""
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: if not device_mac:
logger.warning("No device MAC address available for reconnection") logger.warning("No device MAC address available for reconnection")
return False return False
logger.warning("Initiating Force Disconnect (Zombie Recovery)...") logger.warning("Initiating Force Disconnect (Zombie Recovery)...")
# 1. Force Disconnect
send_gadgetbridge_intent( send_gadgetbridge_intent(
"nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_DISCONNECT", "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_DISCONNECT",
f"-e EXTRA_DEVICE_ADDRESS '{device_mac}'" f"-e EXTRA_DEVICE_ADDRESS '{device_mac}'"
) )
# Wait for the stack to clear
logger.info("Waiting 15s for Bluetooth stack to clear...") logger.info("Waiting 15s for Bluetooth stack to clear...")
time.sleep(15) time.sleep(15)
return True return True
def trigger_gadgetbridge_sync(device_mac=None): def trigger_gadgetbridge_sync(device_mac=None):
"""Trigger Gadgetbridge to sync data from band and export database. """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: if device_mac:
trigger_bluetooth_connect(device_mac) trigger_bluetooth_connect(device_mac)
time.sleep(10) # Wait for Bluetooth connection to establish time.sleep(10)
# Step 2: Sync activity data from band to phone
send_gadgetbridge_intent("nodomain.freeyourgadget.gadgetbridge.command.ACTIVITY_SYNC") send_gadgetbridge_intent("nodomain.freeyourgadget.gadgetbridge.command.ACTIVITY_SYNC")
time.sleep(10) # Wait for sync to complete time.sleep(10)
# Step 3: Trigger database export
send_gadgetbridge_intent("nodomain.freeyourgadget.gadgetbridge.command.TRIGGER_EXPORT") send_gadgetbridge_intent("nodomain.freeyourgadget.gadgetbridge.command.TRIGGER_EXPORT")
@ -135,7 +115,6 @@ def find_latest_db(export_dir):
if not db_files: if not db_files:
return None return None
# Return the most recently modified database
db_files.sort(key=lambda x: x[1], reverse=True) db_files.sort(key=lambda x: x[1], reverse=True)
return db_files[0][0] return db_files[0][0]
@ -144,100 +123,93 @@ class GadgetbridgeMQTT:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.db_path = None self.db_path = None
self.device_name = "fitness_tracker" # MQTT identifier (always fitness_tracker) self.device_name = "fitness_tracker"
self.device_alias = "Unknown" # Actual device name for display self.device_alias = "Unknown"
self.device_id = None # Track device ID for filtering queries self.device_id = None
self.device_mac = None # Track device MAC for Bluetooth reconnection self.device_mac = None
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
self.running = True self.running = True
# New: Track data freshness for watchdog
self.last_data_timestamp = 0 self.last_data_timestamp = 0
self.is_sleeping = False # Track sleep state to skip stale checks during sleep self.is_sleeping = False
# Register signal handlers for graceful shutdown
signal.signal(signal.SIGTERM, self._signal_handler) signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGINT, self._signal_handler) signal.signal(signal.SIGINT, self._signal_handler)
def _signal_handler(self, signum, frame): def _signal_handler(self, signum, frame):
"""Handle shutdown signals gracefully"""
logger.info(f"Received signal {signum}. Shutting down...") logger.info(f"Received signal {signum}. Shutting down...")
self.running = False self.running = False
@staticmethod @staticmethod
def _compute_awake(is_awake_flag, wakeup_raw, stage_code, stage_timestamp_ms, now_ms, avg_recent_hr=None, resting_hr=None): 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. """Enhanced awake logic: Strong bias to sleeping during active sessions."""
# 1. Past wakeup -> definitely awake
Priority:
1. If past wakeup time -> awake (session has ended)
2. If within sleep session (before wakeup) -> check stage data
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 within sleep session but no recent stage -> sleeping
6. Use HR as fallback: HR < (resting_hr + 10) suggests sleeping
7. Default: awake
Note: is_awake_flag indicates session has finished (!isSleepFinish in Gadgetbridge)
so we only use it as confirmation when past wakeup time
Stage codes for Xiaomi: 2=deep, 3=light, 4=REM, 5=awake
"""
# Priority 1: If we're past the wakeup time, the session is over -> awake
if wakeup_raw is not None and wakeup_raw <= now_ms: if wakeup_raw is not None and wakeup_raw <= now_ms:
return True return True
# Priority 2-5: Within a sleep session (wakeup time in the future) # 2. Active sleep session? Bias heavily to sleeping
in_session = wakeup_raw is not None and wakeup_raw > now_ms in_session = wakeup_raw is not None and wakeup_raw > now_ms
if in_session:
# Check if stage data is recent (within 2 hours) recent_stage = stage_timestamp_ms is not None and now_ms - stage_timestamp_ms <= 2 * 60 * 60 * 1000
recent_stage = stage_timestamp_ms is not None and now_ms - stage_timestamp_ms <= 2 * 60 * 60 * 1000 if recent_stage and stage_code == 5: # Recent AWAKE stage
if recent_stage and stage_code is not None:
if stage_code == 5: # AWAKE stage
return True return True
if stage_code in (2, 3, 4): # Deep(2), Light(3), REM(4) if recent_stage and stage_code in (2, 3, 4):
return False
# No recent stage in session -> assume sleeping (common for Xiaomi)
return False
# 3. Not in session: Use stages or HR (30min recency)
recent_stage = stage_timestamp_ms is not None and now_ms - stage_timestamp_ms <= 30 * 60 * 1000
if recent_stage:
if stage_code == 5:
return True
if stage_code in (2, 3, 4):
return False return False
# Priority 5: If within session but no recent stage data -> assume sleeping # 4. HR fallback (tighter threshold)
if in_session: if avg_recent_hr is not None:
return False hr_threshold = (resting_hr or 60) + 5 if resting_hr else 60
if avg_recent_hr < hr_threshold:
return False
# Priority 6: Use HR as fallback indicator (outside sessions) # 5. Default: awake
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
# Priority 7: Default to awake
return True return True
@staticmethod
def _hr_to_sleep_stage(avg_hr, resting_hr):
"""Derive sleep stage from HR (fallback when band stages unreliable)."""
if not avg_hr or not resting_hr:
return "unknown"
ratio = avg_hr / resting_hr
if ratio < 0.85:
return "deep_sleep"
elif ratio < 1.1:
return "light_sleep"
elif ratio < 1.3:
return "rem_sleep"
else:
return "awake"
def connect_mqtt(self): def connect_mqtt(self):
"""Connect to MQTT broker"""
if mqtt is None: if mqtt is None:
logger.error("paho-mqtt not available; cannot publish") logger.error("paho-mqtt not available")
return False return False
# Use callback API version 2 to avoid deprecation warning
try: try:
self.mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) self.mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
except (AttributeError, TypeError): except (AttributeError, TypeError):
# Fallback for older paho-mqtt versions
self.mqtt_client = mqtt.Client() self.mqtt_client = mqtt.Client()
if self.config.get("mqtt_username") and self.config.get("mqtt_password"): if self.config.get("mqtt_username") and self.config.get("mqtt_password"):
self.mqtt_client.username_pw_set( self.mqtt_client.username_pw_set(
self.config["mqtt_username"], self.config["mqtt_username"], self.config["mqtt_password"]
self.config["mqtt_password"]
) )
try: try:
self.mqtt_client.connect( self.mqtt_client.connect(self.config["mqtt_broker"], self.config.get("mqtt_port", 1883), 60)
self.config["mqtt_broker"],
self.config.get("mqtt_port", 1883),
60
)
self.mqtt_client.loop_start() self.mqtt_client.loop_start()
# Wait for connection to establish
time.sleep(1) time.sleep(1)
logger.info(f"Connected to MQTT broker: {self.config['mqtt_broker']}") logger.info(f"Connected to MQTT broker: {self.config['mqtt_broker']}")
return True return True
@ -246,28 +218,22 @@ class GadgetbridgeMQTT:
return False return False
def disconnect_mqtt(self): def disconnect_mqtt(self):
"""Disconnect from MQTT broker"""
if self.mqtt_client: if self.mqtt_client:
self.mqtt_client.loop_stop() self.mqtt_client.loop_stop()
self.mqtt_client.disconnect() self.mqtt_client.disconnect()
def get_device_info(self, cursor): def get_device_info(self, cursor):
"""Get device ID, alias, and MAC from database - picks device with most recent activity"""
try: try:
# Find the device with the most recent activity data
# This ensures we get the currently active band, not an old one
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%')
ORDER BY d._id DESC ORDER BY d._id DESC LIMIT 1
LIMIT 1
""") """)
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
device_id = row[0] device_id = row[0]
# Get actual device name for display device_alias = row[1] if row[1] else row[2]
device_alias = row[1] if row[1] else row[2] # Use ALIAS, fallback to NAME device_mac = row[3]
device_mac = row[3] # MAC address / IDENTIFIER
logger.info(f"Selected device: ID={device_id}, Name={device_alias}, MAC={device_mac}") logger.info(f"Selected device: ID={device_id}, Name={device_alias}, MAC={device_mac}")
return device_id, device_alias, device_mac return device_id, device_alias, device_mac
except Exception as e: except Exception as e:
@ -275,7 +241,6 @@ class GadgetbridgeMQTT:
return None, "Unknown", None return None, "Unknown", None
def get_day_start_timestamp(self): def get_day_start_timestamp(self):
"""Get timestamp for start of current day (4am)"""
now = datetime.now() now = datetime.now()
today = now.date() today = now.date()
day_start = datetime.combine(today, datetime.min.time()).replace(hour=4) day_start = datetime.combine(today, datetime.min.time()).replace(hour=4)
@ -284,7 +249,6 @@ class GadgetbridgeMQTT:
return int(day_start.timestamp()) return int(day_start.timestamp())
def get_day_midnight_timestamp(self): def get_day_midnight_timestamp(self):
"""Get midnight timestamp in seconds for daily summary queries"""
now = datetime.now() now = datetime.now()
today = now.date() today = now.date()
midnight = datetime.combine(today, datetime.min.time()) midnight = datetime.combine(today, datetime.min.time())
@ -293,131 +257,91 @@ class GadgetbridgeMQTT:
return int(midnight.timestamp()) return int(midnight.timestamp())
def query_sensors(self, cursor): def query_sensors(self, cursor):
"""Query all sensor data from database - filtered by device_id"""
data = {} data = {}
day_start_ts = self.get_day_start_timestamp() day_start_ts = self.get_day_start_timestamp()
now_ts = int(datetime.now().timestamp()) now_ts = int(datetime.now().timestamp())
day_midnight = self.get_day_midnight_timestamp() day_midnight = self.get_day_midnight_timestamp()
now_ms = int(time.time() * 1000) now_ms = int(time.time() * 1000)
# Query sleep stage FIRST (needed for is_awake calculation) # Sleep stage (first)
stage_code = None stage_code = None
stage_timestamp_ms = None stage_timestamp_ms = None
try: try:
cursor.execute( cursor.execute("""
""" SELECT STAGE, TIMESTAMP FROM XIAOMI_SLEEP_STAGE_SAMPLE
SELECT STAGE, TIMESTAMP WHERE DEVICE_ID = ? ORDER BY TIMESTAMP DESC LIMIT 1
FROM XIAOMI_SLEEP_STAGE_SAMPLE """, (self.device_id,))
WHERE DEVICE_ID = ?
ORDER BY TIMESTAMP DESC
LIMIT 1
""",
(self.device_id,)
)
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
stage_code, stage_timestamp_ms = row stage_code, stage_timestamp_ms = row
except Exception as e: except Exception as e:
logger.debug(f"Sleep stage query failed: {e}") logger.debug(f"Sleep stage query failed: {e}")
# Query average recent HR (last 10 minutes) for sleep detection fallback # Recent HR (5min now, tighter)
avg_recent_hr = None avg_recent_hr = None
try: try:
cursor.execute( cursor.execute("""
""" SELECT AVG(HEART_RATE) FROM XIAOMI_ACTIVITY_SAMPLE
SELECT AVG(HEART_RATE) WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255
FROM XIAOMI_ACTIVITY_SAMPLE AND TIMESTAMP >= ?
WHERE DEVICE_ID = ? """, (self.device_id, now_ts - 300)) # 5 minutes
AND HEART_RATE > 0 AND HEART_RATE < 255
AND TIMESTAMP >= ?
""",
(self.device_id, now_ts - 600) # Last 10 minutes
)
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]
except Exception as e: except Exception as e:
logger.debug(f"Recent HR query failed: {e}") logger.debug(f"Recent HR query failed: {e}")
# Query resting HR for dynamic sleep threshold # Resting HR
resting_hr = None resting_hr = None
try: try:
cursor.execute( cursor.execute("""
""" SELECT HR_RESTING FROM XIAOMI_DAILY_SUMMARY_SAMPLE
SELECT HR_RESTING WHERE DEVICE_ID = ? AND HR_RESTING > 0 ORDER BY TIMESTAMP DESC LIMIT 1
FROM XIAOMI_DAILY_SUMMARY_SAMPLE """, (self.device_id,))
WHERE DEVICE_ID = ? AND HR_RESTING > 0
ORDER BY TIMESTAMP DESC
LIMIT 1
""",
(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}")
# Daily Steps (filtered by device) # Steps, battery, etc. (unchanged)
try: try:
cursor.execute( cursor.execute("SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", (self.device_id, day_start_ts, now_ts))
"SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_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}")
# Weekly Steps (filtered by device)
try: try:
now = datetime.now() now = datetime.now()
week_start = now.date() - timedelta(days=now.date().weekday()) week_start = now.date() - timedelta(days=now.date().weekday())
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( 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))
"SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?",
(self.device_id, int(week_start_time.timestamp()), 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}")
# Battery Level (filtered by device)
try: try:
cursor.execute( cursor.execute("SELECT LEVEL FROM BATTERY_LEVEL WHERE DEVICE_ID = ? ORDER BY TIMESTAMP DESC LIMIT 1", (self.device_id,))
"SELECT LEVEL FROM BATTERY_LEVEL WHERE DEVICE_ID = ? ORDER BY TIMESTAMP DESC LIMIT 1",
(self.device_id,)
)
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
data["battery_level"] = row[0] data["battery_level"] = row[0]
except Exception as e: except Exception as e:
logger.debug(f"Battery query failed: {e}") logger.debug(f"Battery query failed: {e}")
# Latest Heart Rate (filtered by device)
# --- MODIFIED: Capture TIMESTAMP for watchdog ---
try: try:
cursor.execute( 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,))
"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() row = cursor.fetchone()
if row: if row:
data["heart_rate"] = row[0] data["heart_rate"] = row[0]
# Only update timestamp if DB has newer data than our last recovery
# This prevents the recovery loop when DB timestamp is stale
if row[1] > self.last_data_timestamp: if row[1] > self.last_data_timestamp:
self.last_data_timestamp = row[1] self.last_data_timestamp = row[1]
except Exception as e: except Exception as e:
logger.debug(f"Heart rate query failed: {e}") logger.debug(f"Heart rate query failed: {e}")
# Daily Summary Data (filtered by device)
try: try:
cursor.execute( 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))
"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) # Convert to milliseconds
)
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
if row[0]: data["hr_resting"] = row[0] if row[0]: data["hr_resting"] = row[0]
@ -427,20 +351,15 @@ class GadgetbridgeMQTT:
except Exception as e: except Exception as e:
logger.debug(f"Daily summary query failed: {e}") logger.debug(f"Daily summary query failed: {e}")
# Sleep Data (filtered by device) # Enhanced Sleep Data
try: try:
# SLEEP DURATION: Sum consecutive sleep sessions until there's a 2h+ gap
day_ago_ts_ms = (int(time.time()) - 24 * 3600) * 1000 day_ago_ts_ms = (int(time.time()) - 24 * 3600) * 1000
cursor.execute( cursor.execute("""
"""
SELECT TIMESTAMP, WAKEUP_TIME, TOTAL_DURATION, SELECT TIMESTAMP, WAKEUP_TIME, TOTAL_DURATION,
DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION
FROM XIAOMI_SLEEP_TIME_SAMPLE FROM XIAOMI_SLEEP_TIME_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ?
WHERE DEVICE_ID = ? AND TIMESTAMP >= ?
ORDER BY TIMESTAMP DESC ORDER BY TIMESTAMP DESC
""", """, (self.device_id, day_ago_ts_ms))
(self.device_id, day_ago_ts_ms)
)
sessions = cursor.fetchall() sessions = cursor.fetchall()
total_sleep_min = 0 total_sleep_min = 0
@ -466,56 +385,32 @@ class GadgetbridgeMQTT:
if total_sleep_min > 0: if total_sleep_min > 0:
data["sleep_duration"] = round(total_sleep_min / 60.0, 2) data["sleep_duration"] = round(total_sleep_min / 60.0, 2)
# IS_AWAKE / SLEEP_STAGE: Use the MOST RECENT session (within 24h) # Current session
cursor.execute( 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 DEEP_SLEEP_DURATION, LIGHT_SLEEP_DURATION, REM_SLEEP_DURATION, AWAKE_DURATION
FROM XIAOMI_SLEEP_TIME_SAMPLE FROM XIAOMI_SLEEP_TIME_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?
WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ? ORDER BY TIMESTAMP DESC LIMIT 1
ORDER BY TIMESTAMP DESC """, (self.device_id, day_ago_ts_ms, now_ms))
LIMIT 1
""",
(self.device_id, day_ago_ts_ms, now_ms)
)
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
(total_duration, is_awake_flag, wakeup_raw, sleep_start_ms, (total_duration, is_awake_flag, wakeup_raw, sleep_start_ms, deep_dur, light_dur, rem_dur, awake_dur) = row
deep_dur, light_dur, rem_dur, awake_dur) = row
in_sleep_session = ( in_sleep_session = sleep_start_ms and wakeup_raw and sleep_start_ms <= now_ms < wakeup_raw
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 data["in_sleep_session"] = in_sleep_session
is_awake = self._compute_awake( is_awake = self._compute_awake(
is_awake_flag=is_awake_flag, is_awake_flag, wakeup_raw, stage_code, stage_timestamp_ms, now_ms,
wakeup_raw=wakeup_raw, avg_recent_hr, resting_hr
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 data["is_awake"] = is_awake
# Update sleep state for watchdog (use longer stale threshold during sleep)
self.is_sleeping = not is_awake self.is_sleeping = not is_awake
stage_names = { # Primary sleep stage (relaxed recency: 24h)
0: "not_sleep", stage_names = {0: "not_sleep", 1: "unknown", 2: "deep_sleep", 3: "light_sleep", 4: "rem_sleep", 5: "awake"}
1: "unknown", if stage_code is not None and stage_timestamp_ms:
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 stage_age_minutes = (now_ms - stage_timestamp_ms) / 1000 / 60
if in_sleep_session or stage_age_minutes <= 120: if in_sleep_session or stage_age_minutes <= 1440: # 24h
data["sleep_stage"] = stage_names.get(stage_code, f"unknown_{stage_code}") data["sleep_stage"] = stage_names.get(stage_code, f"unknown_{stage_code}")
data["sleep_stage_code"] = stage_code data["sleep_stage_code"] = stage_code
else: else:
@ -524,36 +419,43 @@ class GadgetbridgeMQTT:
else: else:
data["sleep_stage"] = "not_sleep" if is_awake else "unknown" data["sleep_stage"] = "not_sleep" if is_awake else "unknown"
data["sleep_stage_code"] = 0 data["sleep_stage_code"] = 0
# NEW: HR-derived sleep stage (always available)
hr_stage = self._hr_to_sleep_stage(avg_recent_hr, resting_hr)
data["sleep_stage_hr"] = hr_stage
data["avg_recent_hr"] = round(avg_recent_hr, 1) if avg_recent_hr else None
# Debug logging
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}")
else: else:
# No sleep data - user is awake
data["is_awake"] = True data["is_awake"] = True
data["in_sleep_session"] = False data["in_sleep_session"] = False
data["sleep_stage"] = "not_sleep" data["sleep_stage"] = "not_sleep"
data["sleep_stage_code"] = 0 data["sleep_stage_code"] = 0
data["sleep_stage_hr"] = "awake"
self.is_sleeping = False self.is_sleeping = False
except Exception as e: except Exception as e:
logger.debug(f"Sleep query failed: {e}") logger.debug(f"Sleep query failed: {e}")
# Weight (not device-specific, from scale) # Weight
try: try:
cursor.execute("SELECT WEIGHT_KG FROM MI_SCALE_WEIGHT_SAMPLE ORDER BY TIMESTAMP DESC LIMIT 1") cursor.execute("SELECT WEIGHT_KG FROM MI_SCALE_WEIGHT_SAMPLE ORDER BY TIMESTAMP DESC LIMIT 1")
row = cursor.fetchone() row = cursor.fetchone()
if row and row[0]: if row and row[0]:
data["weight"] = round(row[0], 1) # Round to 1 decimal data["weight"] = round(row[0], 1)
except Exception as e: except Exception as e:
logger.debug(f"Weight query failed: {e}") logger.debug(f"Weight query failed: {e}")
# Server time
data["server_time"] = datetime.now().astimezone().isoformat() data["server_time"] = datetime.now().astimezone().isoformat()
# Device name (actual band name)
data["device"] = self.device_alias data["device"] = self.device_alias
return data return data
def publish_discovery(self): def publish_discovery(self):
"""Publish Home Assistant MQTT discovery configs"""
device_info = { device_info = {
"identifiers": [self.device_name], "identifiers": [self.device_name],
"name": "Fitness Tracker", "name": "Fitness Tracker",
@ -570,12 +472,14 @@ class GadgetbridgeMQTT:
("hr_resting", "Resting HR", "bpm", "mdi:heart-pulse", "measurement", None), ("hr_resting", "Resting HR", "bpm", "mdi:heart-pulse", "measurement", None),
("hr_max", "Max HR", "bpm", "mdi:heart-pulse", "measurement", None), ("hr_max", "Max HR", "bpm", "mdi:heart-pulse", "measurement", None),
("hr_avg", "Average HR", "bpm", "mdi:heart-pulse", "measurement", None), ("hr_avg", "Average HR", "bpm", "mdi:heart-pulse", "measurement", None),
("avg_recent_hr", "Recent Avg HR", "bpm", "mdi:heart-pulse", "measurement", None),
("calories", "Calories", "kcal", "mdi:fire", "total_increasing", None), ("calories", "Calories", "kcal", "mdi:fire", "total_increasing", None),
("sleep_duration", "Sleep Duration", "h", "mdi:sleep", "measurement", None), ("sleep_duration", "Sleep Duration", "h", "mdi:sleep", "measurement", None),
("is_awake", "Is Awake", None, "mdi:power-sleep", None, None), ("is_awake", "Is Awake", None, "mdi:power-sleep", None, None),
("in_sleep_session", "In Sleep Session", None, "mdi:bed", None, None), ("in_sleep_session", "In Sleep Session", None, "mdi:bed", None, None),
("sleep_stage", "Sleep Stage", None, "mdi:sleep-cycle", None, None), ("sleep_stage", "Sleep Stage (Band)", None, "mdi:sleep-cycle", None, None),
("sleep_stage_code", "Sleep Stage Code", None, "mdi:numeric", "measurement", None), ("sleep_stage_code", "Sleep Stage Code", None, "mdi:numeric", "measurement", None),
("sleep_stage_hr", "Sleep Stage (HR)", None, "mdi:heart-pulse-outline", None, None),
("weight", "Weight", "kg", "mdi:scale-bathroom", "measurement", None), ("weight", "Weight", "kg", "mdi:scale-bathroom", "measurement", None),
("server_time", "Last Update", None, "mdi:clock-outline", None, "timestamp"), ("server_time", "Last Update", None, "mdi:clock-outline", None, "timestamp"),
] ]
@ -601,23 +505,18 @@ class GadgetbridgeMQTT:
logger.info("Published Home Assistant discovery configs") logger.info("Published Home Assistant discovery configs")
def publish_data(self, data): def publish_data(self, data):
"""Publish sensor data to MQTT"""
for key, value in data.items(): for key, value in data.items():
topic = f"gadgetbridge/{self.device_name}/{key}" if value is not None: # Skip None values
# Use retain=True so HA gets values on restart topic = f"gadgetbridge/{self.device_name}/{key}"
self.mqtt_client.publish(topic, str(value), qos=1, retain=True) self.mqtt_client.publish(topic, str(value), qos=1, retain=True)
# Log key metrics including sleep status
sleep_info = f"sleep={data.get('sleep_duration', 0)}h" if 'sleep_duration' in data else "sleep=N/A" sleep_info = f"sleep={data.get('sleep_duration', 0)}h" if 'sleep_duration' in data else "sleep=N/A"
awake_info = f"awake={data.get('is_awake', 'N/A')}" awake_info = f"awake={data.get('is_awake', 'N/A')}"
logger.info(f"Published: steps={data.get('daily_steps', 'N/A')}, hr={data.get('heart_rate', 'N/A')}, "
logger.info(f"Published: steps={data.get('daily_steps', 'N/A')}, " f"battery={data.get('battery_level', 'N/A')}%, {sleep_info}, {awake_info}, "
f"hr={data.get('heart_rate', 'N/A')}, " f"stage={data.get('sleep_stage', 'N/A')}/{data.get('sleep_stage_hr', 'N/A')}")
f"battery={data.get('battery_level', 'N/A')}%, "
f"{sleep_info}, {awake_info}")
def process_database(self): def process_database(self):
"""Read database and publish data"""
if not self.db_path or not os.path.exists(self.db_path): if not self.db_path or not os.path.exists(self.db_path):
logger.warning("No database file found") logger.warning("No database file found")
return False return False
@ -626,15 +525,13 @@ class GadgetbridgeMQTT:
conn = sqlite3.connect(self.db_path, timeout=10.0) conn = sqlite3.connect(self.db_path, timeout=10.0)
cursor = conn.cursor() cursor = conn.cursor()
# Get device ID, name, and MAC
self.device_id, self.device_alias, self.device_mac = self.get_device_info(cursor) self.device_id, self.device_alias, self.device_mac = self.get_device_info(cursor)
if not self.device_id: if not self.device_id:
logger.warning("No fitness device found in database") logger.warning("No fitness device found")
conn.close() conn.close()
return False return False
# Query all sensors
data = self.query_sensors(cursor) data = self.query_sensors(cursor)
conn.close() conn.close()
@ -648,45 +545,30 @@ class GadgetbridgeMQTT:
return False return False
def check_and_recover_connection(self): def check_and_recover_connection(self):
"""
WATCHDOG: Checks if data is stale (>20 mins) and forces Bluetooth restart if so.
During sleep, the band syncs data less frequently, so we use a longer
threshold (60 mins) to avoid false recovery triggers.
"""
if self.last_data_timestamp == 0: if self.last_data_timestamp == 0:
return # No data seen yet, skip check return
# Use longer threshold during sleep (band syncs less frequently)
threshold = STALE_THRESHOLD_SECONDS * 3 if self.is_sleeping else STALE_THRESHOLD_SECONDS threshold = STALE_THRESHOLD_SECONDS * 3 if self.is_sleeping else STALE_THRESHOLD_SECONDS
current_ts = int(time.time()) current_ts = int(time.time())
time_diff = current_ts - self.last_data_timestamp time_diff = current_ts - self.last_data_timestamp
if time_diff > threshold: if time_diff > threshold:
logger.warning(f"Data stale ({time_diff}s > {threshold}s). Triggering recovery...") logger.warning(f"Data stale ({time_diff}s > {threshold}s). Triggering recovery...")
if self.device_mac: if self.device_mac:
# 1. Disconnect and wait
trigger_bluetooth_reconnect(self.device_mac) trigger_bluetooth_reconnect(self.device_mac)
# 2. Reset timestamp so we don't loop immediately
self.last_data_timestamp = current_ts self.last_data_timestamp = current_ts
else: else:
logger.warning("Cannot recover: No device MAC available") logger.warning("Cannot recover: No device MAC")
def run(self): def run(self):
"""Main loop"""
export_dir = self.config.get("export_dir", GB_EXPORT_DIR) export_dir = self.config.get("export_dir", GB_EXPORT_DIR)
interval = self.config.get("publish_interval", PUBLISH_INTERVAL) interval = self.config.get("publish_interval", PUBLISH_INTERVAL)
logger.info(f"Starting Gadgetbridge MQTT Publisher") logger.info(f"Starting Gadgetbridge MQTT Publisher (Enhanced Sleep)")
logger.info(f"Export directory: {export_dir}") logger.info(f"Export directory: {export_dir}, interval: {interval}s")
logger.info(f"Publish interval: {interval}s")
# Ensure export directory exists
os.makedirs(export_dir, exist_ok=True) os.makedirs(export_dir, exist_ok=True)
# Connect to MQTT
if not self.connect_mqtt(): if not self.connect_mqtt():
logger.error("Failed to connect to MQTT. Exiting.") logger.error("Failed to connect to MQTT. Exiting.")
sys.exit(1) sys.exit(1)
@ -697,40 +579,31 @@ class GadgetbridgeMQTT:
while self.running: while self.running:
current_time = time.time() current_time = time.time()
# Check if it's time to trigger sync and publish
if current_time - self.last_publish_time >= interval: if current_time - self.last_publish_time >= interval:
try: try:
# 1. WATCHDOG (New)
self.check_and_recover_connection() self.check_and_recover_connection()
logger.info("Triggering Gadgetbridge sync...") logger.info("Triggering Gadgetbridge sync...")
# 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) trigger_gadgetbridge_sync(self.device_mac)
# Wait for export to complete
time.sleep(5) time.sleep(5)
# Find latest database with retry
self.db_path = find_latest_db(export_dir) self.db_path = find_latest_db(export_dir)
if not self.db_path: if not self.db_path:
logger.warning(f"No database found, retrying in 3s...") logger.warning("No database found, retrying...")
time.sleep(3) time.sleep(3)
self.db_path = find_latest_db(export_dir) self.db_path = find_latest_db(export_dir)
if self.db_path: if self.db_path:
logger.info(f"Using database: {os.path.basename(self.db_path)}") logger.info(f"Using database: {os.path.basename(self.db_path)}")
# Publish discovery on first successful read
if not discovery_published: if not discovery_published:
# Need to read device info first
try: try:
conn = sqlite3.connect(self.db_path, timeout=5.0) conn = sqlite3.connect(self.db_path, timeout=5.0)
cursor = conn.cursor() cursor = conn.cursor()
self.device_id, self.device_alias, self.device_mac = self.get_device_info(cursor) self.device_id, self.device_alias, self.device_mac = self.get_device_info(cursor)
conn.close() conn.close()
if self.device_mac: if self.device_mac:
logger.info(f"Device MAC for reconnection: {self.device_mac}") logger.info(f"Device MAC: {self.device_mac}")
except Exception as e: except Exception as e:
logger.warning(f"Could not read device info: {e}") logger.warning(f"Could not read device info: {e}")
self.publish_discovery() self.publish_discovery()
@ -738,7 +611,7 @@ class GadgetbridgeMQTT:
self.process_database() self.process_database()
else: else:
logger.warning(f"No database found in {export_dir}") logger.warning(f"No database in {export_dir}")
except Exception as e: except Exception as e:
logger.error(f"Sync cycle failed: {e}") logger.error(f"Sync cycle failed: {e}")
@ -746,7 +619,7 @@ class GadgetbridgeMQTT:
self.last_publish_time = current_time self.last_publish_time = current_time
logger.info(f"Next publish in {interval}s...") logger.info(f"Next publish in {interval}s...")
time.sleep(10) # Check every 10 seconds time.sleep(10)
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Interrupted by user") logger.info("Interrupted by user")