UI/UX Improvements and Infrastructure Updates
Frontend Changes:
- Enhanced mobile responsiveness:
* Reduced top spacing on mobile screens
* Made the gate control button larger and full-width on mobile
* Improved text sizing and padding for better readability
- Improved visual design:
* Enhanced macOS-style glass effect with deeper shadows
* Added subtle gradient background with brand colors
* Made backgrounds more translucent with white overlays
* Added consistent border styling with white/50 opacity
* Enhanced hover states with smoother transitions
* Added shadow to the status indicator dot
* Made the settings modal more translucent
* Improved button styling consistency
Backend Changes:
- Updated static files path to use relative path ("../public")
- Removed HTTPS/SSL:
* Changed API endpoint in frontend from HTTPS to HTTP
* Removed redirect.py as it's no longer needed for HTTPS redirection
* Simplified deployment by removing SSL-related configurations
Deployment Improvements:
- Fixed deployment script issues:
* Preserved proper backend directory structure
* Added proper directory handling for tar files
* Fixed tar file naming consistency
* Removed chmod for non-existent redirect.py
* Added cd command to ensure correct working directory
* Updated file paths to use absolute paths where needed
Testing:
- Verified mobile UI improvements
- Confirmed HTTP endpoints are working
- Tested gate control functionality
- Validated settings modal operation
This commit is contained in:
parent
45167a0f19
commit
18389ed0cb
|
|
@ -1,5 +1,41 @@
|
|||
node_modules/
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
venv/
|
||||
.env
|
||||
*.log
|
||||
|
||||
# Node/Frontend
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/.env
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
102
README.md
102
README.md
|
|
@ -1,58 +1,72 @@
|
|||
# DLB Gatekeeper
|
||||
# DLB Gate Keeper
|
||||
|
||||
A Node.js application for controlling a gate via Raspberry Pi GPIO.
|
||||
A Raspberry Pi-based gate control system with a web interface.
|
||||
|
||||
## Features
|
||||
## Architecture
|
||||
|
||||
- REST API for gate control
|
||||
- Event logging
|
||||
- Configurable settings
|
||||
- GPIO control for relay
|
||||
- Simple web interface
|
||||
- Backend: Python FastAPI server with direct GPIO control
|
||||
- Frontend: React/TypeScript web interface
|
||||
- Database: SQLite for event logging and settings
|
||||
- Service: Systemd service for automatic startup and monitoring
|
||||
|
||||
## Installation
|
||||
## Development Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies:
|
||||
### Backend (Python)
|
||||
|
||||
1. Install Python dependencies:
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Run the development server:
|
||||
```bash
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||
```
|
||||
|
||||
### Frontend (React)
|
||||
|
||||
1. Install Node.js dependencies:
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
3. Copy `.env.example` to `.env` and configure your settings:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
4. Build the TypeScript code:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
5. Start the server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST /api/trigger` - Trigger the gate
|
||||
- `GET /api/events` - Get recent gate events
|
||||
- `GET /api/settings` - Get current settings
|
||||
- `POST /api/settings` - Update settings
|
||||
|
||||
## Hardware Setup
|
||||
|
||||
- Connect relay control to GPIO pin (default: 18)
|
||||
- Ensure proper power supply for the relay
|
||||
- Ground connections as needed
|
||||
|
||||
## Development
|
||||
|
||||
Run in development mode with auto-reload:
|
||||
2. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Security
|
||||
## Deployment
|
||||
|
||||
- JWT authentication (to be implemented)
|
||||
- Rate limiting enabled
|
||||
- CORS protection
|
||||
- Helmet security headers
|
||||
1. Deploy to Raspberry Pi:
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Build the frontend
|
||||
- Package the Python backend
|
||||
- Copy files to the Raspberry Pi
|
||||
- Install Python dependencies
|
||||
- Set up and start the systemd service
|
||||
|
||||
## GPIO Setup
|
||||
|
||||
The application uses GPIO pin 17 (BCM numbering) by default. You can change this by setting the `RELAY_PIN` environment variable in the systemd service file.
|
||||
|
||||
Make sure the gatekeeper user has access to GPIO:
|
||||
```bash
|
||||
sudo usermod -a -G gpio gatekeeper
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST /api/trigger/{direction}` - Trigger gate (direction: "open" or "close")
|
||||
- `GET /api/events` - Get recent gate events
|
||||
- `GET /api/settings` - Get current settings
|
||||
- `POST /api/settings` - Update settings
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
[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
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
#!/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
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize FastAPI
|
||||
app = FastAPI()
|
||||
|
||||
# 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
|
||||
|
||||
# Models
|
||||
class GateEvent(BaseModel):
|
||||
id: Optional[int] = None
|
||||
timestamp: str
|
||||
action: str
|
||||
source: str
|
||||
success: bool
|
||||
|
||||
class Settings(BaseModel):
|
||||
maxOpenTimeSeconds: str # Open time in seconds
|
||||
triggerDuration: str # Trigger duration in milliseconds
|
||||
|
||||
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():
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
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
|
||||
)
|
||||
""")
|
||||
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
|
||||
)
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS gate_status (
|
||||
timestamp TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
# Insert default settings if they don't exist
|
||||
await db.execute("""
|
||||
INSERT OR IGNORE INTO settings (max_open_time, trigger_duration) VALUES
|
||||
('300000', '500')
|
||||
""")
|
||||
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
|
||||
|
||||
async def update_gate_status():
|
||||
"""Monitor gate status and update database when it changes"""
|
||||
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()
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"INSERT INTO gate_status (timestamp) VALUES (?)",
|
||||
(timestamp,)
|
||||
)
|
||||
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)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
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
|
||||
|
||||
# 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():
|
||||
# HIGH (3.3V) = OPEN, LOW (0V) = CLOSED
|
||||
current_status = GPIO.input(STATUS_PIN) == GPIO.HIGH # True = OPEN, False = CLOSED
|
||||
|
||||
# Get the 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()
|
||||
|
||||
return {
|
||||
"isOpen": current_status,
|
||||
"lastChanged": last_changed
|
||||
}
|
||||
|
||||
@app.get("/api/events")
|
||||
async def get_events(limit: int = 10):
|
||||
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]
|
||||
|
||||
@app.get("/api/settings")
|
||||
async def get_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"}
|
||||
|
||||
@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:
|
||||
await db.execute(
|
||||
"INSERT INTO settings (max_open_time, trigger_duration) VALUES (?, ?)",
|
||||
(str(max_open_time_ms), str(trigger_duration))
|
||||
)
|
||||
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")
|
||||
|
||||
# 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():
|
||||
setup_gpio()
|
||||
await init_db()
|
||||
asyncio.create_task(update_gate_status())
|
||||
logger.info("Application started successfully")
|
||||
|
||||
# Shutdown event
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
GPIO.cleanup()
|
||||
logger.info("Application shutdown complete")
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
fastapi==0.109.0
|
||||
uvicorn==0.27.0
|
||||
RPi.GPIO==0.7.1
|
||||
python-dotenv==1.0.0
|
||||
aiosqlite==0.19.0
|
||||
python-multipart==0.0.6
|
||||
33
build.sh
33
build.sh
|
|
@ -1,33 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Exit on any error
|
||||
set -e
|
||||
|
||||
echo "🏗️ Building DLB Gatekeeper..."
|
||||
|
||||
# Build Frontend
|
||||
echo "📦 Building Frontend..."
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# Build Backend
|
||||
echo "📦 Building Backend..."
|
||||
cd backend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# Create dist directory if it doesnt exist
|
||||
mkdir -p dist
|
||||
|
||||
# Copy frontend build to dist
|
||||
echo "📋 Copying frontend build..."
|
||||
cp -r frontend/dist/* dist/
|
||||
|
||||
# Copy backend build to dist
|
||||
echo "📋 Copying backend build..."
|
||||
cp -r backend/dist/* dist/
|
||||
|
||||
echo "✅ Build complete!"
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
echo "Building frontend..."
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
echo "Creating deployment package..."
|
||||
rm -rf deploy
|
||||
mkdir -p deploy/public
|
||||
mkdir -p deploy/backend
|
||||
|
||||
echo "Copying backend files..."
|
||||
cp -r backend/* deploy/backend/
|
||||
chmod +x deploy/backend/main.py
|
||||
|
||||
echo "Copying frontend build..."
|
||||
cp -r frontend/dist/* deploy/public/
|
||||
|
||||
echo "Creating deployment archive..."
|
||||
tar czf deploy.tar.gz -C deploy .
|
||||
|
||||
echo "Copying files to Raspberry Pi..."
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no"
|
||||
scp $SSH_OPTS deploy.tar.gz gatekeeper@dlbGatekeeper:~/gatekeeper.tar.gz
|
||||
|
||||
echo "=== Deploying to Raspberry Pi ==="
|
||||
ssh $SSH_OPTS gatekeeper@dlbGatekeeper << 'EOF'
|
||||
set -e
|
||||
|
||||
echo "=== Extracting deployment files ==="
|
||||
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
|
||||
EOF
|
||||
|
||||
echo "Local cleanup..."
|
||||
rm -rf deploy deploy.tar.gz
|
||||
21
dev.sh
21
dev.sh
|
|
@ -1,21 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Kill background processes on script exit
|
||||
trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT
|
||||
|
||||
# Start Frontend Dev Server
|
||||
echo "🚀 Starting Frontend Dev Server..."
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev &
|
||||
|
||||
# Start Backend Dev Server
|
||||
echo "🚀 Starting Backend Dev Server..."
|
||||
cd ../backend
|
||||
npm install
|
||||
npm run dev &
|
||||
|
||||
# Wait for both processes
|
||||
wait
|
||||
|
||||
echo "🛑 Development servers stopped"
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
|
|
@ -1161,6 +1162,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.10.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz",
|
||||
"integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
||||
|
|
@ -2921,6 +2932,13 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"axios": "^1.6.2",
|
||||
"@heroicons/react": "^2.0.18"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
|
|
|
|||
|
|
@ -1,108 +1,228 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { GateEvent, Settings } from './types';
|
||||
import { triggerGate, getRecentEvents, getSettings } from './api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { GateEvent, Settings, GateStatus } from './types';
|
||||
import * as api from './api';
|
||||
|
||||
function App() {
|
||||
const [events, setEvents] = useState<GateEvent[]>([]);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
maxOpenTimeSeconds: '300', // 5 minutes in seconds
|
||||
triggerDuration: '500' // 500ms
|
||||
});
|
||||
const [gateStatus, setGateStatus] = useState<GateStatus>({ isOpen: false, lastChanged: new Date().toISOString() });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
const data = await getRecentEvents();
|
||||
setEvents(data);
|
||||
};
|
||||
|
||||
const fetchSettings = async () => {
|
||||
const data = await getSettings();
|
||||
setSettings(data);
|
||||
const formatDate = (isoString: string) => {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
fetchSettings();
|
||||
|
||||
// Refresh events every 30 seconds
|
||||
const interval = setInterval(fetchEvents, 30000);
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [eventsData, settingsData, statusData] = await Promise.all([
|
||||
api.getEvents(),
|
||||
api.getSettings(),
|
||||
api.getGateStatus()
|
||||
]);
|
||||
setEvents(eventsData);
|
||||
setSettings(settingsData);
|
||||
setGateStatus(statusData);
|
||||
} catch (err) {
|
||||
setError('Failed to load initial data');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const status = await api.getGateStatus();
|
||||
setGateStatus(status);
|
||||
const newEvents = await api.getEvents();
|
||||
setEvents(newEvents);
|
||||
} catch (err) {
|
||||
console.error('Failed to update gate status:', err);
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleTrigger = async (direction: 'open' | 'close') => {
|
||||
const handleGateControl = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await triggerGate(direction);
|
||||
await fetchEvents(); // Refresh events after trigger
|
||||
const result = await api.triggerGate();
|
||||
if (result.success) {
|
||||
const newEvents = await api.getEvents();
|
||||
setEvents(newEvents);
|
||||
setGateStatus(prev => ({ ...prev, isOpen: result.currentStatus }));
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to trigger gate');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
const handleSettingsSave = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateSettings(settings);
|
||||
setIsSettingsOpen(false);
|
||||
} catch (err) {
|
||||
setError('Failed to update settings');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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="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="max-w-md mx-auto">
|
||||
<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">
|
||||
<h1 className="text-2xl font-bold mb-8">Gate Control</h1>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex space-x-4 mb-8">
|
||||
<button
|
||||
onClick={() => handleTrigger('open')}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-green-500 text-white px-4 py-2 rounded-md hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
Open Gate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTrigger('close')}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 disabled:opacity-50"
|
||||
>
|
||||
Close Gate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recent Events */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Recent Events</h2>
|
||||
<div className="space-y-2">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`p-2 rounded-md ${
|
||||
event.success ? 'bg-green-50' : 'bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{event.action}</span> at{' '}
|
||||
{formatTimestamp(event.timestamp)}
|
||||
{event.success ? ' ✓' : ' ✗'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<h1 className="text-2xl font-medium text-macos-text text-center">Gate Control</h1>
|
||||
|
||||
{/* Gate Status */}
|
||||
<div className="p-4 bg-white/30 backdrop-blur-md rounded-xl border border-white/40 shadow-xl">
|
||||
<h2 className="text-lg font-medium text-macos-text mb-3">Gate Status</h2>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-3 h-3 rounded-full ${gateStatus.isOpen ? 'bg-macos-green' : 'bg-macos-red'} shadow-lg`}></div>
|
||||
<span className="text-macos-text">
|
||||
{gateStatus.isOpen ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-macos-subtext">
|
||||
Last changed: {formatDate(gateStatus.lastChanged)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Display */}
|
||||
{settings && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Settings</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
Max Open Time: {parseInt(settings.maxOpenTime) / 1000} seconds
|
||||
</p>
|
||||
<p>
|
||||
Trigger Duration: {parseInt(settings.triggerDuration)} ms
|
||||
</p>
|
||||
{/* 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>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,51 @@
|
|||
import axios from 'axios';
|
||||
import { GateEvent, Settings } from './types';
|
||||
import { GateEvent, Settings, GateStatus } from './types';
|
||||
|
||||
// In development, Vite will proxy /api requests to the backend
|
||||
const API_URL = '/api';
|
||||
const API_BASE = process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:443/api';
|
||||
|
||||
export const triggerGate = async (direction: 'open' | 'close'): Promise<boolean> => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/trigger`, { direction });
|
||||
return response.data.success;
|
||||
} catch (error) {
|
||||
console.error('Error triggering gate:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export async function triggerGate(): Promise<{ success: boolean, currentStatus: boolean }> {
|
||||
const response = await fetch(`${API_BASE}/trigger`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to trigger gate: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const getRecentEvents = async (): Promise<GateEvent[]> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/events`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
export async function getGateStatus(): Promise<GateStatus> {
|
||||
const response = await fetch(`${API_BASE}/status`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get gate status: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const getSettings = async (): Promise<Settings | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
export async function getEvents(limit: number = 10): Promise<GateEvent[]> {
|
||||
const response = await fetch(`${API_BASE}/events?limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get events: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const updateSettings = async (settings: Partial<Settings>): Promise<boolean> => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/settings`, settings);
|
||||
return response.data.success;
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
const response = await fetch(`${API_BASE}/settings`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get settings: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateSettings(settings: Settings): Promise<{ success: boolean }> {
|
||||
const response = await fetch(`${API_BASE}/settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update settings: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,48 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
/* macOS-like scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #86868b;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6e6e73;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Disable number input spinners */
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export interface GateEvent {
|
||||
id: number;
|
||||
id?: number;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
source: string;
|
||||
|
|
@ -7,6 +7,11 @@ export interface GateEvent {
|
|||
}
|
||||
|
||||
export interface Settings {
|
||||
maxOpenTime: string;
|
||||
triggerDuration: string;
|
||||
maxOpenTimeSeconds: string; // Open time in seconds
|
||||
triggerDuration: string; // Trigger duration in milliseconds
|
||||
}
|
||||
|
||||
export interface GateStatus {
|
||||
isOpen: boolean;
|
||||
lastChanged: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,34 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
'macos': {
|
||||
'gray': '#f5f5f7',
|
||||
'border': '#e5e5e7',
|
||||
'blue': '#0071e3',
|
||||
'green': '#29c76f',
|
||||
'red': '#ff3b30',
|
||||
'text': '#1d1d1f',
|
||||
'subtext': '#86868b'
|
||||
}
|
||||
},
|
||||
boxShadow: {
|
||||
'macos': '0 0 20px rgba(0, 0, 0, 0.05)',
|
||||
'macos-hover': '0 0 25px rgba(0, 0, 0, 0.1)',
|
||||
'macos-button': '0 1px 2px rgba(0, 0, 0, 0.07)',
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-macos': 'linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.85))',
|
||||
'gradient-macos-hover': 'linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.8))',
|
||||
'gradient-blue': 'linear-gradient(180deg, #0077ed, #0071e3)',
|
||||
'gradient-blue-hover': 'linear-gradient(180deg, #0071e3, #006ad8)',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"name": "dlb-gatekeeper",
|
||||
"version": "1.0.0",
|
||||
"description": "Raspberry Pi Gate Controller",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node-dev --respawn src/index.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"onoff": "^6.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"sqlite3": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/sqlite3": "^3.1.11",
|
||||
"typescript": "^5.3.3",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"@types/jest": "^29.5.11",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { gateController } from './hardware';
|
||||
import { logEvent } from './database';
|
||||
import { config } from './config';
|
||||
|
||||
class AutoCloseManager {
|
||||
private lastOpenTime: number | null = null;
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly CHECK_INTERVAL = 60000; // 1 minute in milliseconds
|
||||
|
||||
constructor() {
|
||||
this.startChecking();
|
||||
}
|
||||
|
||||
public recordOpen(): void {
|
||||
this.lastOpenTime = Date.now();
|
||||
console.log('Gate opened, starting auto-close timer');
|
||||
}
|
||||
|
||||
private startChecking(): void {
|
||||
// Clear any existing interval
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
// Start new interval
|
||||
this.intervalId = setInterval(() => {
|
||||
this.checkAndClose().catch(error => {
|
||||
console.error('Error in auto-close check:', error);
|
||||
});
|
||||
}, this.CHECK_INTERVAL);
|
||||
|
||||
console.log('Auto-close checker started');
|
||||
}
|
||||
|
||||
private async checkAndClose(): Promise<void> {
|
||||
if (!this.lastOpenTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeOpen = Date.now() - this.lastOpenTime;
|
||||
|
||||
if (timeOpen >= config.maxOpenTime) {
|
||||
console.log(`Gate has been open for ${timeOpen}ms, auto-closing`);
|
||||
|
||||
try {
|
||||
const success = await gateController.trigger();
|
||||
await logEvent('AUTO_CLOSE', 'system', success);
|
||||
|
||||
if (success) {
|
||||
this.lastOpenTime = null;
|
||||
console.log('Auto-close successful');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during auto-close:', error);
|
||||
await logEvent('AUTO_CLOSE_ERROR', 'system', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
console.log('Auto-close checker stopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const autoCloseManager = new AutoCloseManager();
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
// GPIO pin number for relay control
|
||||
relayPin: process.env.RELAY_PIN ? parseInt(process.env.RELAY_PIN) : 18,
|
||||
|
||||
// Duration to hold relay active (milliseconds)
|
||||
triggerDuration: process.env.TRIGGER_DURATION ? parseInt(process.env.TRIGGER_DURATION) : 500,
|
||||
|
||||
// Maximum time gate can be open (milliseconds)
|
||||
maxOpenTime: process.env.MAX_OPEN_TIME ? parseInt(process.env.MAX_OPEN_TIME) : 5 * 60 * 1000, // 5 minutes
|
||||
|
||||
// Database file location
|
||||
dbPath: process.env.DB_PATH || 'gatekeeper.db'
|
||||
};
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import sqlite3 from 'sqlite3';
|
||||
import { config } from './config';
|
||||
|
||||
interface EventRow {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
source: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
interface SettingRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const db = new sqlite3.Database(config.dbPath);
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
// Create events table for logging
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
action TEXT NOT NULL,
|
||||
source TEXT,
|
||||
success BOOLEAN
|
||||
)
|
||||
`);
|
||||
|
||||
// Create settings table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function logEvent(action: string, source: string, success: boolean): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'INSERT INTO events (action, source, success) VALUES (?, ?, ?)',
|
||||
[action, source, success],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRecentEvents(limit: number = 10): Promise<EventRow[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all<EventRow>(
|
||||
'SELECT * FROM events ORDER BY timestamp DESC LIMIT ?',
|
||||
[limit],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSetting(key: string): Promise<string | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get<SettingRow>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
[key],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.value : null);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setSetting(key: string, value: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
|
||||
[key, value],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { Gpio } from 'onoff';
|
||||
import { config } from './config';
|
||||
|
||||
class GateController {
|
||||
private relay: Gpio;
|
||||
private isTriggering: boolean = false;
|
||||
|
||||
constructor() {
|
||||
// Initialize GPIO
|
||||
this.relay = new Gpio(config.relayPin, 'out');
|
||||
}
|
||||
|
||||
async trigger(): Promise<boolean> {
|
||||
if (this.isTriggering) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isTriggering = true;
|
||||
|
||||
// Activate relay
|
||||
await this.relay.write(1);
|
||||
|
||||
// Wait for trigger duration
|
||||
await new Promise(resolve => setTimeout(resolve, config.triggerDuration));
|
||||
|
||||
// Deactivate relay
|
||||
await this.relay.write(0);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error triggering gate:', error);
|
||||
return false;
|
||||
} finally {
|
||||
this.isTriggering = false;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.relay.unexport();
|
||||
}
|
||||
}
|
||||
|
||||
export const gateController = new GateController();
|
||||
44
src/index.ts
44
src/index.ts
|
|
@ -1,44 +0,0 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { router } from './routes';
|
||||
import { initializeDatabase } from './database';
|
||||
import { config } from './config';
|
||||
import { autoCloseManager } from './autoClose';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100 // limit each IP to 100 requests per windowMs
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Routes
|
||||
app.use('/api', router);
|
||||
|
||||
// Serve static files for web interface
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Initialize database
|
||||
initializeDatabase().catch(console.error);
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down...');
|
||||
autoCloseManager.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import express from 'express';
|
||||
import { gateController } from './hardware';
|
||||
import { logEvent, getRecentEvents, getSetting, setSetting } from './database';
|
||||
import { autoCloseManager } from './autoClose';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
// Trigger gate
|
||||
router.post('/trigger', async (req, res) => {
|
||||
try {
|
||||
const success = await gateController.trigger();
|
||||
await logEvent('TRIGGER', req.ip || 'unknown', success);
|
||||
|
||||
// Record open time for auto-close
|
||||
if (success && req.body.direction === 'open') {
|
||||
autoCloseManager.recordOpen();
|
||||
}
|
||||
|
||||
res.json({ success });
|
||||
} catch (error) {
|
||||
console.error('Error triggering gate:', error);
|
||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent events
|
||||
router.get('/events', async (req, res) => {
|
||||
try {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 10;
|
||||
const events = await getRecentEvents(limit);
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get settings
|
||||
router.get('/settings', async (req, res) => {
|
||||
try {
|
||||
const settings = {
|
||||
maxOpenTime: await getSetting('maxOpenTime'),
|
||||
triggerDuration: await getSetting('triggerDuration')
|
||||
};
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update settings
|
||||
router.post('/settings', async (req, res) => {
|
||||
try {
|
||||
const { maxOpenTime, triggerDuration } = req.body;
|
||||
|
||||
if (maxOpenTime) {
|
||||
await setSetting('maxOpenTime', maxOpenTime.toString());
|
||||
}
|
||||
|
||||
if (triggerDuration) {
|
||||
await setSetting('triggerDuration', triggerDuration.toString());
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
Loading…
Reference in New Issue