use heart rate for sleep fallback
This commit is contained in:
parent
13c551aaf4
commit
6a41d7675d
208
main.py
208
main.py
@ -51,21 +51,46 @@ def load_config():
|
|||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
def send_gadgetbridge_intent(action):
|
def send_gadgetbridge_intent(action, extra_args=""):
|
||||||
"""Send broadcast intent to Gadgetbridge via Termux API"""
|
"""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}")
|
logger.info(f"Sending intent: {action}")
|
||||||
result = os.system(cmd)
|
result = os.system(cmd)
|
||||||
if result != 0:
|
if result != 0:
|
||||||
logger.warning(f"Intent may have failed (exit code: {result})")
|
logger.warning(f"Intent may have failed (exit code: {result})")
|
||||||
|
return result == 0
|
||||||
|
|
||||||
|
|
||||||
def trigger_gadgetbridge_sync():
|
def trigger_bluetooth_connect(device_mac):
|
||||||
"""Trigger Gadgetbridge to sync data from band and export database"""
|
"""Force Gadgetbridge to connect to the device via Bluetooth.
|
||||||
# First sync data from the band
|
|
||||||
|
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")
|
send_gadgetbridge_intent("nodomain.freeyourgadget.gadgetbridge.command.ACTIVITY_SYNC")
|
||||||
time.sleep(5) # Give it time to sync
|
time.sleep(10) # Wait for sync to complete
|
||||||
# Then trigger database export
|
|
||||||
|
# Step 3: Trigger database export
|
||||||
send_gadgetbridge_intent("nodomain.freeyourgadget.gadgetbridge.command.TRIGGER_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_name = "fitness_tracker" # MQTT identifier (always fitness_tracker)
|
||||||
self.device_alias = "Unknown" # Actual device name for display
|
self.device_alias = "Unknown" # Actual device name for display
|
||||||
self.device_id = None # Track device ID for filtering queries
|
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.mqtt_client = None
|
||||||
self.last_publish_time = 0
|
self.last_publish_time = 0
|
||||||
self.last_db_mtime = 0
|
self.last_db_mtime = 0
|
||||||
@ -110,15 +136,19 @@ class GadgetbridgeMQTT:
|
|||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""Decide awake state using most reliable signals first.
|
||||||
|
|
||||||
Priority:
|
Priority:
|
||||||
1. If past wakeup time -> awake (session ended)
|
1. If past wakeup time -> awake (session ended)
|
||||||
2. If is_awake_flag set -> awake
|
2. If is_awake_flag explicitly set to 1 -> awake
|
||||||
3. If recent sleep stage shows awake -> awake
|
3. If recent sleep stage shows awake (code 5) -> awake
|
||||||
4. If recent sleep stage shows sleep (deep/light/REM) -> sleeping
|
4. If recent sleep stage shows sleep (2=deep, 3=light, 4=REM) -> sleeping
|
||||||
5. Default: awake
|
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 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:
|
||||||
@ -127,17 +157,27 @@ class GadgetbridgeMQTT:
|
|||||||
if is_awake_flag == 1:
|
if is_awake_flag == 1:
|
||||||
return True
|
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 recent_stage and stage_code is not None:
|
||||||
if stage_code == 5: # AWAKE
|
if stage_code == 5: # AWAKE stage
|
||||||
return True
|
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
|
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
|
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):
|
def connect_mqtt(self):
|
||||||
"""Connect to MQTT broker"""
|
"""Connect to MQTT broker"""
|
||||||
if mqtt is None:
|
if mqtt is None:
|
||||||
@ -178,12 +218,12 @@ class GadgetbridgeMQTT:
|
|||||||
self.mqtt_client.disconnect()
|
self.mqtt_client.disconnect()
|
||||||
|
|
||||||
def get_device_info(self, cursor):
|
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:
|
try:
|
||||||
# Find the device with the most recent activity data
|
# Find the device with the most recent activity data
|
||||||
# This ensures we get the currently active band, not an old one
|
# This ensures we get the currently active band, not an old one
|
||||||
cursor.execute("""
|
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%')
|
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
|
||||||
@ -193,11 +233,12 @@ class GadgetbridgeMQTT:
|
|||||||
device_id = row[0]
|
device_id = row[0]
|
||||||
# Get actual device name for display
|
# Get actual device name for display
|
||||||
device_alias = row[1] if row[1] else row[2] # Use ALIAS, fallback to NAME
|
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}")
|
device_mac = row[3] # MAC address / IDENTIFIER
|
||||||
return device_id, device_alias
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error getting device info: {e}")
|
logger.error(f"Error getting device info: {e}")
|
||||||
return None, "Unknown"
|
return None, "Unknown", None
|
||||||
|
|
||||||
def get_day_start_timestamp(self):
|
def get_day_start_timestamp(self):
|
||||||
"""Get timestamp for start of current day (4am)"""
|
"""Get timestamp for start of current day (4am)"""
|
||||||
@ -245,6 +286,44 @@ class GadgetbridgeMQTT:
|
|||||||
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
|
||||||
|
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)
|
# Daily Steps (filtered by device)
|
||||||
try:
|
try:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
@ -317,7 +396,8 @@ class GadgetbridgeMQTT:
|
|||||||
|
|
||||||
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
|
||||||
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
|
ORDER BY TIMESTAMP DESC
|
||||||
@ -327,10 +407,55 @@ class GadgetbridgeMQTT:
|
|||||||
)
|
)
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row:
|
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:
|
# Calculate sleep duration using multiple strategies
|
||||||
data["sleep_duration"] = round(total_duration / 60.0, 2)
|
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
|
# Determine if currently in a sleep session
|
||||||
# User is sleeping if: sleep_start <= now < wakeup_time
|
# User is sleeping if: sleep_start <= now < wakeup_time
|
||||||
@ -347,14 +472,13 @@ class GadgetbridgeMQTT:
|
|||||||
stage_code=stage_code,
|
stage_code=stage_code,
|
||||||
stage_timestamp_ms=stage_timestamp_ms,
|
stage_timestamp_ms=stage_timestamp_ms,
|
||||||
now_ms=now_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
|
||||||
|
|
||||||
# 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
|
||||||
if stage_code is not None and stage_timestamp_ms is not None:
|
# Stage mapping for Xiaomi devices (verified from database)
|
||||||
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 = {
|
stage_names = {
|
||||||
0: "not_sleep",
|
0: "not_sleep",
|
||||||
1: "unknown",
|
1: "unknown",
|
||||||
@ -363,12 +487,21 @@ class GadgetbridgeMQTT:
|
|||||||
4: "rem_sleep",
|
4: "rem_sleep",
|
||||||
5: "awake"
|
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"] = stage_names.get(stage_code, f"unknown_{stage_code}")
|
||||||
data["sleep_stage_code"] = stage_code
|
data["sleep_stage_code"] = stage_code
|
||||||
else:
|
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"] = "not_sleep"
|
||||||
data["sleep_stage_code"] = 0
|
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:
|
else:
|
||||||
# No sleep data - user is awake
|
# No sleep data - user is awake
|
||||||
data["is_awake"] = True
|
data["is_awake"] = True
|
||||||
@ -465,8 +598,8 @@ 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 and actual name
|
# Get device ID, name, and MAC
|
||||||
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)
|
||||||
|
|
||||||
if not self.device_id:
|
if not self.device_id:
|
||||||
logger.warning("No fitness device found in database")
|
logger.warning("No fitness device found in database")
|
||||||
@ -513,10 +646,11 @@ class GadgetbridgeMQTT:
|
|||||||
if current_time - self.last_publish_time >= interval:
|
if current_time - self.last_publish_time >= interval:
|
||||||
try:
|
try:
|
||||||
logger.info("Triggering Gadgetbridge sync...")
|
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
|
# Wait for export to complete
|
||||||
time.sleep(10)
|
time.sleep(5)
|
||||||
|
|
||||||
# Find latest database with retry
|
# Find latest database with retry
|
||||||
self.db_path = find_latest_db(export_dir)
|
self.db_path = find_latest_db(export_dir)
|
||||||
@ -534,8 +668,10 @@ class GadgetbridgeMQTT:
|
|||||||
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.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:
|
||||||
|
logger.info(f"Device MAC for reconnection: {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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user