fix: align frontend-backend types and API responses

- Updated Settings interface to use numbers for maxOpenTimeSeconds and triggerDuration
- Fixed triggerGate response type to match backend
- Updated SettingsDialog to handle numeric inputs correctly
This commit is contained in:
Josh Finlay 2025-01-08 08:32:52 +10:00
parent c4305643f4
commit 0277e3d6ec
7 changed files with 1002 additions and 406 deletions

View File

@ -11,7 +11,7 @@ from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List, Union
import RPi.GPIO as GPIO import RPi.GPIO as GPIO
from mqtt_integration import HomeAssistantMQTT from mqtt_integration import HomeAssistantMQTT
@ -19,16 +19,61 @@ from mqtt_integration import HomeAssistantMQTT
# Configure logging # Configure logging
LOG_FILE = "/var/log/gatekeeper.log" LOG_FILE = "/var/log/gatekeeper.log"
logger = logging.getLogger("gatekeeper") logger = logging.getLogger("gatekeeper")
logger.setLevel(logging.INFO)
# Create rotating file handler (10MB per file, keep 5 backup files) # Models
class GateEvent(BaseModel):
id: Optional[int] = None
timestamp: str
action: str
source: str
success: bool
class MQTTSettings(BaseModel):
broker: str = "localhost"
port: str = "1883"
username: str = ""
password: str = ""
clientId: str = "gatekeeper"
enabled: bool = False
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( file_handler = RotatingFileHandler(
LOG_FILE, LOG_FILE,
maxBytes=10*1024*1024, # 10MB maxBytes=settings.logging.maxBytes,
backupCount=5, backupCount=settings.logging.backupCount,
delay=True # Only create log file when first record is written delay=True # Only create log file when first record is written
) )
file_handler.setLevel(logging.INFO) file_handler.setLevel(log_level)
# Create formatter and add it to the handler # Create formatter and add it to the handler
formatter = logging.Formatter( formatter = logging.Formatter(
@ -39,6 +84,7 @@ file_handler.setFormatter(formatter)
# Add the handler to the logger # Add the handler to the logger
logger.addHandler(file_handler) logger.addHandler(file_handler)
logger.debug("Logging configured with level: %s", settings.logging.level)
# Log uncaught exceptions # Log uncaught exceptions
def handle_exception(exc_type, exc_value, exc_traceback): def handle_exception(exc_type, exc_value, exc_traceback):
@ -55,11 +101,14 @@ sys.excepthook = handle_exception
app = FastAPI() app = FastAPI()
ha_mqtt = HomeAssistantMQTT() ha_mqtt = HomeAssistantMQTT()
# Constants
DB_PATH = "gate.db" # Database file path
# Set up MQTT event logging # Set up MQTT event logging
async def log_mqtt_event(action: str, success: bool = True): async def log_mqtt_event(action: str, success: bool = True):
"""Log MQTT events to the database and log file""" """Log MQTT events to the database and log file"""
logger.info(f"MQTT Event - {action} (Success: {success})") 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( await db.execute(
"INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)",
(datetime.utcnow().isoformat(), action, "MQTT", success) (datetime.utcnow().isoformat(), action, "MQTT", success)
@ -77,203 +126,170 @@ app.add_middleware(
allow_headers=["*"], 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 # Gate control
async def trigger_gate() -> bool: async def trigger_gate() -> bool:
try: try:
GPIO.output(RELAY_1_PIN, GPIO.HIGH) settings = app.state.current_settings
await asyncio.sleep(TRIGGER_DURATION / 1000) # Convert to seconds if not settings:
GPIO.output(RELAY_1_PIN, GPIO.LOW) 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 return True
except Exception as e: except Exception as e:
logger.error(f"Error triggering gate: {e}") logger.error(f"Error triggering gate: {e}")
return False return False
last_open_time = None last_open_time = None
gate_monitor_running = False
async def check_auto_close(): auto_close_running = False
"""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
async def update_gate_status(): async def update_gate_status():
"""Monitor gate status and update database when it changes""" """Monitor gate status and update database when it changes"""
global last_open_time global gate_monitor_running
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 last_status = None
consecutive_errors = 0
while True: while True:
try: try:
current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH # True = OPEN, False = CLOSED if not gate_monitor_running:
logger.info("Gate status monitor stopped")
break
current_status = GPIO.input(status_pin) == GPIO.HIGH
if last_status != current_status: if last_status != current_status:
timestamp = datetime.now() timestamp = datetime.now()
logger.info(f"Gate status changed to: {'open' if current_status else 'closed'}")
logger.debug("Updating database with new status")
# Update last_open_time when gate opens async with await get_db() as db:
if current_status: # Gate just opened
last_open_time = timestamp
else: # Gate just closed
last_open_time = None
# Update gate_status table
async with aiosqlite.connect(DB_PATH) as db:
await db.execute( await db.execute(
"INSERT INTO gate_status (timestamp) VALUES (?)", "INSERT INTO gate_status (timestamp) VALUES (?)",
(timestamp.isoformat(),) (timestamp.isoformat(),)
) )
# Log the status change as an event
status_str = "opened" if current_status else "closed"
await db.execute( await db.execute(
"INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)",
(timestamp.isoformat(), f"gate {status_str}", "sensor", True) (timestamp.isoformat(), f"gate {'opened' if current_status else 'closed'}", "sensor", True)
) )
await db.commit() await db.commit()
# Update Home Assistant via MQTT
await ha_mqtt.publish_state(current_status) await ha_mqtt.publish_state(current_status)
last_status = current_status last_status = current_status
logger.info(f"Gate status changed to: {'open' if current_status else 'closed'}") consecutive_errors = 0
else:
logger.debug(f"Gate status unchanged: {'open' if current_status else 'closed'}")
await asyncio.sleep(0.5) # Check every 500ms await asyncio.sleep(0.5)
except Exception as e: except Exception as e:
logger.error(f"Error in update_gate_status: {e}") consecutive_errors += 1
await asyncio.sleep(5) # Wait longer on error 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
consecutive_errors = 0
await asyncio.sleep(1)
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 # MQQT Command Handler
async def handle_mqtt_command(should_open: bool): async def handle_mqtt_command(should_open: bool):
"""Handle commands received from Home Assistant""" """Handle commands received from Home Assistant"""
try: 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() await trigger_gate()
except Exception as e: except Exception as e:
logger.error(f"Error handling MQTT command: {e}") logger.error(f"Error handling MQTT command: {e}")
@ -281,42 +297,51 @@ async def handle_mqtt_command(should_open: bool):
# API Routes # API Routes
@app.post("/api/trigger") @app.post("/api/trigger")
async def trigger(): async def trigger():
"""Trigger the gate"""
try:
success = await trigger_gate() success = await trigger_gate()
timestamp = datetime.now().isoformat() timestamp = datetime.now().isoformat()
current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH
action = "trigger gate"
async with aiosqlite.connect(DB_PATH) as db: # 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( await db.execute(
"INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)",
(timestamp, action, "api", success) (timestamp, "trigger gate", "api", success)
) )
await db.commit() await db.commit()
return {"success": success, "currentStatus": current_status} 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") @app.get("/api/status")
async def get_status(): async def get_status():
"""Get current gate status""" """Get current gate status"""
is_open = GPIO.input(STATUS_PIN) == GPIO.HIGH try:
settings = app.state.current_settings or Settings()
is_open = GPIO.input(settings.gpio.statusPin) == GPIO.HIGH
# Get last status change time async with await get_db() as db:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute( cursor = await db.execute(
"SELECT timestamp FROM gate_status ORDER BY timestamp DESC LIMIT 1" "SELECT timestamp FROM gate_status ORDER BY timestamp DESC LIMIT 1"
) )
result = await cursor.fetchone() row = await cursor.fetchone()
last_changed = result[0] if result else datetime.now().isoformat() last_changed = row[0] if row else datetime.now().isoformat()
return { return {"isOpen": is_open, "lastChanged": last_changed}
"isOpen": is_open, except Exception as e:
"lastChanged": last_changed logger.error("Error getting gate status", exc_info=True)
} raise HTTPException(status_code=500, detail="Failed to get gate status")
@app.get("/api/events") @app.get("/api/events")
async def get_events(limit: int = 10, offset: int = 0): async def get_events(limit: int = 10, offset: int = 0):
"""Get recent gate events with pagination""" """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 db.row_factory = aiosqlite.Row
# Get total count # Get total count
@ -343,7 +368,7 @@ async def get_events(limit: int = 10, offset: int = 0):
@app.get("/api/settings") @app.get("/api/settings")
async def get_settings(): async def get_settings():
"""Get current 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") cursor = await db.execute("SELECT key, value FROM settings")
rows = await cursor.fetchall() rows = await cursor.fetchall()
settings = {} settings = {}
@ -360,6 +385,15 @@ async def get_settings():
"password": "", "password": "",
"clientId": "gatekeeper", "clientId": "gatekeeper",
"enabled": False "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): async def update_settings(settings: Settings):
"""Update settings""" """Update settings"""
try: try:
async with aiosqlite.connect(DB_PATH) as db: async with await get_db() as db:
# Update each setting # Update each setting
for key, value in settings.dict().items(): for key, value in settings.dict().items():
await db.execute( await db.execute(
@ -378,6 +412,7 @@ async def update_settings(settings: Settings):
# Update environment variables and MQTT connection # Update environment variables and MQTT connection
if settings.mqtt: if settings.mqtt:
# Update environment variables
os.environ["MQTT_BROKER"] = settings.mqtt["broker"] os.environ["MQTT_BROKER"] = settings.mqtt["broker"]
os.environ["MQTT_PORT"] = settings.mqtt["port"] os.environ["MQTT_PORT"] = settings.mqtt["port"]
os.environ["MQTT_USERNAME"] = settings.mqtt["username"] os.environ["MQTT_USERNAME"] = settings.mqtt["username"]
@ -410,10 +445,13 @@ async def update_settings(settings: Settings):
) )
await db.commit() await db.commit()
# Update logging configuration
setup_logging(settings)
return {"success": True} return {"success": True}
except Exception as e: except Exception as e:
# Log failure event # Log failure event
async with aiosqlite.connect(DB_PATH) as db: async with await get_db() as db:
await db.execute( await db.execute(
"INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)", "INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)",
( (
@ -429,52 +467,210 @@ async def update_settings(settings: Settings):
# Serve static files # Serve static files
app.mount("/", StaticFiles(directory="../public", html=True), name="static") app.mount("/", StaticFiles(directory="../public", html=True), name="static")
# Background task for monitoring gate status # Database connection pool
@app.on_event("startup") db_pool = None
async def startup_event():
"""Initialize the application on startup""" async def get_db():
logger.info("Starting Gatekeeper application") """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: try:
# Initialize database settings = app.state.current_settings
await init_db() if not settings:
logger.info("Database initialized successfully") logger.warning("No settings available, using default settings")
settings = Settings()
# Setup GPIO # Setup gate control pin
setup_gpio() gate_pin = settings.gpio.gatePin
logger.info("GPIO initialized successfully") GPIO.setup(gate_pin, GPIO.OUT)
GPIO.output(gate_pin, GPIO.LOW)
# Initialize MQTT from settings # Setup status pin if needed
settings = await get_settings() status_pin = settings.gpio.statusPin
if settings.mqtt and settings.mqtt.get("enabled"): 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") logger.info("MQTT enabled in settings, initializing connection")
ha_mqtt.enable() ha_mqtt.enable()
else: else:
logger.info("MQTT disabled in settings") logger.info("MQTT disabled in settings")
# Start background tasks # 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"""
try:
logger.info("Starting Gatekeeper application")
# 1. Initialize database
logger.info("Initializing database...")
await init_db()
logger.info("Database initialized successfully")
# 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")
# 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")
# 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.status_task = asyncio.create_task(update_gate_status())
app.state.auto_close_task = asyncio.create_task(check_auto_close()) 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: except Exception as e:
logger.error(f"Startup error: {str(e)}", exc_info=True) logger.critical("Failed to start application", exc_info=True)
raise raise
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown_event(): async def shutdown_event():
"""Cleanup on shutdown""" """Cleanup on shutdown"""
logger.info("Shutting down Gatekeeper application")
try: 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"): if hasattr(app.state, "status_task"):
app.state.status_task.cancel() app.state.status_task.cancel()
if hasattr(app.state, "auto_close_task"): if hasattr(app.state, "auto_close_task"):
app.state.auto_close_task.cancel() app.state.auto_close_task.cancel()
logger.info("Background tasks stopped")
# Disconnect MQTT # 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() await ha_mqtt.disconnect()
logger.info("MQTT disconnected")
# Cleanup GPIO # 3. Clean up GPIO
logger.info("Cleaning up GPIO...")
GPIO.cleanup() 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: except Exception as e:
logger.error(f"Shutdown error: {str(e)}", exc_info=True) logger.error("Error during shutdown", exc_info=True)
raise raise

View File

@ -8,6 +8,7 @@
"name": "dlb-gatekeeper-frontend", "name": "dlb-gatekeeper-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0",
"@heroicons/react": "^2.0.18", "@heroicons/react": "^2.0.18",
"axios": "^1.6.2", "axios": "^1.6.2",
"react": "^18.2.0", "react": "^18.2.0",
@ -715,6 +716,78 @@
"node": ">=12" "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": { "node_modules/@heroicons/react": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
@ -844,6 +917,89 @@
"node": ">=14" "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": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.29.2", "version": "4.29.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.2.tgz",
@ -1110,6 +1266,42 @@
"win32" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1474,6 +1666,15 @@
"node": ">= 6" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2837,6 +3038,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tailwindcss": {
"version": "3.4.17", "version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
@ -2918,6 +3125,12 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "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": { "node_modules/typescript": {
"version": "5.7.2", "version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",

View File

@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0",
"@heroicons/react": "^2.0.18", "@heroicons/react": "^2.0.18",
"axios": "^1.6.2", "axios": "^1.6.2",
"react": "^18.2.0", "react": "^18.2.0",

View File

@ -18,6 +18,15 @@ function App() {
password: '', password: '',
clientId: 'gatekeeper', clientId: 'gatekeeper',
enabled: false enabled: false
},
gpio: {
gatePin: 17,
statusPin: 27
},
logging: {
level: 'WARNING',
maxBytes: 10 * 1024 * 1024, // 10MB
backupCount: 5
} }
}); });
const [gateStatus, setGateStatus] = useState<GateStatus>({ isOpen: false, lastChanged: new Date().toISOString() }); const [gateStatus, setGateStatus] = useState<GateStatus>({ isOpen: false, lastChanged: new Date().toISOString() });

View File

@ -2,7 +2,7 @@ import { GateEvent, Settings, GateStatus } from './types';
const API_BASE = process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:443/api'; 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`, { const response = await fetch(`${API_BASE}/trigger`, {
method: 'POST', method: 'POST',
}); });

View File

@ -1,16 +1,29 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Settings, MQTTSettings } from '../types'; import { Settings, MQTTSettings } from '../types';
import { getSettings, updateSettings } from '../api'; import { getSettings, updateSettings } from '../api';
import { Tab } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
interface SettingsDialogProps { interface SettingsDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; 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) { export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const [settings, setSettings] = useState<Settings>({ const [settings, setSettings] = useState<Settings>({
maxOpenTimeSeconds: "300", maxOpenTimeSeconds: 300,
triggerDuration: "500", triggerDuration: 500,
mqtt: { mqtt: {
broker: "localhost", broker: "localhost",
port: "1883", port: "1883",
@ -18,6 +31,15 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
password: "", password: "",
clientId: "gatekeeper", clientId: "gatekeeper",
enabled: false enabled: false
},
gpio: {
gatePin: 17,
statusPin: 27
},
logging: {
level: "WARNING",
maxBytes: 10 * 1024 * 1024, // 10MB
backupCount: 5
} }
}); });
@ -61,11 +83,46 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
return ( return (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-white/80 backdrop-blur-xl rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-white/50"> <div className="bg-white/80 backdrop-blur-xl rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden border border-white/50">
<form onSubmit={handleSubmit} className="p-6 space-y-6"> <form onSubmit={handleSubmit} className="h-full flex flex-col">
<h2 className="text-xl font-medium text-[#1d1d1f] mb-4">Settings</h2> {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-medium text-[#1d1d1f]">Settings</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-500"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
{/* Tabs */}
<Tab.Group>
<Tab.List className="flex space-x-1 border-b border-gray-200 px-6">
{TABS.map((tab) => (
<Tab
key={tab.name}
className={({ selected }) =>
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'
)
}
>
<span className="flex items-center space-x-2">
<span>{tab.icon}</span>
<span>{tab.name}</span>
</span>
</Tab>
))}
</Tab.List>
<Tab.Panels className="flex-1 overflow-y-auto">
{/* Gate Settings */} {/* Gate Settings */}
<Tab.Panel className="p-6 space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-base font-medium text-[#1d1d1f]">Gate Settings</h3> <h3 className="text-base font-medium text-[#1d1d1f]">Gate Settings</h3>
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
@ -76,7 +133,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<input <input
type="number" type="number"
value={settings.maxOpenTimeSeconds} value={settings.maxOpenTimeSeconds}
onChange={(e) => setSettings(prev => ({ ...prev, maxOpenTimeSeconds: e.target.value }))} onChange={(e) => 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 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 px-3 py-2 text-[#1d1d1f] shadow-sm
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3]
@ -90,7 +147,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<input <input
type="number" type="number"
value={settings.triggerDuration} value={settings.triggerDuration}
onChange={(e) => setSettings(prev => ({ ...prev, triggerDuration: e.target.value }))} onChange={(e) => 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 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 px-3 py-2 text-[#1d1d1f] shadow-sm
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3]
@ -99,26 +156,140 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div> </div>
</div> </div>
</div> </div>
</Tab.Panel>
{/* GPIO Settings */}
<Tab.Panel className="p-6 space-y-6">
<div className="space-y-4">
<h3 className="text-base font-medium text-[#1d1d1f]">GPIO Settings</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Gate Control Pin
</label>
<input
type="number"
value={settings.gpio.gatePin}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Gate Status Pin
</label>
<input
type="number"
value={settings.gpio.statusPin}
onChange={(e) => 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"
/>
</div>
</div>
</div>
</Tab.Panel>
{/* Logging Settings */}
<Tab.Panel className="p-6 space-y-6">
<div className="space-y-4">
<h3 className="text-base font-medium text-[#1d1d1f]">Logging Settings</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Log Level
</label>
<select
value={settings.logging.level}
onChange={(e) => setSettings(prev => ({
...prev,
logging: { ...prev.logging, level: 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"
>
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Max Log File Size (bytes)
</label>
<input
type="number"
value={settings.logging.maxBytes}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-[#86868b]">Default: 10MB (10485760 bytes)</p>
</div>
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Number of Backup Files
</label>
<input
type="number"
value={settings.logging.backupCount}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-[#86868b]">Number of old log files to keep</p>
</div>
</div>
</div>
</Tab.Panel>
{/* MQTT Settings */} {/* MQTT Settings */}
<Tab.Panel className="p-6 space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium text-[#1d1d1f]">MQTT Settings</h3> <h3 className="text-base font-medium text-[#1d1d1f]">MQTT Settings</h3>
<div className="space-y-4">
<div className="flex items-center"> <div className="flex items-center">
<input <input
type="checkbox" type="checkbox"
id="mqtt-enabled"
name="enabled" name="enabled"
checked={settings.mqtt.enabled} checked={settings.mqtt.enabled}
onChange={handleMQTTChange} onChange={handleMQTTChange}
className="h-4 w-4 text-[#0071e3] focus:ring-[#0071e3]/30 border-[#e5e5e5] rounded className="h-4 w-4 text-[#0071e3] border-gray-300 rounded
transition-colors duration-200" focus:ring-[#0071e3] focus:ring-offset-0"
/> />
<label className="ml-2 block text-sm text-[#1d1d1f]"> <label htmlFor="mqtt-enabled" className="ml-2 text-sm text-gray-600">
Enable MQTT Integration Enable MQTT
</label> </label>
</div> </div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4">
<div> <div>
<label className="block text-sm font-medium text-[#86868b] mb-1"> <label className="block text-sm font-medium text-[#86868b] mb-1">
Broker Address Broker Address
@ -130,9 +301,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
onChange={handleMQTTChange} onChange={handleMQTTChange}
disabled={!settings.mqtt.enabled} disabled={!settings.mqtt.enabled}
className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm 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 px-3 py-2 text-[#1d1d1f] shadow-sm disabled:bg-gray-100 disabled:text-gray-500
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3]
disabled:bg-[#f5f5f7] disabled:text-[#86868b]
transition-colors duration-200" transition-colors duration-200"
/> />
</div> </div>
@ -147,9 +317,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
onChange={handleMQTTChange} onChange={handleMQTTChange}
disabled={!settings.mqtt.enabled} disabled={!settings.mqtt.enabled}
className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm 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 px-3 py-2 text-[#1d1d1f] shadow-sm disabled:bg-gray-100 disabled:text-gray-500
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3]
disabled:bg-[#f5f5f7] disabled:text-[#86868b]
transition-colors duration-200" transition-colors duration-200"
/> />
</div> </div>
@ -164,9 +333,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
onChange={handleMQTTChange} onChange={handleMQTTChange}
disabled={!settings.mqtt.enabled} disabled={!settings.mqtt.enabled}
className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm 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 px-3 py-2 text-[#1d1d1f] shadow-sm disabled:bg-gray-100 disabled:text-gray-500
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3]
disabled:bg-[#f5f5f7] disabled:text-[#86868b]
transition-colors duration-200" transition-colors duration-200"
/> />
</div> </div>
@ -181,9 +349,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
onChange={handleMQTTChange} onChange={handleMQTTChange}
disabled={!settings.mqtt.enabled} disabled={!settings.mqtt.enabled}
className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm 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 px-3 py-2 text-[#1d1d1f] shadow-sm disabled:bg-gray-100 disabled:text-gray-500
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3]
disabled:bg-[#f5f5f7] disabled:text-[#86868b]
transition-colors duration-200" transition-colors duration-200"
/> />
</div> </div>
@ -198,34 +365,31 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
onChange={handleMQTTChange} onChange={handleMQTTChange}
disabled={!settings.mqtt.enabled} disabled={!settings.mqtt.enabled}
className="mt-1 block w-full rounded-lg border border-[#e5e5e5] bg-white/50 backdrop-blur-sm 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 px-3 py-2 text-[#1d1d1f] shadow-sm disabled:bg-gray-100 disabled:text-gray-500
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3] focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30 focus:border-[#0071e3]
disabled:bg-[#f5f5f7] disabled:text-[#86868b]
transition-colors duration-200" transition-colors duration-200"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </Tab.Panel>
</Tab.Panels>
</Tab.Group>
{/* Dialog Actions */} {/* Footer */}
<div className="mt-6 flex justify-end space-x-3"> <div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm font-medium text-[#1d1d1f] bg-[#f5f5f7] hover:bg-[#e5e5e5] className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-800
rounded-lg border border-[#e5e5e5] shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#0071e3]"
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30
transition-colors duration-200"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-[#0071e3] hover:bg-[#0077ED] className="px-4 py-2 text-sm font-medium text-white bg-[#0071e3] rounded-md
rounded-lg border border-transparent shadow-sm hover:bg-[#0077ed] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#0071e3]"
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30
transition-colors duration-200"
> >
Save Changes Save Changes
</button> </button>

View File

@ -15,10 +15,23 @@ export interface MQTTSettings {
enabled: boolean; enabled: boolean;
} }
export interface GPIOSettings {
gatePin: number;
statusPin: number;
}
export interface LoggingSettings {
level: string;
maxBytes: number;
backupCount: number;
}
export interface Settings { export interface Settings {
maxOpenTimeSeconds: string; // Open time in seconds maxOpenTimeSeconds: number; // Open time in seconds
triggerDuration: string; // Trigger duration in milliseconds triggerDuration: number; // Trigger duration in milliseconds
mqtt: MQTTSettings; mqtt: MQTTSettings;
gpio: GPIOSettings;
logging: LoggingSettings;
} }
export interface GateStatus { export interface GateStatus {