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
|
||||
250
backend/main.py
250
backend/main.py
|
|
@ -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'}")
|
||||
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
|
||||
@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")
|
||||
|
||||
# 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")
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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="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'}
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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