Compare commits

...

10 Commits

Author SHA1 Message Date
ac829eacb0
Add support for Fossil Hybrid HR 2026-02-02 22:40:58 +01:00
Oliver
00b21abc88 improve sleep detection & add hr based calculation 2026-01-15 12:05:20 +00:00
Oliver Großkloß
e11b4564f0 Track sleep state and adjust data freshness checks for improved recovery logic 2025-12-26 20:10:14 +01:00
Oliver
8514180505 Add auto reconnect logic 2025-12-19 13:02:43 +00:00
Oliver
92ca129510 update calculation for sleep sensors 2025-12-18 11:48:21 +00:00
Oliver
6a41d7675d use heart rate for sleep fallback 2025-12-17 13:34:26 +00:00
Oliver Großkloß
13c551aaf4 Improve sleep state detection 2025-12-15 15:27:16 +01:00
Oliver Großkloß
05fddea91d Improve sleep state detection 2025-12-09 15:40:54 +01:00
Oliver Großkloß
592530aac2 fix sleep stage 2025-12-08 14:38:27 +01:00
Oliver
e7c00f6901 Update Readme.md 2025-12-08 12:13:07 +00:00
4 changed files with 496 additions and 187 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__

View File

@ -79,6 +79,8 @@ Config is stored at `~/.config/gadgetbridge_mqtt/config.json`:
} }
``` ```
**Security Note:** MQTT credentials are stored in plaintext. The setup script sets file permissions to `600` (owner read/write only), but be aware of this if sharing your device or backups.
## Published Sensors ## Published Sensors
| Sensor | Topic | Unit | | Sensor | Topic | Unit |
@ -93,6 +95,8 @@ Config is stored at `~/.config/gadgetbridge_mqtt/config.json`:
| Calories | `gadgetbridge/{device}/calories` | kcal | | Calories | `gadgetbridge/{device}/calories` | kcal |
| Sleep Duration | `gadgetbridge/{device}/sleep_duration` | hours | | Sleep Duration | `gadgetbridge/{device}/sleep_duration` | hours |
| Is Awake | `gadgetbridge/{device}/is_awake` | bool | | Is Awake | `gadgetbridge/{device}/is_awake` | bool |
| Sleep Stage | `gadgetbridge/{device}/sleep_stage` | text |
| Sleep Stage Code | `gadgetbridge/{device}/sleep_stage_code` | int |
| Weight | `gadgetbridge/{device}/weight` | kg | | Weight | `gadgetbridge/{device}/weight` | kg |
| Last Update | `gadgetbridge/{device}/server_time` | timestamp | | Last Update | `gadgetbridge/{device}/server_time` | timestamp |

597
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
@ -20,6 +21,9 @@ from pathlib import Path
try: try:
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
except ImportError: except ImportError:
if "PYTEST_CURRENT_TEST" in os.environ:
mqtt = None
else:
print("Error: paho-mqtt not installed. Run: pip install paho-mqtt") print("Error: paho-mqtt not installed. Run: pip install paho-mqtt")
sys.exit(1) sys.exit(1)
@ -27,6 +31,7 @@ except ImportError:
CONFIG_FILE = os.path.expanduser("~/.config/gadgetbridge_mqtt/config.json") CONFIG_FILE = os.path.expanduser("~/.config/gadgetbridge_mqtt/config.json")
GB_EXPORT_DIR = "/storage/emulated/0/Documents/GB_Export" GB_EXPORT_DIR = "/storage/emulated/0/Documents/GB_Export"
PUBLISH_INTERVAL = 300 # 5 minutes PUBLISH_INTERVAL = 300 # 5 minutes
STALE_THRESHOLD_SECONDS = 1200 # 20 minutes (Force reconnect if no new data for this long)
# --- Logging --- # --- Logging ---
logging.basicConfig( logging.basicConfig(
@ -48,21 +53,51 @@ 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 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_bluetooth_reconnect(device_mac):
"""Force a full disconnect cycle for zombie recovery."""
if not device_mac:
logger.warning("No device MAC address available for reconnection")
return False
logger.warning("Initiating Force Disconnect (Zombie Recovery)...")
send_gadgetbridge_intent(
"nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_DISCONNECT",
f"-e EXTRA_DEVICE_ADDRESS '{device_mac}'"
)
logger.info("Waiting 15s for Bluetooth stack to clear...")
time.sleep(15)
return True
def trigger_gadgetbridge_sync(device_mac=None):
"""Trigger Gadgetbridge to sync data from band and export database."""
if device_mac:
trigger_bluetooth_connect(device_mac)
time.sleep(10)
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)
# Then trigger database export
send_gadgetbridge_intent("nodomain.freeyourgadget.gadgetbridge.command.TRIGGER_EXPORT") send_gadgetbridge_intent("nodomain.freeyourgadget.gadgetbridge.command.TRIGGER_EXPORT")
@ -80,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]
@ -89,46 +123,94 @@ 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
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
self.running = True self.running = True
# Register signal handlers for graceful shutdown self.last_data_timestamp = 0
self.is_sleeping = False
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
def _compute_awake(is_awake_flag, wakeup_raw, stage_code, stage_timestamp_ms, now_ms, avg_recent_hr=None, resting_hr=None):
"""Enhanced awake logic: Strong bias to sleeping during active sessions."""
# 1. Past wakeup -> definitely awake
if wakeup_raw is not None and wakeup_raw <= now_ms:
return True
# 2. Active sleep session? Bias heavily to sleeping
in_session = wakeup_raw is not None and wakeup_raw > now_ms
if in_session:
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
return True
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
# 4. HR fallback (tighter threshold)
if avg_recent_hr is not None:
hr_threshold = (resting_hr or 60) + 5 if resting_hr else 60
if avg_recent_hr < hr_threshold:
return False
# 5. Default: awake
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:
# Use callback API version 2 to avoid deprecation warning logger.error("paho-mqtt not available")
return False
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
@ -137,35 +219,51 @@ 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 detect_device_type(self, cursor, device_id):
"""Get device ID and alias from database - picks device with most recent activity""" """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: 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 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 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]
logger.info(f"Selected device: ID={device_id}, Name={device_alias}") device_type = self.detect_device_type(cursor, device_id)
return device_id, device_alias 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: 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)"""
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)
@ -174,7 +272,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())
@ -183,156 +280,341 @@ 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)
# Daily Steps (filtered by device) # Sleep stage (first)
stage_code = None
stage_timestamp_ms = None
try: try:
cursor.execute( if self.device_type == 'xiaomi':
"SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", cursor.execute("""
(self.device_id, day_start_ts, now_ts) 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:
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]
except Exception as e:
logger.debug(f"Recent HR query failed: {e}")
# Resting HR
resting_hr = None
try:
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.
try:
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}")
# 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( week_start_ts = int(week_start_time.timestamp())
"SELECT SUM(STEPS) FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ?", if self.device_type == 'xiaomi':
(self.device_id, int(week_start_time.timestamp()), now_ts) 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}")
# 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)
try: try:
cursor.execute( if self.device_type == 'xiaomi':
"SELECT HEART_RATE FROM XIAOMI_ACTIVITY_SAMPLE WHERE DEVICE_ID = ? AND HEART_RATE > 0 AND HEART_RATE < 255 ORDER BY TIMESTAMP DESC LIMIT 1", 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,))
(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]
# 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: except Exception as e:
logger.debug(f"Heart rate query failed: {e}") logger.error(f"Heart rate query failed: {e}")
# Daily Summary Data (filtered by device)
try: try:
cursor.execute( if self.device_type == 'xiaomi':
"SELECT HR_RESTING, HR_MAX, HR_AVG, CALORIES FROM XIAOMI_DAILY_SUMMARY_SAMPLE WHERE DEVICE_ID = ? AND TIMESTAMP >= ? ORDER BY TIMESTAMP DESC LIMIT 1", 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))
(self.device_id, day_midnight)
)
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]
if row[1]: data["hr_max"] = row[1] if row[1]: data["hr_max"] = row[1]
if row[2]: data["hr_avg"] = row[2] if row[2]: data["hr_avg"] = row[2]
if row[3]: data["calories"] = row[3] 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}")
# Sleep Data (filtered by device) # Enhanced Sleep Data
try: try:
day_ago_ts = int(time.time()) - 24 * 3600 day_ago_ts_ms = (int(time.time()) - 24 * 3600) * 1000
if self.device_type == 'xiaomi':
cursor.execute( cursor.execute("""
""" SELECT TIMESTAMP, WAKEUP_TIME, TOTAL_DURATION,
SELECT TOTAL_DURATION, IS_AWAKE, WAKEUP_TIME 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
LIMIT 1 """, (self.device_id, day_ago_ts_ms))
""", elif self.device_type == 'hybrid':
(self.device_id, day_ago_ts) # 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
last_wakeup_ms = None
for sess_row in sessions:
sess_start, sess_wake, sess_total, sess_deep, sess_light, sess_rem = sess_row
if last_wakeup_ms is not None:
gap_hours = (last_wakeup_ms - sess_wake) / 1000 / 3600 if sess_wake else 999
if gap_hours > 2:
break
sess_min = 0
if sess_deep or sess_light or sess_rem:
sess_min = (sess_deep or 0) + (sess_light or 0) + (sess_rem or 0)
elif sess_total:
sess_min = sess_total
total_sleep_min += sess_min
last_wakeup_ms = sess_start
if total_sleep_min > 0:
data["sleep_duration"] = round(total_sleep_min / 60.0, 2)
# Current session
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() row = cursor.fetchone()
if row: if row:
total_duration, is_awake_flag, wakeup_raw = row (total_duration, is_awake_flag, wakeup_raw, sleep_start_ms, deep_dur, light_dur, rem_dur, awake_dur) = row
# Convert duration to hours in_sleep_session = sleep_start_ms and wakeup_raw and sleep_start_ms <= now_ms < wakeup_raw
if total_duration is not None: data["in_sleep_session"] = in_sleep_session
data["sleep_duration"] = round(total_duration / 60.0, 2)
# NULL means "not finalized", treat as False (not awake)
# 0 means explicitly "still asleep"
# 1 means explicitly "woke up"
if is_awake_flag is None:
is_awake = False # No data = not awake
else:
is_awake = (is_awake_flag == 1)
is_awake = self._compute_awake(
is_awake_flag, wakeup_raw, stage_code, stage_timestamp_ms, now_ms,
avg_recent_hr, resting_hr
)
data["is_awake"] = is_awake data["is_awake"] = is_awake
self.is_sleeping = not is_awake
# Primary sleep stage (relaxed recency: 24h)
stage_names = {0: "not_sleep", 1: "unknown", 2: "deep_sleep", 3: "light_sleep", 4: "rem_sleep", 5: "awake"}
if stage_code is not None and stage_timestamp_ms:
stage_age_minutes = (now_ms - stage_timestamp_ms) / 1000 / 60
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_code"] = stage_code
else:
data["sleep_stage"] = "not_sleep"
data["sleep_stage_code"] = 0
else:
data["sleep_stage"] = "not_sleep" if is_awake else "unknown"
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
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_info}, hr_stage={hr_stage}, recent_hr={recent_hr_val}, resting={resting_hr}")
else:
data["is_awake"] = True
data["in_sleep_session"] = False
data["sleep_stage"] = "not_sleep"
data["sleep_stage_code"] = 0
data["sleep_stage_hr"] = "awake"
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}")
# Sleep Stage Data (current stage) # Weight
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 = row
stage_names = {
0: "awake", 1: "light_sleep", 2: "deep_sleep",
3: "rem_sleep", 4: "deep_sleep_v2", 5: "light_sleep_v2"
}
data["sleep_stage"] = stage_names.get(stage_code, f"unknown_{stage_code}")
data["sleep_stage_code"] = stage_code
except Exception as e:
logger.debug(f"Sleep stage query failed: {e}")
# Weight (not device-specific, from scale)
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",
@ -349,11 +631,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),
("sleep_stage", "Sleep Stage", None, "mdi:sleep-cycle", "measurement", None), ("in_sleep_session", "In Sleep Session", None, "mdi:bed", 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"),
] ]
@ -379,18 +664,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():
if value is not None: # Skip None values
topic = f"gadgetbridge/{self.device_name}/{key}" topic = f"gadgetbridge/{self.device_name}/{key}"
# Use retain=True so HA gets values on restart
self.mqtt_client.publish(topic, str(value), qos=1, retain=True) self.mqtt_client.publish(topic, str(value), qos=1, retain=True)
logger.info(f"Published: steps={data.get('daily_steps', 'N/A')}, " sleep_info = f"sleep={data.get('sleep_duration', 0)}h" if 'sleep_duration' in data else "sleep=N/A"
f"hr={data.get('heart_rate', 'N/A')}, " awake_info = f"awake={data.get('is_awake', 'N/A')}"
f"battery={data.get('battery_level', 'N/A')}%") logger.info(f"Published: steps={data.get('daily_steps', 'N/A')}, hr={data.get('heart_rate', 'N/A')}, "
f"battery={data.get('battery_level', 'N/A')}%, {sleep_info}, {awake_info}, "
f"stage={data.get('sleep_stage', 'N/A')}/{data.get('sleep_stage_hr', 'N/A')}")
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
@ -399,15 +684,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 and actual name self.device_id, self.device_alias, self.device_mac = self.get_device_info(cursor)
self.device_id, self.device_alias = 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()
@ -420,19 +703,31 @@ class GadgetbridgeMQTT:
return False return False
def check_and_recover_connection(self):
if self.last_data_timestamp == 0:
return
threshold = STALE_THRESHOLD_SECONDS * 3 if self.is_sleeping else STALE_THRESHOLD_SECONDS
current_ts = int(time.time())
time_diff = current_ts - self.last_data_timestamp
if time_diff > threshold:
logger.warning(f"Data stale ({time_diff}s > {threshold}s). Triggering recovery...")
if self.device_mac:
trigger_bluetooth_reconnect(self.device_mac)
self.last_data_timestamp = current_ts
else:
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)
@ -443,33 +738,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:
self.check_and_recover_connection()
logger.info("Triggering Gadgetbridge sync...") logger.info("Triggering Gadgetbridge sync...")
trigger_gadgetbridge_sync() trigger_gadgetbridge_sync(self.device_mac)
time.sleep(5)
# Wait for export to complete
time.sleep(10)
# 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.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: {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()
@ -477,7 +770,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}")
@ -485,7 +778,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")

View File

@ -25,20 +25,22 @@ def print_banner():
def get_input(prompt, default=None, required=True): def get_input(prompt, default=None, required=True):
"""Get user input with optional default value""" """Get user input with optional default value (loop-based to avoid stack overflow)"""
while True:
if default: if default:
prompt = f"{prompt} [{default}]: " full_prompt = f"{prompt} [{default}]: "
else: else:
prompt = f"{prompt}: " full_prompt = f"{prompt}: "
value = input(prompt).strip() value = input(full_prompt).strip()
if not value and default: if value:
return value
if default:
return default return default
if not value and required: if required:
print("This field is required!") print("This field is required!")
return get_input(prompt.replace(f" [{default}]", "").replace(": ", ""), default, required) continue
return value return value
@ -62,13 +64,18 @@ def setup_mqtt():
def save_config(config): def save_config(config):
"""Save configuration to file""" """Save configuration to file with restricted permissions"""
os.makedirs(CONFIG_DIR, exist_ok=True) os.makedirs(CONFIG_DIR, exist_ok=True)
with open(CONFIG_FILE, "w") as f: with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2) json.dump(config, f, indent=2)
# Restrict file permissions (owner read/write only) for security
# Note: MQTT credentials are stored in plaintext
os.chmod(CONFIG_FILE, 0o600)
print(f"\n✓ Config saved to: {CONFIG_FILE}") print(f"\n✓ Config saved to: {CONFIG_FILE}")
print(" (Note: MQTT credentials stored in plaintext - file permissions set to 600)")
def setup_directories(): def setup_directories():
@ -148,12 +155,14 @@ def install_dependencies():
"""Install required Python packages""" """Install required Python packages"""
print("\n=== Installing Dependencies ===\n") print("\n=== Installing Dependencies ===\n")
try: # Use python -m pip to ensure we're using the correct Python interpreter
os.system("pip install paho-mqtt") exit_code = os.system(f"{sys.executable} -m pip install paho-mqtt")
if exit_code == 0:
print("✓ paho-mqtt installed") print("✓ paho-mqtt installed")
except Exception as e: else:
print(f"Failed to install paho-mqtt: {e}") print(f"pip install failed (exit code: {exit_code})")
print(" Run manually: pip install paho-mqtt") print(f" Run manually: {sys.executable} -m pip install paho-mqtt")
def print_gadgetbridge_instructions(export_dir): def print_gadgetbridge_instructions(export_dir):
@ -224,7 +233,9 @@ def main():
install_dependencies() install_dependencies()
# Download/copy main script # Download/copy main script
download_main_script() if not download_main_script():
print("\n✗ Setup aborted: main script is required.")
sys.exit(1)
# Create autostart # Create autostart
create_autostart() create_autostart()