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 pydantic import BaseModel
import aiosqlite
from datetime import datetime
from datetime import datetime, timedelta
import asyncio
from typing import List, Optional
import logging
import json
from mqtt_integration import HomeAssistantMQTT
# Configure logging
logging.basicConfig(
@ -20,6 +23,7 @@ logger = logging.getLogger(__name__)
# Initialize FastAPI
app = FastAPI()
ha_mqtt = HomeAssistantMQTT()
# CORS 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)
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):
@ -46,8 +51,9 @@ class GateEvent(BaseModel):
success: bool
class Settings(BaseModel):
maxOpenTimeSeconds: str # Open time in seconds
triggerDuration: str # Trigger duration in milliseconds
maxOpenTimeSeconds: str
triggerDuration: str
mqtt: dict
class GateStatus(BaseModel):
isOpen: bool
@ -72,7 +78,9 @@ def setup_gpio():
# 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,
@ -82,23 +90,43 @@ async def init_db():
success BOOLEAN NOT NULL
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
max_open_time TEXT NOT NULL,
trigger_duration TEXT 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
)
""")
# Insert default settings if they don't exist
# Create settings table
await db.execute("""
INSERT OR IGNORE INTO settings (max_open_time, trigger_duration) VALUES
('300000', '500')
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
@ -112,36 +140,100 @@ async def trigger_gate() -> bool:
logger.error(f"Error triggering gate: {e}")
return False
async def update_gate_status():
"""Monitor gate status and update database when it changes"""
last_status = None
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
async def update_gate_status():
"""Monitor gate status and update database when it changes"""
global last_open_time
last_status = None
while True:
try:
# HIGH (3.3V) = OPEN, LOW (0V) = 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:
await db.execute(
"INSERT INTO gate_status (timestamp) VALUES (?)",
(timestamp,)
(timestamp.isoformat(),)
)
await db.commit()
# 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, f"gate {status_str}", "sensor", True)
(timestamp.isoformat(), f"gate {status_str}", "sensor", True)
)
await db.commit()
# 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 monitoring gate status: {e}")
await asyncio.sleep(0.1) # Check every 100ms
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
@app.post("/api/trigger")
@ -162,61 +254,81 @@ async def trigger():
@app.get("/api/status")
async def get_status():
# HIGH (3.3V) = OPEN, LOW (0V) = CLOSED
current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH # True = OPEN, False = CLOSED
"""Get current gate status"""
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:
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()
result = await cursor.fetchone()
last_changed = result[0] if result else datetime.now().isoformat()
return {
"isOpen": current_status,
"isOpen": is_open,
"lastChanged": last_changed
}
@app.get("/api/events")
async def get_events(limit: int = 10):
"""Get recent gate events"""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM events ORDER BY timestamp DESC LIMIT ?",
(limit,)
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
events = await cursor.fetchall()
return [dict(event) for event in events]
@app.get("/api/settings")
async def get_settings():
"""Get current settings"""
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")
row = await cursor.fetchone()
if row:
max_open_time_ms, trigger_duration = row
# Convert milliseconds to seconds for maxOpenTime
return {"maxOpenTimeSeconds": str(int(max_open_time_ms) // 1000), "triggerDuration": str(trigger_duration)}
return {"maxOpenTimeSeconds": "300", "triggerDuration": "500"}
cursor = await db.execute("SELECT key, value FROM settings")
rows = await cursor.fetchall()
settings = {}
for key, value in rows:
settings[key] = json.loads(value)
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")
async def update_settings(settings: Settings):
try:
# 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:
"""Update settings"""
async with aiosqlite.connect(DB_PATH) as db:
# Update each setting
for key, value in settings.dict().items():
await db.execute(
"INSERT INTO settings (max_open_time, trigger_duration) VALUES (?, ?)",
(str(max_open_time_ms), str(trigger_duration))
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, json.dumps(value))
)
await db.commit()
return {"success": True}
except Exception as e:
logger.error(f"Error updating settings: {e}")
raise HTTPException(status_code=500, detail="Failed to update settings")
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}
# Serve static files
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
@app.on_event("startup")
async def startup_event():
setup_gpio()
"""Initialize the application on startup"""
# Initialize database
await init_db()
asyncio.create_task(update_gate_status())
logger.info("Application started successfully")
# 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}")
# Shutdown event
@app.on_event("shutdown")
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()
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
uvicorn==0.27.0
RPi.GPIO==0.7.1
python-dotenv==1.0.0
fastapi==0.104.1
uvicorn==0.24.0
aiosqlite==0.19.0
RPi.GPIO==0.7.1
gmqtt==0.6.12
pydantic==2.5.2
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 mkdir -p /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 ==="
rm gatekeeper.tar.gz

View File

@ -1,12 +1,22 @@
import React, { useState, useEffect } from 'react';
import { GateEvent, Settings, GateStatus } from './types';
import * as api from './api';
import { SettingsDialog } from './components/SettingsDialog';
import { Cog6ToothIcon } from '@heroicons/react/24/outline';
function App() {
const [events, setEvents] = useState<GateEvent[]>([]);
const [settings, setSettings] = useState<Settings>({
maxOpenTimeSeconds: '300', // 5 minutes in seconds
triggerDuration: '500' // 500ms
maxOpenTimeSeconds: '300',
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 [loading, setLoading] = useState(false);
@ -84,149 +94,81 @@ function App() {
};
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="relative py-2 sm:py-3 sm:max-w-xl sm:mx-auto w-full px-4 sm:px-0">
<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="min-h-screen bg-gray-100 py-6 flex flex-col justify-center sm:py-12">
<div className="relative py-3 sm:max-w-xl sm:mx-auto">
<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="space-y-6">
{/* Header */}
<h1 className="text-2xl font-medium text-macos-text text-center">Gate Control</h1>
{/* Gate Status */}
<div className="p-4 bg-white/30 backdrop-blur-md rounded-xl border border-white/40 shadow-xl">
<h2 className="text-lg font-medium text-macos-text mb-3">Gate Status</h2>
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<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'}
<div className="divide-y divide-gray-200">
<div className="py-8 text-base leading-6 space-y-4 text-gray-700 sm:text-lg sm:leading-7">
<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 */}
<div className="mb-4">
<p className="text-lg font-semibold">
Status: <span className={gateStatus.isOpen ? "text-green-600" : "text-red-600"}>
{gateStatus.isOpen ? "Open" : "Closed"}
</span>
</div>
<div className="text-sm text-macos-subtext">
</p>
<p className="text-sm text-gray-500">
Last changed: {formatDate(gateStatus.lastChanged)}
</p>
</div>
{/* Gate Control Button */}
<div className="mt-4">
<button
onClick={handleGateControl}
disabled={loading}
className={`w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white
${loading ? 'bg-gray-400' : 'bg-blue-600 hover:bg-blue-700'}
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
>
{loading ? 'Processing...' : 'Trigger Gate'}
</button>
</div>
{/* Error Display */}
{error && (
<div className="mt-4 p-4 bg-red-100 text-red-700 rounded-md">
{error}
</div>
)}
{/* Recent Events */}
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">Recent Events</h2>
<div className="space-y-4">
{events.map((event, index) => (
<div key={index} className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">{formatDate(event.timestamp)}</p>
<p className="font-medium">{event.action}</p>
<p className="text-sm text-gray-500">Source: {event.source}</p>
</div>
))}
</div>
</div>
</div>
{/* Gate Controls */}
<div className="flex justify-center space-x-4 py-4">
<button
onClick={handleGateControl}
disabled={loading}
className={`px-8 py-4 text-base sm:text-lg font-semibold w-full sm:w-auto
${gateStatus.isOpen
? 'bg-white/30 hover:bg-white/40 text-macos-text border border-white/50'
: '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'}
</button>
</div>
{/* Error Display */}
{error && (
<div className="text-macos-red text-sm text-center">
{error}
</div>
)}
{/* Recent Events */}
<div className="mt-6">
<h2 className="text-lg font-medium text-macos-text mb-3">Recent Events</h2>
<div className="space-y-2 max-h-64 overflow-y-auto pr-2 -mr-2">
{events.map((event, index) => (
<div key={index} className="p-3 bg-white/50 rounded-lg border border-macos-border shadow-macos-button">
<div className="flex justify-between items-center">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${event.success ? 'bg-macos-green' : 'bg-macos-red'}`}></div>
<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>
{/* Settings */}
<div className="mt-6 flex justify-center">
<button
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>
{/* Settings Modal */}
{isSettingsOpen && (
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center">
<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">
<h2 className="text-lg font-medium text-macos-text mb-4">Settings</h2>
<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>
<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>
{/* Settings Dialog */}
<SettingsDialog
isOpen={isSettingsOpen}
onClose={() => {
setIsSettingsOpen(false);
// loadSettings(); // Refresh settings after dialog closes
}}
/>
</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;
}
export interface MQTTSettings {
broker: string;
port: string;
username: string;
password: string;
clientId: string;
enabled: boolean;
}
export interface Settings {
maxOpenTimeSeconds: string; // Open time in seconds
triggerDuration: string; // Trigger duration in milliseconds
mqtt: MQTTSettings;
}
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