diff --git a/backend/main.py b/backend/main.py index c91fde0..9b501c3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,7 @@ from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Union import RPi.GPIO as GPIO from mqtt_integration import HomeAssistantMQTT @@ -19,26 +19,72 @@ from mqtt_integration import HomeAssistantMQTT # Configure logging LOG_FILE = "/var/log/gatekeeper.log" logger = logging.getLogger("gatekeeper") -logger.setLevel(logging.INFO) -# Create rotating file handler (10MB per file, keep 5 backup files) -file_handler = RotatingFileHandler( - LOG_FILE, - maxBytes=10*1024*1024, # 10MB - backupCount=5, - delay=True # Only create log file when first record is written -) -file_handler.setLevel(logging.INFO) +# Models +class GateEvent(BaseModel): + id: Optional[int] = None + timestamp: str + action: str + source: str + success: bool -# Create formatter and add it to the handler -formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) -file_handler.setFormatter(formatter) +class MQTTSettings(BaseModel): + broker: str = "localhost" + port: str = "1883" + username: str = "" + password: str = "" + clientId: str = "gatekeeper" + enabled: bool = False -# Add the handler to the logger -logger.addHandler(file_handler) +class GPIOSettings(BaseModel): + gatePin: int = 17 # Default GPIO pin for gate control + statusPin: int = 27 # Default GPIO pin for gate status + +class LoggingSettings(BaseModel): + level: str = "WARNING" # Default to WARNING level + maxBytes: int = 10 * 1024 * 1024 # 10MB default + backupCount: int = 5 # Keep 5 backup files by default + +class Settings(BaseModel): + maxOpenTimeSeconds: int = 300 # Default 5 minutes + triggerDuration: int = 500 # Default 500ms + mqtt: MQTTSettings = MQTTSettings() + gpio: GPIOSettings = GPIOSettings() + logging: LoggingSettings = LoggingSettings() + +class GateStatus(BaseModel): + isOpen: bool + lastChanged: str + +# Configure logging +def setup_logging(settings: Settings): + """Configure logging based on settings""" + log_level = getattr(logging, settings.logging.level.upper(), logging.WARNING) + logger.setLevel(log_level) + + # Remove existing handlers + for handler in logger.handlers[:]: + logger.removeHandler(handler) + + # Create rotating file handler + file_handler = RotatingFileHandler( + LOG_FILE, + maxBytes=settings.logging.maxBytes, + backupCount=settings.logging.backupCount, + delay=True # Only create log file when first record is written + ) + file_handler.setLevel(log_level) + + # Create formatter and add it to the handler + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + file_handler.setFormatter(formatter) + + # Add the handler to the logger + logger.addHandler(file_handler) + logger.debug("Logging configured with level: %s", settings.logging.level) # Log uncaught exceptions def handle_exception(exc_type, exc_value, exc_traceback): @@ -55,11 +101,14 @@ sys.excepthook = handle_exception app = FastAPI() ha_mqtt = HomeAssistantMQTT() +# Constants +DB_PATH = "gate.db" # Database file path + # Set up MQTT event logging async def log_mqtt_event(action: str, success: bool = True): """Log MQTT events to the database and log file""" logger.info(f"MQTT Event - {action} (Success: {success})") - async with aiosqlite.connect("gate.db") as db: + async with await get_db() as db: await db.execute( "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", (datetime.utcnow().isoformat(), action, "MQTT", success) @@ -77,203 +126,170 @@ app.add_middleware( allow_headers=["*"], ) -# Constants -DB_PATH = "gate.db" -RELAY_1_PIN = int(os.getenv("RELAY_1_PIN", "22")) # GPIO22 (Pin 15) -RELAY_2_PIN = int(os.getenv("RELAY_2_PIN", "5")) # GPIO5 (Pin 29) -STATUS_PIN = int(os.getenv("STATUS_PIN", "4")) # GPIO4 (Pin 7) -TRIGGER_DURATION = int(os.getenv("TRIGGER_DURATION", "500")) # 500ms default -DEFAULT_MAX_OPEN_TIME = 300 # seconds (5 minutes) - -# Models -class GateEvent(BaseModel): - id: Optional[int] = None - timestamp: str - action: str - source: str - success: bool - -class Settings(BaseModel): - maxOpenTimeSeconds: str - triggerDuration: str - mqtt: dict - -class GateStatus(BaseModel): - isOpen: bool - lastChanged: str - -# GPIO Setup -def setup_gpio(): - GPIO.setwarnings(False) - GPIO.setmode(GPIO.BCM) - - # Setup relays as outputs (LOW is off) - GPIO.setup(RELAY_1_PIN, GPIO.OUT) - GPIO.setup(RELAY_2_PIN, GPIO.OUT) - GPIO.output(RELAY_1_PIN, GPIO.LOW) - GPIO.output(RELAY_2_PIN, GPIO.LOW) - - # Setup status pin as input with pull-down - # This means it will read LOW when floating - GPIO.setup(STATUS_PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) - - logger.info(f"GPIO initialized: Relay 1 on GPIO{RELAY_1_PIN}, Relay 2 on GPIO{RELAY_2_PIN}, Status on GPIO{STATUS_PIN}") - -# Database functions -async def init_db(): - """Initialize the SQLite database""" - async with aiosqlite.connect(DB_PATH) as db: - # Create events table - await db.execute(""" - CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL, - action TEXT NOT NULL, - source TEXT NOT NULL, - success BOOLEAN NOT NULL - ) - """) - - # Create gate_status table - await db.execute(""" - CREATE TABLE IF NOT EXISTS gate_status ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL - ) - """) - - # Create settings table - await db.execute(""" - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - """) - - # Insert default settings if they don't exist - default_settings = { - "maxOpenTimeSeconds": "300", - "triggerDuration": "500", - "mqtt": { - "broker": "localhost", - "port": "1883", - "username": "", - "password": "", - "clientId": "gatekeeper", - "enabled": False - } - } - - for key, value in default_settings.items(): - await db.execute( - "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", - (key, json.dumps(value)) - ) - - await db.commit() - # Gate control async def trigger_gate() -> bool: try: - GPIO.output(RELAY_1_PIN, GPIO.HIGH) - await asyncio.sleep(TRIGGER_DURATION / 1000) # Convert to seconds - GPIO.output(RELAY_1_PIN, GPIO.LOW) + settings = app.state.current_settings + if not settings: + logger.warning("No settings available, using default settings") + settings = Settings() + + gate_pin = settings.gpio.gatePin + GPIO.output(gate_pin, GPIO.HIGH) + await asyncio.sleep(settings.triggerDuration / 1000) # Convert ms to seconds + GPIO.output(gate_pin, GPIO.LOW) return True except Exception as e: logger.error(f"Error triggering gate: {e}") return False last_open_time = None - -async def check_auto_close(): - """Check if gate has been open too long and close it if needed""" - global last_open_time - while True: - try: - if GPIO.input(STATUS_PIN) == GPIO.HIGH: # Gate is open - current_time = datetime.now() - - # Initialize last_open_time if gate is open and time not set - if last_open_time is None: - last_open_time = current_time - - # Get max open time from settings - async with aiosqlite.connect(DB_PATH) as db: - cursor = await db.execute("SELECT value FROM settings WHERE key = 'maxOpenTimeSeconds'") - row = await cursor.fetchone() - max_open_time = int(json.loads(row[0])) if row else DEFAULT_MAX_OPEN_TIME - - # Check if gate has been open too long - if (current_time - last_open_time).total_seconds() > max_open_time: - logger.warning(f"Gate has been open for more than {max_open_time} seconds. Auto-closing...") - await trigger_gate() - timestamp = current_time.isoformat() - - # Log auto-close event - async with aiosqlite.connect(DB_PATH) as db: - await db.execute( - "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", - (timestamp, "auto-close", "system", True) - ) - await db.commit() - else: - # Reset last_open_time when gate is closed - last_open_time = None - - except Exception as e: - logger.error(f"Error in auto-close check: {e}") - - await asyncio.sleep(1) # Check every second +gate_monitor_running = False +auto_close_running = False async def update_gate_status(): """Monitor gate status and update database when it changes""" - global last_open_time - last_status = None + global gate_monitor_running - while True: - try: - current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH # True = OPEN, False = CLOSED - - if last_status != current_status: - timestamp = datetime.now() + if gate_monitor_running: + logger.warning("Gate status monitor already running, skipping...") + return + + gate_monitor_running = True + logger.info("Starting gate status monitoring task") + + try: + settings = app.state.current_settings + if not settings: + logger.warning("No settings available, using default settings") + settings = Settings() + + status_pin = settings.gpio.statusPin + last_status = None + consecutive_errors = 0 + + while True: + try: + if not gate_monitor_running: + logger.info("Gate status monitor stopped") + break + + current_status = GPIO.input(status_pin) == GPIO.HIGH - # Update last_open_time when gate opens - if current_status: # Gate just opened - last_open_time = timestamp - else: # Gate just closed + if last_status != current_status: + timestamp = datetime.now() + logger.info(f"Gate status changed to: {'open' if current_status else 'closed'}") + logger.debug("Updating database with new status") + + async with await get_db() as db: + await db.execute( + "INSERT INTO gate_status (timestamp) VALUES (?)", + (timestamp.isoformat(),) + ) + + await db.execute( + "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", + (timestamp.isoformat(), f"gate {'opened' if current_status else 'closed'}", "sensor", True) + ) + await db.commit() + + await ha_mqtt.publish_state(current_status) + last_status = current_status + consecutive_errors = 0 + else: + logger.debug(f"Gate status unchanged: {'open' if current_status else 'closed'}") + + await asyncio.sleep(0.5) + + except Exception as e: + consecutive_errors += 1 + wait_time = min(30, 2 ** consecutive_errors) + logger.error(f"Error in update_gate_status (attempt {consecutive_errors}): {e}", exc_info=True) + logger.warning(f"Retrying in {wait_time} seconds...") + await asyncio.sleep(wait_time) + finally: + gate_monitor_running = False + logger.info("Gate status monitor stopped") + +async def check_auto_close(): + """Check if gate has been open too long and close it if needed""" + global last_open_time, auto_close_running + + if auto_close_running: + logger.warning("Auto-close monitor already running, skipping...") + return + + auto_close_running = True + logger.info("Starting auto-close monitoring task") + + try: + settings = app.state.current_settings + if not settings: + logger.warning("No settings available, using default settings") + settings = Settings() + + status_pin = settings.gpio.statusPin + consecutive_errors = 0 + + while True: + try: + if not auto_close_running: + logger.info("Auto-close monitor stopped") + break + + if GPIO.input(status_pin) == GPIO.HIGH: # Gate is open + current_time = datetime.now() + + if last_open_time is None: + last_open_time = current_time + logger.debug("Gate opened, starting timer") + + time_open = (current_time - last_open_time).total_seconds() + logger.debug(f"Gate has been open for {time_open:.1f} seconds") + + if time_open > settings.maxOpenTimeSeconds: + logger.warning(f"Gate has been open for {time_open:.1f} seconds, auto-closing") + timestamp = current_time.isoformat() + + async with await get_db() as db: + await db.execute( + "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", + (timestamp, "auto-close", "system", True) + ) + await db.commit() + + await trigger_gate() + last_open_time = None + logger.info("Gate auto-closed successfully") + else: + if last_open_time is not None: + logger.debug("Gate is now closed, resetting timer") last_open_time = None - # Update gate_status table - async with aiosqlite.connect(DB_PATH) as db: - await db.execute( - "INSERT INTO gate_status (timestamp) VALUES (?)", - (timestamp.isoformat(),) - ) - - # Log the status change as an event - status_str = "opened" if current_status else "closed" - await db.execute( - "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", - (timestamp.isoformat(), f"gate {status_str}", "sensor", True) - ) - await db.commit() + consecutive_errors = 0 + await asyncio.sleep(1) - # Update Home Assistant via MQTT - await ha_mqtt.publish_state(current_status) - last_status = current_status - logger.info(f"Gate status changed to: {'open' if current_status else 'closed'}") - - await asyncio.sleep(0.5) # Check every 500ms - - except Exception as e: - logger.error(f"Error in update_gate_status: {e}") - await asyncio.sleep(5) # Wait longer on error + except Exception as e: + consecutive_errors += 1 + wait_time = min(30, 2 ** consecutive_errors) + logger.error(f"Error in check_auto_close (attempt {consecutive_errors}): {e}", exc_info=True) + logger.warning(f"Retrying in {wait_time} seconds...") + await asyncio.sleep(wait_time) + finally: + auto_close_running = False + logger.info("Auto-close monitor stopped") # MQQT Command Handler async def handle_mqtt_command(should_open: bool): """Handle commands received from Home Assistant""" try: - if should_open != (GPIO.input(STATUS_PIN) == GPIO.HIGH): + settings = app.state.current_settings + if not settings: + logger.warning("No settings available, using default settings") + settings = Settings() + + status_pin = settings.gpio.statusPin + if should_open != (GPIO.input(status_pin) == GPIO.HIGH): await trigger_gate() except Exception as e: logger.error(f"Error handling MQTT command: {e}") @@ -281,42 +297,51 @@ async def handle_mqtt_command(should_open: bool): # API Routes @app.post("/api/trigger") async def trigger(): - success = await trigger_gate() - timestamp = datetime.now().isoformat() - current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH - action = "trigger gate" - - async with aiosqlite.connect(DB_PATH) as db: - await db.execute( - "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", - (timestamp, action, "api", success) - ) - await db.commit() - - return {"success": success, "currentStatus": current_status} + """Trigger the gate""" + try: + success = await trigger_gate() + timestamp = datetime.now().isoformat() + + # Get current status after trigger + settings = app.state.current_settings or Settings() + current_status = GPIO.input(settings.gpio.statusPin) == GPIO.HIGH + + # Log event + async with await get_db() as db: + await db.execute( + "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", + (timestamp, "trigger gate", "api", success) + ) + await db.commit() + + return {"success": success, "timestamp": timestamp, "isOpen": current_status} + except Exception as e: + logger.error("Error triggering gate", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to trigger gate") @app.get("/api/status") async def get_status(): """Get current gate status""" - is_open = GPIO.input(STATUS_PIN) == GPIO.HIGH - - # Get last status change time - async with aiosqlite.connect(DB_PATH) as db: - cursor = await db.execute( - "SELECT timestamp FROM gate_status ORDER BY timestamp DESC LIMIT 1" - ) - result = await cursor.fetchone() - last_changed = result[0] if result else datetime.now().isoformat() - - return { - "isOpen": is_open, - "lastChanged": last_changed - } + try: + settings = app.state.current_settings or Settings() + is_open = GPIO.input(settings.gpio.statusPin) == GPIO.HIGH + + async with await get_db() as db: + cursor = await db.execute( + "SELECT timestamp FROM gate_status ORDER BY timestamp DESC LIMIT 1" + ) + row = await cursor.fetchone() + last_changed = row[0] if row else datetime.now().isoformat() + + return {"isOpen": is_open, "lastChanged": last_changed} + except Exception as e: + logger.error("Error getting gate status", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to get gate status") @app.get("/api/events") async def get_events(limit: int = 10, offset: int = 0): """Get recent gate events with pagination""" - async with aiosqlite.connect(DB_PATH) as db: + async with await get_db() as db: db.row_factory = aiosqlite.Row # Get total count @@ -343,7 +368,7 @@ async def get_events(limit: int = 10, offset: int = 0): @app.get("/api/settings") async def get_settings(): """Get current settings""" - async with aiosqlite.connect(DB_PATH) as db: + async with await get_db() as db: cursor = await db.execute("SELECT key, value FROM settings") rows = await cursor.fetchall() settings = {} @@ -360,6 +385,15 @@ async def get_settings(): "password": "", "clientId": "gatekeeper", "enabled": False + }), + "gpio": settings.get("gpio", { + "gatePin": 17, + "statusPin": 27 + }), + "logging": settings.get("logging", { + "level": "WARNING", + "maxBytes": 10 * 1024 * 1024, + "backupCount": 5 }) } @@ -367,7 +401,7 @@ async def get_settings(): async def update_settings(settings: Settings): """Update settings""" try: - async with aiosqlite.connect(DB_PATH) as db: + async with await get_db() as db: # Update each setting for key, value in settings.dict().items(): await db.execute( @@ -378,6 +412,7 @@ async def update_settings(settings: Settings): # Update environment variables and MQTT connection if settings.mqtt: + # Update environment variables os.environ["MQTT_BROKER"] = settings.mqtt["broker"] os.environ["MQTT_PORT"] = settings.mqtt["port"] os.environ["MQTT_USERNAME"] = settings.mqtt["username"] @@ -410,10 +445,13 @@ async def update_settings(settings: Settings): ) await db.commit() + # Update logging configuration + setup_logging(settings) + return {"success": True} except Exception as e: # Log failure event - async with aiosqlite.connect(DB_PATH) as db: + async with await get_db() as db: await db.execute( "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", ( @@ -429,52 +467,210 @@ async def update_settings(settings: Settings): # Serve static files app.mount("/", StaticFiles(directory="../public", html=True), name="static") -# Background task for monitoring gate status +# Database connection pool +db_pool = None + +async def get_db(): + """Get a database connection from the pool""" + global db_pool + if db_pool is None: + db_pool = await aiosqlite.connect(DB_PATH) + db_pool.row_factory = aiosqlite.Row + return db_pool + +# GPIO Setup +def setup_gpio(): + """Initialize GPIO pins based on settings""" + GPIO.setwarnings(False) + GPIO.setmode(GPIO.BCM) + + try: + settings = app.state.current_settings + if not settings: + logger.warning("No settings available, using default settings") + settings = Settings() + + # Setup gate control pin + gate_pin = settings.gpio.gatePin + GPIO.setup(gate_pin, GPIO.OUT) + GPIO.output(gate_pin, GPIO.LOW) + + # Setup status pin if needed + status_pin = settings.gpio.statusPin + GPIO.setup(status_pin, GPIO.IN, pull_up_down=GPIO.PUD_UP) + + logger.info(f"GPIO initialized (Gate Pin: {gate_pin}, Status Pin: {status_pin})") + except Exception as e: + logger.error(f"Failed to setup GPIO: {e}", exc_info=True) + raise + +# Database functions +async def init_db(): + """Initialize the SQLite database""" + async with await get_db() as db: + # Create events table + await db.execute(""" + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + action TEXT NOT NULL, + source TEXT NOT NULL, + success BOOLEAN NOT NULL + ) + """) + + # Create gate_status table + await db.execute(""" + CREATE TABLE IF NOT EXISTS gate_status ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL + ) + """) + + # Create settings table + await db.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + + # Insert default settings if they don't exist + default_settings = Settings().dict() + for key, value in default_settings.items(): + await db.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + (key, json.dumps(value)) + ) + + await db.commit() + +# Load settings from database +async def load_settings(): + """Load settings and initialize components based on settings""" + try: + settings_dict = await get_settings() + settings = Settings(**settings_dict) + + # Store settings in app state for easy access + app.state.current_settings = settings + + # Configure MQTT based on settings + if settings.mqtt: + # Update environment variables + os.environ["MQTT_BROKER"] = settings.mqtt.broker + os.environ["MQTT_PORT"] = settings.mqtt.port + os.environ["MQTT_USERNAME"] = settings.mqtt.username + os.environ["MQTT_PASSWORD"] = settings.mqtt.password + os.environ["MQTT_CLIENT_ID"] = settings.mqtt.clientId + + # Enable/disable MQTT based on settings + if settings.mqtt.enabled: + logger.info("MQTT enabled in settings, initializing connection") + ha_mqtt.enable() + else: + logger.info("MQTT disabled in settings") + + # Set other application settings + os.environ["MAX_OPEN_TIME_SECONDS"] = str(settings.maxOpenTimeSeconds) + os.environ["TRIGGER_DURATION"] = str(settings.triggerDuration) + + # Configure logging + setup_logging(settings) + + logger.info(f"Settings loaded successfully (Max Open Time: {settings.maxOpenTimeSeconds}s, " + f"Trigger Duration: {settings.triggerDuration}ms, " + f"Gate Pin: {settings.gpio.gatePin}, Status Pin: {settings.gpio.statusPin})") + return settings + + except Exception as e: + logger.warning(f"Failed to load settings: {e}", exc_info=True) + logger.info("Using default settings") + default_settings = Settings() + app.state.current_settings = default_settings + return default_settings + @app.on_event("startup") async def startup_event(): """Initialize the application on startup""" - logger.info("Starting Gatekeeper application") try: - # Initialize database + logger.info("Starting Gatekeeper application") + + # 1. Initialize database + logger.info("Initializing database...") await init_db() logger.info("Database initialized successfully") - # Setup GPIO + # 2. Load settings from database + logger.info("Loading settings...") + settings = await load_settings() + app.state.current_settings = settings + logger.info("Settings loaded successfully") + + # 3. Setup GPIO + logger.info("Initializing GPIO...") setup_gpio() logger.info("GPIO initialized successfully") - # Initialize MQTT from settings - settings = await get_settings() - if settings.mqtt and settings.mqtt.get("enabled"): - logger.info("MQTT enabled in settings, initializing connection") + # 4. Initialize MQTT if enabled + if settings.mqtt.enabled: + logger.info("MQTT enabled, initializing connection...") ha_mqtt.enable() + logger.info("MQTT initialized successfully") else: logger.info("MQTT disabled in settings") - # Start background tasks + # 5. Start background tasks + logger.info("Starting background tasks...") + global gate_monitor_running, auto_close_running + gate_monitor_running = True + auto_close_running = True app.state.status_task = asyncio.create_task(update_gate_status()) app.state.auto_close_task = asyncio.create_task(check_auto_close()) + logger.info("Background tasks started successfully") + + logger.info("Gatekeeper application started successfully") except Exception as e: - logger.error(f"Startup error: {str(e)}", exc_info=True) + logger.critical("Failed to start application", exc_info=True) raise @app.on_event("shutdown") async def shutdown_event(): """Cleanup on shutdown""" - logger.info("Shutting down Gatekeeper application") try: - # Cancel background tasks + logger.info("Shutting down Gatekeeper application") + + # 1. Stop background tasks + logger.info("Stopping background tasks...") + global gate_monitor_running, auto_close_running + gate_monitor_running = False + auto_close_running = False + if hasattr(app.state, "status_task"): app.state.status_task.cancel() if hasattr(app.state, "auto_close_task"): app.state.auto_close_task.cancel() - - # Disconnect MQTT - await ha_mqtt.disconnect() - - # Cleanup GPIO + logger.info("Background tasks stopped") + + # 2. Disconnect MQTT if it was enabled + if hasattr(app.state, "current_settings") and app.state.current_settings.mqtt.enabled: + logger.info("Disconnecting MQTT...") + await ha_mqtt.disconnect() + logger.info("MQTT disconnected") + + # 3. Clean up GPIO + logger.info("Cleaning up GPIO...") GPIO.cleanup() - logger.info("Cleanup completed successfully") + logger.info("GPIO cleanup completed") + + # 4. Close database connection + logger.info("Closing database connection...") + global db_pool + if db_pool: + await db_pool.close() + logger.info("Database connection closed") + + logger.info("Shutdown completed successfully") except Exception as e: - logger.error(f"Shutdown error: {str(e)}", exc_info=True) + logger.error("Error during shutdown", exc_info=True) raise diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b899891..8902def 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "dlb-gatekeeper-frontend", "version": "1.0.0", "dependencies": { + "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.0.18", "axios": "^1.6.2", "react": "^18.2.0", @@ -715,6 +716,78 @@ "node": ">=12" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz", + "integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.17.1", + "@react-aria/interactions": "^3.21.3", + "@tanstack/react-virtual": "^3.8.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/@heroicons/react": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", @@ -844,6 +917,89 @@ "node": ">=14" } }, + "node_modules/@react-aria/focus": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.19.0.tgz", + "integrity": "sha512-hPF9EXoUQeQl1Y21/rbV2H4FdUR2v+4/I0/vB+8U3bT1CJ+1AFj1hc/rqx2DqEwDlEwOHN+E4+mRahQmlybq0A==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.22.5", + "@react-aria/utils": "^3.26.0", + "@react-types/shared": "^3.26.0", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.22.5.tgz", + "integrity": "sha512-kMwiAD9E0TQp+XNnOs13yVJghiy8ET8L0cbkeuTgNI96sOAp/63EJ1FSrDf17iD8sdjt41LafwX/dKXW9nCcLQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.7", + "@react-aria/utils": "^3.26.0", + "@react-types/shared": "^3.26.0", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", + "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.26.0.tgz", + "integrity": "sha512-LkZouGSjjQ0rEqo4XJosS4L3YC/zzQkfRM3KoqK6fUOmUJ9t0jQ09WjiF+uOoG9u+p30AVg3TrZRUWmoTS+koQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.7", + "@react-stately/utils": "^3.10.5", + "@react-types/shared": "^3.26.0", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.5", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.5.tgz", + "integrity": "sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz", + "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.29.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.2.tgz", @@ -1110,6 +1266,42 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz", + "integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.11.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz", + "integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1474,6 +1666,15 @@ "node": ">= 6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2837,6 +3038,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -2918,6 +3125,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 38a0f61..24a5fad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.0.18", "axios": "^1.6.2", "react": "^18.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba13d76..e8a1662 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,15 @@ function App() { password: '', clientId: 'gatekeeper', enabled: false + }, + gpio: { + gatePin: 17, + statusPin: 27 + }, + logging: { + level: 'WARNING', + maxBytes: 10 * 1024 * 1024, // 10MB + backupCount: 5 } }); const [gateStatus, setGateStatus] = useState({ isOpen: false, lastChanged: new Date().toISOString() }); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1d34c2f..2b3cd86 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,7 +2,7 @@ import { GateEvent, Settings, GateStatus } from './types'; const API_BASE = process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:443/api'; -export async function triggerGate(): Promise<{ success: boolean, currentStatus: boolean }> { +export async function triggerGate(): Promise<{ success: boolean, timestamp: string, isOpen: boolean }> { const response = await fetch(`${API_BASE}/trigger`, { method: 'POST', }); diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx index 1f5ae37..c062b89 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -1,16 +1,29 @@ import React, { useState, useEffect } from 'react'; import { Settings, MQTTSettings } from '../types'; import { getSettings, updateSettings } from '../api'; +import { Tab } from '@headlessui/react'; +import { XMarkIcon } from '@heroicons/react/24/outline'; interface SettingsDialogProps { isOpen: boolean; onClose: () => void; } +function classNames(...classes: string[]) { + return classes.filter(Boolean).join(' '); +} + +const TABS = [ + { name: 'Gate', icon: '🚪' }, + { name: 'GPIO', icon: '⚡' }, + { name: 'Logging', icon: '📝' }, + { name: 'MQTT', icon: '🔌' }, +]; + export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { const [settings, setSettings] = useState({ - maxOpenTimeSeconds: "300", - triggerDuration: "500", + maxOpenTimeSeconds: 300, + triggerDuration: 500, mqtt: { broker: "localhost", port: "1883", @@ -18,6 +31,15 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { password: "", clientId: "gatekeeper", enabled: false + }, + gpio: { + gatePin: 17, + statusPin: 27 + }, + logging: { + level: "WARNING", + maxBytes: 10 * 1024 * 1024, // 10MB + backupCount: 5 } }); @@ -61,171 +83,313 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { return (
-
-
-

Settings

- - {/* Gate Settings */} -
-

Gate Settings

-
-
- - setSettings(prev => ({ ...prev, maxOpenTimeSeconds: e.target.value }))} - className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm - px-3 py-2 text-[#1d1d1f] shadow-sm - focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] - transition-colors duration-200" - /> -
-
- - setSettings(prev => ({ ...prev, triggerDuration: e.target.value }))} - className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm - px-3 py-2 text-[#1d1d1f] shadow-sm - focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] - transition-colors duration-200" - /> -
-
-
- - {/* MQTT Settings */} -
-

MQTT Settings

-
-
- - -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - {/* Dialog Actions */} -
+
+ + {/* Header */} +
+

Settings

+
+ + {/* Tabs */} + + + {TABS.map((tab) => ( + + classNames( + 'py-3 px-4 text-sm font-medium border-b-2 outline-none', + selected + ? 'border-[#0071e3] text-[#0071e3]' + : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' + ) + } + > + + {tab.icon} + {tab.name} + + + ))} + + + + {/* Gate Settings */} + +
+

Gate Settings

+
+
+ + setSettings(prev => ({ ...prev, maxOpenTimeSeconds: parseInt(e.target.value) || 0 }))} + className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm + px-3 py-2 text-[#1d1d1f] shadow-sm + focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] + transition-colors duration-200" + /> +
+
+ + setSettings(prev => ({ ...prev, triggerDuration: parseInt(e.target.value) || 0 }))} + className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm + px-3 py-2 text-[#1d1d1f] shadow-sm + focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] + transition-colors duration-200" + /> +
+
+
+
+ + {/* GPIO Settings */} + +
+

GPIO Settings

+
+
+ + setSettings(prev => ({ + ...prev, + gpio: { ...prev.gpio, gatePin: parseInt(e.target.value) } + }))} + className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm + px-3 py-2 text-[#1d1d1f] shadow-sm + focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] + transition-colors duration-200" + /> +
+
+ + setSettings(prev => ({ + ...prev, + gpio: { ...prev.gpio, statusPin: parseInt(e.target.value) } + }))} + className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm + px-3 py-2 text-[#1d1d1f] shadow-sm + focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] + transition-colors duration-200" + /> +
+
+
+
+ + {/* Logging Settings */} + +
+

Logging Settings

+
+
+ + +
+
+ + setSettings(prev => ({ + ...prev, + logging: { ...prev.logging, maxBytes: parseInt(e.target.value) } + }))} + className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm + px-3 py-2 text-[#1d1d1f] shadow-sm + focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] + transition-colors duration-200" + /> +

Default: 10MB (10485760 bytes)

+
+
+ + setSettings(prev => ({ + ...prev, + logging: { ...prev.logging, backupCount: parseInt(e.target.value) } + }))} + className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm + px-3 py-2 text-[#1d1d1f] shadow-sm + focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] + transition-colors duration-200" + /> +

Number of old log files to keep

+
+
+
+
+ + {/* MQTT Settings */} + +
+
+

MQTT Settings

+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + {/* Footer */} +
+ diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 02c5cbf..0916377 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -15,10 +15,23 @@ export interface MQTTSettings { enabled: boolean; } +export interface GPIOSettings { + gatePin: number; + statusPin: number; +} + +export interface LoggingSettings { + level: string; + maxBytes: number; + backupCount: number; +} + export interface Settings { - maxOpenTimeSeconds: string; // Open time in seconds - triggerDuration: string; // Trigger duration in milliseconds + maxOpenTimeSeconds: number; // Open time in seconds + triggerDuration: number; // Trigger duration in milliseconds mqtt: MQTTSettings; + gpio: GPIOSettings; + logging: LoggingSettings; } export interface GateStatus {