Add MQTT integration with Home Assistant and update deployment script
This commit is contained in:
parent
ddb5bdf40e
commit
70f7e4fd84
|
|
@ -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
|
|
||||||
244
backend/main.py
244
backend/main.py
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue