373 lines
12 KiB
Python
373 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
import os
|
|
import RPi.GPIO as GPIO
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
import aiosqlite
|
|
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(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Initialize FastAPI
|
|
app = FastAPI()
|
|
ha_mqtt = HomeAssistantMQTT()
|
|
|
|
# CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # In production, replace with specific origins
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Constants
|
|
DB_PATH = "gate.db"
|
|
RELAY_1_PIN = int(os.getenv("RELAY_1_PIN", "22")) # GPIO22 (Pin 15)
|
|
RELAY_2_PIN = int(os.getenv("RELAY_2_PIN", "5")) # GPIO5 (Pin 29)
|
|
STATUS_PIN = int(os.getenv("STATUS_PIN", "4")) # GPIO4 (Pin 7)
|
|
TRIGGER_DURATION = int(os.getenv("TRIGGER_DURATION", "500")) # 500ms default
|
|
DEFAULT_MAX_OPEN_TIME = 300 # seconds (5 minutes)
|
|
|
|
# Models
|
|
class GateEvent(BaseModel):
|
|
id: Optional[int] = None
|
|
timestamp: str
|
|
action: str
|
|
source: str
|
|
success: bool
|
|
|
|
class Settings(BaseModel):
|
|
maxOpenTimeSeconds: str
|
|
triggerDuration: str
|
|
mqtt: dict
|
|
|
|
class GateStatus(BaseModel):
|
|
isOpen: bool
|
|
lastChanged: str
|
|
|
|
# GPIO Setup
|
|
def setup_gpio():
|
|
GPIO.setwarnings(False)
|
|
GPIO.setmode(GPIO.BCM)
|
|
|
|
# Setup relays as outputs (LOW is off)
|
|
GPIO.setup(RELAY_1_PIN, GPIO.OUT)
|
|
GPIO.setup(RELAY_2_PIN, GPIO.OUT)
|
|
GPIO.output(RELAY_1_PIN, GPIO.LOW)
|
|
GPIO.output(RELAY_2_PIN, GPIO.LOW)
|
|
|
|
# Setup status pin as input with pull-down
|
|
# This means it will read LOW when floating
|
|
GPIO.setup(STATUS_PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
|
|
|
|
logger.info(f"GPIO initialized: Relay 1 on GPIO{RELAY_1_PIN}, Relay 2 on GPIO{RELAY_2_PIN}, Status on GPIO{STATUS_PIN}")
|
|
|
|
# Database functions
|
|
async def init_db():
|
|
"""Initialize the SQLite database"""
|
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
# Create events table
|
|
await db.execute("""
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
success BOOLEAN NOT NULL
|
|
)
|
|
""")
|
|
|
|
# Create gate_status table
|
|
await db.execute("""
|
|
CREATE TABLE IF NOT EXISTS gate_status (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp TEXT NOT NULL
|
|
)
|
|
""")
|
|
|
|
# Create settings table
|
|
await db.execute("""
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
)
|
|
""")
|
|
|
|
# Insert default settings if they don't exist
|
|
default_settings = {
|
|
"maxOpenTimeSeconds": "300",
|
|
"triggerDuration": "500",
|
|
"mqtt": {
|
|
"broker": "localhost",
|
|
"port": "1883",
|
|
"username": "",
|
|
"password": "",
|
|
"clientId": "gatekeeper",
|
|
"enabled": False
|
|
}
|
|
}
|
|
|
|
for key, value in default_settings.items():
|
|
await db.execute(
|
|
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
|
(key, json.dumps(value))
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
# Gate control
|
|
async def trigger_gate() -> bool:
|
|
try:
|
|
GPIO.output(RELAY_1_PIN, GPIO.HIGH)
|
|
await asyncio.sleep(TRIGGER_DURATION / 1000) # Convert to seconds
|
|
GPIO.output(RELAY_1_PIN, GPIO.LOW)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error triggering gate: {e}")
|
|
return False
|
|
|
|
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:
|
|
current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH # True = OPEN, False = CLOSED
|
|
|
|
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.isoformat(),)
|
|
)
|
|
|
|
# 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.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 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")
|
|
async def trigger():
|
|
success = await trigger_gate()
|
|
timestamp = datetime.now().isoformat()
|
|
current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH
|
|
action = "trigger gate"
|
|
|
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
await db.execute(
|
|
"INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)",
|
|
(timestamp, action, "api", success)
|
|
)
|
|
await db.commit()
|
|
|
|
return {"success": success, "currentStatus": current_status}
|
|
|
|
@app.get("/api/status")
|
|
async def get_status():
|
|
"""Get current gate status"""
|
|
is_open = GPIO.input(STATUS_PIN) == GPIO.HIGH
|
|
|
|
# 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"
|
|
)
|
|
result = await cursor.fetchone()
|
|
last_changed = result[0] if result else datetime.now().isoformat()
|
|
|
|
return {
|
|
"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,)
|
|
)
|
|
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 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):
|
|
"""Update settings"""
|
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
# Update each setting
|
|
for key, value in settings.dict().items():
|
|
await db.execute(
|
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
|
(key, json.dumps(value))
|
|
)
|
|
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")
|
|
|
|
# Background task for monitoring gate status
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
"""Initialize the application on startup"""
|
|
# Initialize database
|
|
await init_db()
|
|
|
|
# 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")
|