diff --git a/main.py b/main.py index 31fc95b..9e295b8 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,11 @@ #!/usr/bin/env python3 + """ + Gadgetbridge MQTT Step Counter Integration + Extracts sensor data from Gadgetbridge SQLite database and publishes to Home Assistant via MQTT + """ import os @@ -13,7 +17,7 @@ from typing import Dict, Any import asyncio import aiomqtt import re - +import time class GadgetbridgeMQTTPublisher: def __init__(self): @@ -23,6 +27,8 @@ class GadgetbridgeMQTTPublisher: self.load_config() self.mqtt_client = None self.publish_interval = int(os.getenv("PUBLISH_INTERVAL_SECONDS", "300")) + self.max_retries = int(os.getenv("MAX_RETRIES", "5")) + self.retry_delay = int(os.getenv("RETRY_DELAY_SECONDS", "30")) self.sensors = [ { "name": "Daily Steps", @@ -167,6 +173,7 @@ class GadgetbridgeMQTTPublisher: self.logger.info(f"Published discovery config for {entity_id}") except Exception as e: self.logger.error(f"Failed to publish discovery config: {e}") + raise async def setup_home_assistant_entities(self): """Setup Home Assistant entities via MQTT discovery""" @@ -176,6 +183,7 @@ class GadgetbridgeMQTTPublisher: "model": "Fitness Tracker", "manufacturer": "Gadgetbridge", } + for sensor in self.sensors: config = { "name": f"{self.device_name.replace('_', ' ').title()} {sensor['name']}", @@ -183,10 +191,12 @@ class GadgetbridgeMQTTPublisher: "state_topic": sensor["state_topic"], "device": device_info, } + # Add optional fields if present for key in ["unit_of_measurement", "icon", "state_class", "device_class"]: if key in sensor: config[key] = sensor[key] + await self.publish_home_assistant_discovery( "sensor", sensor["unique_id"], config ) @@ -295,18 +305,22 @@ class GadgetbridgeMQTTPublisher: if not os.path.exists(self.db_path): self.logger.error(f"Database file not found: {self.db_path}") return {} + try: - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path, timeout=10.0) cursor = conn.cursor() data = {} + for sensor in self.sensors: try: data[sensor["unique_id"]] = sensor["query"](cursor) except Exception as e: self.logger.error(f"Error querying {sensor['unique_id']}: {e}") data[sensor["unique_id"]] = None + conn.close() return data + except Exception as e: self.logger.error(f"Error querying database: {e}") return {} @@ -322,64 +336,105 @@ class GadgetbridgeMQTTPublisher: ) except Exception as e: self.logger.error(f"Failed to publish {sensor['unique_id']}: {e}") + raise + self.logger.info(f"Published sensor data: {data}") - async def run(self): - """Main execution method (async)""" - self.logger.info("Starting Gadgetbridge MQTT Publisher") - try: - async with aiomqtt.Client( - hostname=self.mqtt_config["broker"], - port=self.mqtt_config["port"], - username=self.mqtt_config["username"] or None, - password=self.mqtt_config["password"] or None, - ) as client: - self.mqtt_client = client - await self.setup_home_assistant_entities() - # Publish immediately on startup - sensor_data = self.get_sensor_data() - await self.publish_sensor_data(sensor_data) - self.logger.info( - f"Sleeping for {self.publish_interval} seconds before next publish..." + async def mqtt_connect_with_retry(self): + """Connect to MQTT broker with retry logic""" + for attempt in range(self.max_retries): + try: + self.logger.info(f"Attempting MQTT connection (attempt {attempt + 1}/{self.max_retries})") + client = aiomqtt.Client( + hostname=self.mqtt_config["broker"], + port=self.mqtt_config["port"], + username=self.mqtt_config["username"] or None, + password=self.mqtt_config["password"] or None, ) - while True: - await asyncio.sleep(self.publish_interval) + # Test connection + await client.connect() + self.logger.info("MQTT connection successful") + return client + except Exception as e: + self.logger.error(f"MQTT connection attempt {attempt + 1} failed: {e}") + if attempt < self.max_retries - 1: + self.logger.info(f"Retrying in {self.retry_delay} seconds...") + await asyncio.sleep(self.retry_delay) + else: + self.logger.error("All MQTT connection attempts failed") + raise + + async def run_main_loop(self): + """Main execution loop with error recovery""" + while True: + try: + async with await self.mqtt_connect_with_retry() as client: + self.mqtt_client = client + await self.setup_home_assistant_entities() + + # Publish immediately on startup sensor_data = self.get_sensor_data() await self.publish_sensor_data(sensor_data) - self.logger.info( - f"Sleeping for {self.publish_interval} seconds before next publish..." - ) - except Exception as e: - self.logger.error(f"Failed to connect to MQTT broker: {e}") + self.logger.info(f"Sleeping for {self.publish_interval} seconds before next publish...") + + # Main publishing loop + while True: + await asyncio.sleep(self.publish_interval) + sensor_data = self.get_sensor_data() + await self.publish_sensor_data(sensor_data) + self.logger.info(f"Sleeping for {self.publish_interval} seconds before next publish...") + + except Exception as e: + self.logger.error(f"Error in main loop: {e}") + self.logger.info(f"Restarting main loop in {self.retry_delay} seconds...") + await asyncio.sleep(self.retry_delay) + + async def run(self): + """Main execution method (async) - now with proper error recovery""" + self.logger.info("Starting Gadgetbridge MQTT Publisher") + + # Initial database check + if not os.path.exists(self.db_path): + self.logger.error(f"Database file not found: {self.db_path}") + self.logger.error("Waiting for database to become available...") + while not os.path.exists(self.db_path): + await asyncio.sleep(30) + self.logger.info("Database file found, continuing...") + + # Run main loop with recovery + await self.run_main_loop() def get_device_alias(self) -> str: """Fetch ALIAS from DEVICE table for device_name where NAME contains 'band' or 'watch' (case-insensitive)""" if not os.path.exists(self.db_path): - self.logger.error(f"Database file not found: {self.db_path}") + self.logger.warning(f"Database file not found during init: {self.db_path}") return "fitness_tracker" + try: - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path, timeout=5.0) cursor = conn.cursor() cursor.execute( """ SELECT ALIAS FROM DEVICE WHERE LOWER(NAME) LIKE '%band%' OR LOWER(NAME) LIKE '%watch%' LIMIT 1 - """ + """ ) row = cursor.fetchone() conn.close() + if row and row[0]: # Sanitize alias for MQTT topics return re.sub(r"\W+", "_", row[0]).lower() else: return "fitness_tracker" + except Exception as e: self.logger.error(f"Error fetching device alias: {e}") return "fitness_tracker" - # --- Main Entry Point --- + if __name__ == "__main__": publisher = GadgetbridgeMQTTPublisher() - asyncio.run(publisher.run()) + asyncio.run(publisher.run()) \ No newline at end of file