Add MQTT integration with Home Assistant and update deployment script

This commit is contained in:
Josh Finlay 2025-01-08 07:39:07 +10:00
parent ddb5bdf40e
commit 70f7e4fd84
9 changed files with 712 additions and 221 deletions

View File

@ -1,19 +0,0 @@
[Unit]
Description=DLB Gate Keeper Service
After=network.target
[Service]
Type=simple
User=gatekeeper
Group=gpio
WorkingDirectory=/home/gatekeeper/gatekeeper
Environment="RELAY_1_PIN=22"
Environment="RELAY_2_PIN=5"
Environment="STATUS_PIN=4"
Environment="TRIGGER_DURATION=500"
ExecStart=/usr/local/bin/uvicorn main:app --host 0.0.0.0 --port 3000
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@ -6,10 +6,13 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
import aiosqlite import aiosqlite
from datetime import datetime from datetime import datetime, timedelta
import asyncio import asyncio
from typing import List, Optional from typing import List, Optional
import logging import logging
import json
from mqtt_integration import HomeAssistantMQTT
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -20,6 +23,7 @@ logger = logging.getLogger(__name__)
# Initialize FastAPI # Initialize FastAPI
app = FastAPI() app = FastAPI()
ha_mqtt = HomeAssistantMQTT()
# CORS middleware # CORS middleware
app.add_middleware( app.add_middleware(
@ -36,6 +40,7 @@ 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) RELAY_2_PIN = int(os.getenv("RELAY_2_PIN", "5")) # GPIO5 (Pin 29)
STATUS_PIN = int(os.getenv("STATUS_PIN", "4")) # GPIO4 (Pin 7) STATUS_PIN = int(os.getenv("STATUS_PIN", "4")) # GPIO4 (Pin 7)
TRIGGER_DURATION = int(os.getenv("TRIGGER_DURATION", "500")) # 500ms default TRIGGER_DURATION = int(os.getenv("TRIGGER_DURATION", "500")) # 500ms default
DEFAULT_MAX_OPEN_TIME = 300 # seconds (5 minutes)
# Models # Models
class GateEvent(BaseModel): class GateEvent(BaseModel):
@ -46,8 +51,9 @@ class GateEvent(BaseModel):
success: bool success: bool
class Settings(BaseModel): class Settings(BaseModel):
maxOpenTimeSeconds: str # Open time in seconds maxOpenTimeSeconds: str
triggerDuration: str # Trigger duration in milliseconds triggerDuration: str
mqtt: dict
class GateStatus(BaseModel): class GateStatus(BaseModel):
isOpen: bool isOpen: bool
@ -72,7 +78,9 @@ def setup_gpio():
# Database functions # Database functions
async def init_db(): async def init_db():
"""Initialize the SQLite database"""
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
# Create events table
await db.execute(""" await db.execute("""
CREATE TABLE IF NOT EXISTS events ( CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -82,23 +90,43 @@ async def init_db():
success BOOLEAN NOT NULL success BOOLEAN NOT NULL
) )
""") """)
await db.execute("""
CREATE TABLE IF NOT EXISTS settings ( # Create gate_status table
id INTEGER PRIMARY KEY AUTOINCREMENT,
max_open_time TEXT NOT NULL,
trigger_duration TEXT NOT NULL
)
""")
await db.execute(""" await db.execute("""
CREATE TABLE IF NOT EXISTS gate_status ( CREATE TABLE IF NOT EXISTS gate_status (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL timestamp TEXT NOT NULL
) )
""") """)
# Insert default settings if they don't exist
# Create settings table
await db.execute(""" await db.execute("""
INSERT OR IGNORE INTO settings (max_open_time, trigger_duration) VALUES CREATE TABLE IF NOT EXISTS settings (
('300000', '500') 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() await db.commit()
# Gate control # Gate control
@ -112,36 +140,100 @@ async def trigger_gate() -> bool:
logger.error(f"Error triggering gate: {e}") logger.error(f"Error triggering gate: {e}")
return False return False
async def update_gate_status(): last_open_time = None
"""Monitor gate status and update database when it changes"""
last_status = 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
async def update_gate_status():
"""Monitor gate status and update database when it changes"""
global last_open_time
last_status = None
while True: while True:
try: try:
# HIGH (3.3V) = OPEN, LOW (0V) = CLOSED
current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH # True = OPEN, False = CLOSED current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH # True = OPEN, False = CLOSED
if current_status != last_status:
timestamp = datetime.now().isoformat() if last_status != current_status:
timestamp = datetime.now()
# Update last_open_time when gate opens
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: 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,) (timestamp.isoformat(),)
) )
await db.commit()
# Log the status change as an event # Log the status change as an event
status_str = "opened" if current_status else "closed" 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, f"gate {status_str}", "sensor", True) (timestamp.isoformat(), f"gate {status_str}", "sensor", True)
) )
await db.commit() await db.commit()
# Update Home Assistant via MQTT
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'}") logger.info(f"Gate status changed to: {'open' if current_status else 'closed'}")
except Exception as e:
logger.error(f"Error monitoring gate status: {e}")
await asyncio.sleep(0.1) # Check every 100ms 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
# 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):
await trigger_gate()
except Exception as e:
logger.error(f"Error handling MQTT command: {e}")
# API Routes # API Routes
@app.post("/api/trigger") @app.post("/api/trigger")
@ -162,61 +254,81 @@ async def trigger():
@app.get("/api/status") @app.get("/api/status")
async def get_status(): async def get_status():
# HIGH (3.3V) = OPEN, LOW (0V) = CLOSED """Get current gate status"""
current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH # True = OPEN, False = CLOSED is_open = GPIO.input(STATUS_PIN) == GPIO.HIGH
# Get the last status change time # Get last status change time
async with aiosqlite.connect(DB_PATH) 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"
) )
row = await cursor.fetchone() result = await cursor.fetchone()
last_changed = row[0] if row else datetime.now().isoformat() last_changed = result[0] if result else datetime.now().isoformat()
return { return {
"isOpen": current_status, "isOpen": is_open,
"lastChanged": last_changed "lastChanged": last_changed
} }
@app.get("/api/events") @app.get("/api/events")
async def get_events(limit: int = 10): async def get_events(limit: int = 10):
"""Get recent gate events"""
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
cursor = await db.execute( cursor = await db.execute(
"SELECT * FROM events ORDER BY timestamp DESC LIMIT ?", "SELECT * FROM events ORDER BY timestamp DESC LIMIT ?",
(limit,) (limit,)
) )
rows = await cursor.fetchall() events = await cursor.fetchall()
return [dict(row) for row in rows] return [dict(event) for event in events]
@app.get("/api/settings") @app.get("/api/settings")
async def get_settings(): async def get_settings():
"""Get current settings"""
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("SELECT max_open_time, trigger_duration FROM settings ORDER BY id DESC LIMIT 1") cursor = await db.execute("SELECT key, value FROM settings")
row = await cursor.fetchone() rows = await cursor.fetchall()
if row: settings = {}
max_open_time_ms, trigger_duration = row for key, value in rows:
# Convert milliseconds to seconds for maxOpenTime settings[key] = json.loads(value)
return {"maxOpenTimeSeconds": str(int(max_open_time_ms) // 1000), "triggerDuration": str(trigger_duration)}
return {"maxOpenTimeSeconds": "300", "triggerDuration": "500"} return {
"maxOpenTimeSeconds": settings.get("maxOpenTimeSeconds", "300"),
"triggerDuration": settings.get("triggerDuration", "500"),
"mqtt": settings.get("mqtt", {
"broker": "localhost",
"port": "1883",
"username": "",
"password": "",
"clientId": "gatekeeper",
"enabled": False
})
}
@app.post("/api/settings") @app.post("/api/settings")
async def update_settings(settings: Settings): async def update_settings(settings: Settings):
try: """Update settings"""
# Convert seconds to milliseconds for storage
max_open_time_ms = int(settings.maxOpenTimeSeconds) * 1000
trigger_duration = int(settings.triggerDuration)
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
# Update each setting
for key, value in settings.dict().items():
await db.execute( await db.execute(
"INSERT INTO settings (max_open_time, trigger_duration) VALUES (?, ?)", "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(str(max_open_time_ms), str(trigger_duration)) (key, json.dumps(value))
) )
await db.commit() await db.commit()
# Update environment variables and MQTT connection
if settings.mqtt:
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
ha_mqtt.enable(settings.mqtt.get("enabled", False))
return {"success": True} return {"success": True}
except Exception as e:
logger.error(f"Error updating settings: {e}")
raise HTTPException(status_code=500, detail="Failed to update 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")
@ -224,13 +336,37 @@ app.mount("/", StaticFiles(directory="../public", html=True), name="static")
# Background task for monitoring gate status # Background task for monitoring gate status
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
setup_gpio() """Initialize the application on startup"""
# Initialize database
await init_db() await init_db()
asyncio.create_task(update_gate_status())
logger.info("Application started successfully")
# Shutdown event # Setup GPIO
setup_gpio()
# Start background tasks
app.state.status_task = asyncio.create_task(update_gate_status())
app.state.auto_close_task = asyncio.create_task(check_auto_close())
# Initialize MQTT from settings
try:
settings = await get_settings()
if settings["mqtt"].get("enabled"):
ha_mqtt.enable(True)
except Exception as e:
logger.error(f"Failed to initialize MQTT: {e}")
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown_event(): async def shutdown_event():
"""Clean up on shutdown"""
# Cancel background tasks
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
GPIO.cleanup() GPIO.cleanup()
logger.info("Application shutdown complete") logger.info("Application shutdown complete")

182
backend/mqtt_integration.py Normal file
View File

@ -0,0 +1,182 @@
import os
import json
import asyncio
import logging
from gmqtt import Client as MQTTClient
from typing import Optional, Callable
logger = logging.getLogger(__name__)
class HomeAssistantMQTT:
def __init__(self):
# MQTT Configuration
self.broker = os.getenv("MQTT_BROKER", "localhost")
self.port = int(os.getenv("MQTT_PORT", "1883"))
self.username = os.getenv("MQTT_USERNAME", None)
self.password = os.getenv("MQTT_PASSWORD", None)
self.client_id = os.getenv("MQTT_CLIENT_ID", "gatekeeper")
# Home Assistant MQTT topics
self.node_id = "gatekeeper"
self.object_id = "gate"
self.discovery_prefix = "homeassistant"
self.state_topic = f"homeassistant/cover/{self.node_id}/{self.object_id}/state"
self.command_topic = f"homeassistant/cover/{self.node_id}/{self.object_id}/command"
self.config_topic = f"homeassistant/cover/{self.node_id}/{self.object_id}/config"
self.availability_topic = f"homeassistant/cover/{self.node_id}/{self.object_id}/availability"
self.client: Optional[MQTTClient] = None
self.command_callback: Optional[Callable] = None
self._connected = False
self._reconnect_task: Optional[asyncio.Task] = None
self._enabled = False
def enable(self, enabled: bool = True):
"""Enable or disable MQTT integration"""
self._enabled = enabled
if enabled and not self._connected and not self._reconnect_task:
# Set up command handler if not already set
from main import handle_mqtt_command # Import here to avoid circular import
self.set_command_callback(handle_mqtt_command)
# Start reconnection
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
elif not enabled and self._reconnect_task:
self._reconnect_task.cancel()
self._reconnect_task = None
asyncio.create_task(self.disconnect())
async def _reconnect_loop(self):
"""Continuously try to reconnect when connection is lost"""
while self._enabled:
try:
if not self._connected:
await self.connect()
await asyncio.sleep(5) # Wait before checking connection again
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Reconnection attempt failed: {e}")
await asyncio.sleep(5) # Wait before retrying
def on_connect(self, client, flags, rc, properties):
"""Callback for when connection is established"""
logger.info("Connected to MQTT broker")
self._connected = True
asyncio.create_task(self._post_connect())
def on_message(self, client, topic, payload, qos, properties):
"""Callback for when a message is received"""
if topic == self.command_topic and self.command_callback:
try:
command = payload.decode()
asyncio.create_task(self.command_callback(command))
except Exception as e:
logger.error(f"Error processing command: {e}")
def on_disconnect(self, client, packet, exc=None):
"""Callback for when connection is lost"""
logger.info("Disconnected from MQTT broker")
self._connected = False
# Start reconnection if enabled
if self._enabled and not self._reconnect_task:
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
async def _post_connect(self):
"""Tasks to run after connection is established"""
try:
# Send Home Assistant discovery configuration
await self.publish_discovery_config()
# Publish availability status
await self.publish(self.availability_topic, "online", retain=True)
# Subscribe to command topic
await self.subscribe(self.command_topic)
except Exception as e:
logger.error(f"Error in post-connect tasks: {e}")
async def connect(self):
"""Connect to MQTT broker"""
try:
self.client = MQTTClient(self.client_id)
# Set callbacks
self.client.on_connect = self.on_connect
self.client.on_message = self.on_message
self.client.on_disconnect = self.on_disconnect
# Set credentials if provided
if self.username and self.password:
self.client.set_auth_credentials(self.username, self.password)
# Connect to broker
await self.client.connect(self.broker, self.port)
logger.info("Initiating connection to MQTT broker")
except Exception as e:
logger.error(f"Failed to connect to MQTT broker: {e}")
raise
async def disconnect(self):
"""Disconnect from MQTT broker"""
if self.client and self._connected:
try:
await self.publish(self.availability_topic, "offline", retain=True)
await self.client.disconnect()
except Exception as e:
logger.error(f"Error during disconnect: {e}")
async def publish(self, topic: str, payload: str, retain: bool = False):
"""Publish a message to a topic"""
if self.client and self._connected:
try:
await self.client.publish(topic, payload, retain=retain)
except Exception as e:
logger.error(f"Failed to publish message: {e}")
async def subscribe(self, topic: str):
"""Subscribe to a topic"""
if self.client and self._connected:
try:
await self.client.subscribe([(topic, 0)])
except Exception as e:
logger.error(f"Failed to subscribe to topic: {e}")
async def publish_discovery_config(self):
"""Publish Home Assistant MQTT discovery configuration"""
config = {
"name": "Gate",
"unique_id": f"{self.node_id}_{self.object_id}",
"device_class": "gate",
"command_topic": self.command_topic,
"state_topic": self.state_topic,
"availability_topic": self.availability_topic,
"payload_available": "online",
"payload_not_available": "offline",
"payload_open": "OPEN",
"payload_close": "CLOSE",
"payload_stop": "STOP",
"state_open": "open",
"state_closed": "closed",
"state_opening": "opening",
"state_closing": "closing",
"device": {
"identifiers": [self.node_id],
"name": "Gate Keeper",
"model": "DLB Gate Controller",
"manufacturer": "Athena Networks",
}
}
try:
await self.publish(self.config_topic, json.dumps(config), retain=True)
except Exception as e:
logger.error(f"Failed to publish discovery config: {e}")
def set_command_callback(self, callback: Callable):
"""Set callback for handling commands"""
self.command_callback = callback
async def publish_state(self, state: str):
"""Publish gate state"""
await self.publish(self.state_topic, state)

View File

@ -1,6 +1,10 @@
fastapi==0.109.0 fastapi==0.104.1
uvicorn==0.27.0 uvicorn==0.24.0
RPi.GPIO==0.7.1
python-dotenv==1.0.0
aiosqlite==0.19.0 aiosqlite==0.19.0
RPi.GPIO==0.7.1
gmqtt==0.6.12
pydantic==2.5.2
python-multipart==0.0.6 python-multipart==0.0.6
anyio>=3.7.1,<4.0.0
starlette==0.27.0
typing-extensions>=4.8.0

View File

@ -37,12 +37,6 @@ cd /home/gatekeeper
sudo rm -rf /home/gatekeeper/gatekeeper sudo rm -rf /home/gatekeeper/gatekeeper
sudo mkdir -p /home/gatekeeper/gatekeeper sudo mkdir -p /home/gatekeeper/gatekeeper
sudo tar xzf gatekeeper.tar.gz -C /home/gatekeeper/gatekeeper sudo tar xzf gatekeeper.tar.gz -C /home/gatekeeper/gatekeeper
# Copy backend files to root level for the service
sudo cp -r /home/gatekeeper/gatekeeper/backend/* /home/gatekeeper/
echo "=== Restarting services ==="
cd /home/gatekeeper
sudo systemctl restart dlbgatekeeper
echo "=== Cleaning up ===" echo "=== Cleaning up ==="
rm gatekeeper.tar.gz rm gatekeeper.tar.gz

View File

@ -1,12 +1,22 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { GateEvent, Settings, GateStatus } from './types'; import { GateEvent, Settings, GateStatus } from './types';
import * as api from './api'; import * as api from './api';
import { SettingsDialog } from './components/SettingsDialog';
import { Cog6ToothIcon } from '@heroicons/react/24/outline';
function App() { function App() {
const [events, setEvents] = useState<GateEvent[]>([]); const [events, setEvents] = useState<GateEvent[]>([]);
const [settings, setSettings] = useState<Settings>({ const [settings, setSettings] = useState<Settings>({
maxOpenTimeSeconds: '300', // 5 minutes in seconds maxOpenTimeSeconds: '300',
triggerDuration: '500' // 500ms triggerDuration: '500',
mqtt: {
broker: 'localhost',
port: '1883',
username: '',
password: '',
clientId: 'gatekeeper',
enabled: false
}
}); });
const [gateStatus, setGateStatus] = useState<GateStatus>({ isOpen: false, lastChanged: new Date().toISOString() }); const [gateStatus, setGateStatus] = useState<GateStatus>({ isOpen: false, lastChanged: new Date().toISOString() });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -84,150 +94,82 @@ function App() {
}; };
return ( return (
<div className="min-h-screen bg-gradient-to-br from-macos-purple/20 via-macos-blue/10 to-macos-green/20 py-2 sm:py-6 flex flex-col justify-center"> <div className="min-h-screen bg-gray-100 py-6 flex flex-col justify-center sm:py-12">
<div className="relative py-2 sm:py-3 sm:max-w-xl sm:mx-auto w-full px-4 sm:px-0"> <div className="relative py-3 sm:max-w-xl sm:mx-auto">
<div className="relative px-4 py-6 sm:py-10 bg-white/40 backdrop-blur-xl shadow-2xl rounded-2xl sm:p-16 border border-white/50"> <div className="relative px-4 py-10 bg-white shadow-lg sm:rounded-3xl sm:p-20">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="space-y-6"> <div className="divide-y divide-gray-200">
{/* Header */} <div className="py-8 text-base leading-6 space-y-4 text-gray-700 sm:text-lg sm:leading-7">
<h1 className="text-2xl font-medium text-macos-text text-center">Gate Control</h1> <div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-900">Gate Control</h1>
<button
onClick={() => setIsSettingsOpen(true)}
className="p-2 text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<Cog6ToothIcon className="h-6 w-6" />
</button>
</div>
{/* Gate Status */} {/* Gate Status */}
<div className="p-4 bg-white/30 backdrop-blur-md rounded-xl border border-white/40 shadow-xl"> <div className="mb-4">
<h2 className="text-lg font-medium text-macos-text mb-3">Gate Status</h2> <p className="text-lg font-semibold">
<div className="flex flex-col space-y-2"> Status: <span className={gateStatus.isOpen ? "text-green-600" : "text-red-600"}>
<div className="flex items-center space-x-2"> {gateStatus.isOpen ? "Open" : "Closed"}
<div className={`w-3 h-3 rounded-full ${gateStatus.isOpen ? 'bg-macos-green' : 'bg-macos-red'} shadow-lg`}></div>
<span className="text-macos-text">
{gateStatus.isOpen ? 'Open' : 'Closed'}
</span> </span>
</div> </p>
<div className="text-sm text-macos-subtext"> <p className="text-sm text-gray-500">
Last changed: {formatDate(gateStatus.lastChanged)} Last changed: {formatDate(gateStatus.lastChanged)}
</div> </p>
</div>
</div> </div>
{/* Gate Controls */} {/* Gate Control Button */}
<div className="flex justify-center space-x-4 py-4"> <div className="mt-4">
<button <button
onClick={handleGateControl} onClick={handleGateControl}
disabled={loading} disabled={loading}
className={`px-8 py-4 text-base sm:text-lg font-semibold w-full sm:w-auto className={`w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white
${gateStatus.isOpen ${loading ? 'bg-gray-400' : 'bg-blue-600 hover:bg-blue-700'}
? 'bg-white/30 hover:bg-white/40 text-macos-text border border-white/50' focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
: 'bg-macos-blue hover:bg-macos-blue/90 text-white'
}
backdrop-blur-md rounded-xl shadow-xl
focus:outline-none focus:ring-2 focus:ring-macos-blue/50
transition-all duration-200
${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
{gateStatus.isOpen ? 'Close Gate' : 'Open Gate'} {loading ? 'Processing...' : 'Trigger Gate'}
</button> </button>
</div> </div>
{/* Error Display */} {/* Error Display */}
{error && ( {error && (
<div className="text-macos-red text-sm text-center"> <div className="mt-4 p-4 bg-red-100 text-red-700 rounded-md">
{error} {error}
</div> </div>
)} )}
{/* Recent Events */} {/* Recent Events */}
<div className="mt-6"> <div className="mt-8">
<h2 className="text-lg font-medium text-macos-text mb-3">Recent Events</h2> <h2 className="text-xl font-semibold mb-4">Recent Events</h2>
<div className="space-y-2 max-h-64 overflow-y-auto pr-2 -mr-2"> <div className="space-y-4">
{events.map((event, index) => ( {events.map((event, index) => (
<div key={index} className="p-3 bg-white/50 rounded-lg border border-macos-border shadow-macos-button"> <div key={index} className="p-4 bg-gray-50 rounded-lg">
<div className="flex justify-between items-center"> <p className="text-sm text-gray-600">{formatDate(event.timestamp)}</p>
<div className="flex items-center space-x-2"> <p className="font-medium">{event.action}</p>
<div className={`w-2 h-2 rounded-full ${event.success ? 'bg-macos-green' : 'bg-macos-red'}`}></div> <p className="text-sm text-gray-500">Source: {event.source}</p>
<span className="text-sm font-medium text-macos-text">
{event.action}
</span>
</div>
<span className="text-xs text-macos-subtext">
{formatDate(event.timestamp)}
</span>
</div>
<div className="mt-1 text-xs text-macos-subtext">
Source: {event.source}
</div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div>
{/* Settings */} </div>
<div className="mt-6 flex justify-center"> </div>
<button </div>
onClick={() => setIsSettingsOpen(true)}
className="text-sm text-macos-subtext hover:text-macos-text transition-colors px-4 py-2 rounded-lg hover:bg-white/20"
>
Settings
</button>
</div> </div>
{/* Settings Modal */} {/* Settings Dialog */}
{isSettingsOpen && ( <SettingsDialog
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center"> isOpen={isSettingsOpen}
<div className="bg-white/60 backdrop-blur-xl p-6 rounded-xl shadow-2xl border border-white/50 max-w-md w-full mx-4"> onClose={() => {
<h2 className="text-lg font-medium text-macos-text mb-4">Settings</h2> setIsSettingsOpen(false);
// loadSettings(); // Refresh settings after dialog closes
<div className="space-y-4"> }}
<div>
<label className="block text-sm text-macos-subtext mb-1">
Maximum Open Time (seconds)
</label>
<input
type="number"
value={settings.maxOpenTimeSeconds}
onChange={(e) => setSettings(prev => ({ ...prev, maxOpenTimeSeconds: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-white/50 bg-white/30 backdrop-blur-md
focus:outline-none focus:ring-2 focus:ring-macos-blue/50 text-macos-text shadow-lg"
/> />
</div> </div>
<div>
<label className="block text-sm text-macos-subtext mb-1">
Trigger Duration (milliseconds)
</label>
<input
type="number"
value={settings.triggerDuration}
onChange={(e) => setSettings(prev => ({ ...prev, triggerDuration: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-white/50 bg-white/30 backdrop-blur-md
focus:outline-none focus:ring-2 focus:ring-macos-blue/50 text-macos-text shadow-lg"
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => setIsSettingsOpen(false)}
className="px-4 py-2 text-sm text-macos-text bg-white/30 hover:bg-white/40 backdrop-blur-md
rounded-lg border border-white/50 transition-colors shadow-lg"
>
Cancel
</button>
<button
onClick={handleSettingsSave}
disabled={loading}
className="px-4 py-2 text-sm text-white bg-macos-blue hover:bg-macos-blue/90
rounded-lg shadow-xl transition-colors disabled:opacity-50"
>
Save
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
); );
} }

View File

@ -0,0 +1,237 @@
import React, { useState, useEffect } from 'react';
import { Settings, MQTTSettings } from '../types';
import { getSettings, updateSettings } from '../api';
interface SettingsDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const [settings, setSettings] = useState<Settings>({
maxOpenTimeSeconds: "300",
triggerDuration: "500",
mqtt: {
broker: "localhost",
port: "1883",
username: "",
password: "",
clientId: "gatekeeper",
enabled: false
}
});
useEffect(() => {
if (isOpen) {
loadSettings();
}
}, [isOpen]);
const loadSettings = async () => {
try {
const currentSettings = await getSettings();
setSettings(currentSettings);
} catch (error) {
console.error('Failed to load settings:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateSettings(settings);
onClose();
} catch (error) {
console.error('Failed to update settings:', error);
}
};
const handleMQTTChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setSettings(prev => ({
...prev,
mqtt: {
...prev.mqtt,
[name]: type === 'checkbox' ? checked : value
}
}));
};
if (!isOpen) return null;
return (
<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">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<h2 className="text-xl font-medium text-[#1d1d1f] mb-4">Settings</h2>
{/* Gate Settings */}
<div className="space-y-4">
<h3 className="text-base font-medium text-[#1d1d1f]">Gate Settings</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Max Open Time (seconds)
</label>
<input
type="number"
value={settings.maxOpenTimeSeconds}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Trigger Duration (milliseconds)
</label>
<input
type="number"
value={settings.triggerDuration}
onChange={(e) => 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"
/>
</div>
</div>
</div>
{/* MQTT Settings */}
<div className="space-y-4">
<h3 className="text-base font-medium text-[#1d1d1f]">MQTT Settings</h3>
<div className="space-y-4">
<div className="flex items-center">
<input
type="checkbox"
name="enabled"
checked={settings.mqtt.enabled}
onChange={handleMQTTChange}
className="h-4 w-4 text-[#0071e3] focus:ring-[#0071e3]/30 border-[#e5e5e5] rounded
transition-colors duration-200"
/>
<label className="ml-2 block text-sm text-[#1d1d1f]">
Enable MQTT Integration
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Broker Address
</label>
<input
type="text"
name="broker"
value={settings.mqtt.broker}
onChange={handleMQTTChange}
disabled={!settings.mqtt.enabled}
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]
disabled:bg-[#f5f5f7] disabled:text-[#86868b]
transition-colors duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Port
</label>
<input
type="text"
name="port"
value={settings.mqtt.port}
onChange={handleMQTTChange}
disabled={!settings.mqtt.enabled}
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]
disabled:bg-[#f5f5f7] disabled:text-[#86868b]
transition-colors duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Username
</label>
<input
type="text"
name="username"
value={settings.mqtt.username}
onChange={handleMQTTChange}
disabled={!settings.mqtt.enabled}
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]
disabled:bg-[#f5f5f7] disabled:text-[#86868b]
transition-colors duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Password
</label>
<input
type="password"
name="password"
value={settings.mqtt.password}
onChange={handleMQTTChange}
disabled={!settings.mqtt.enabled}
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]
disabled:bg-[#f5f5f7] disabled:text-[#86868b]
transition-colors duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#86868b] mb-1">
Client ID
</label>
<input
type="text"
name="clientId"
value={settings.mqtt.clientId}
onChange={handleMQTTChange}
disabled={!settings.mqtt.enabled}
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]
disabled:bg-[#f5f5f7] disabled:text-[#86868b]
transition-colors duration-200"
/>
</div>
</div>
</div>
</div>
{/* Dialog Actions */}
<div className="mt-6 flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-[#1d1d1f] bg-[#f5f5f7] hover:bg-[#e5e5e5]
rounded-lg border border-[#e5e5e5] shadow-sm
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30
transition-colors duration-200"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-[#0071e3] hover:bg-[#0077ED]
rounded-lg border border-transparent shadow-sm
focus:outline-none focus:ring-2 focus:ring-[#0071e3]/30
transition-colors duration-200"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -6,9 +6,19 @@ export interface GateEvent {
success: boolean; success: boolean;
} }
export interface MQTTSettings {
broker: string;
port: string;
username: string;
password: string;
clientId: string;
enabled: boolean;
}
export interface Settings { export interface Settings {
maxOpenTimeSeconds: string; // Open time in seconds maxOpenTimeSeconds: string; // Open time in seconds
triggerDuration: string; // Trigger duration in milliseconds triggerDuration: string; // Trigger duration in milliseconds
mqtt: MQTTSettings;
} }
export interface GateStatus { export interface GateStatus {

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
fastapi==0.104.1
uvicorn==0.24.0
aiosqlite==0.19.0
RPi.GPIO==0.7.1
asyncio-mqtt==0.16.1