use heart rate for sleep fallback

This commit is contained in:
Oliver 2025-12-17 13:34:26 +00:00
parent 13c551aaf4
commit 6a41d7675d

208
main.py
View File

@ -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,17 +157,27 @@ 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
# 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"""
if mqtt is None:
@ -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,14 +472,13 @@ 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
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:
# 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",
@ -363,12 +487,21 @@ class GadgetbridgeMQTT:
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
# 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()