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/
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
venv/
|
||||||
.env
|
.env
|
||||||
*.log
|
|
||||||
|
# Node/Frontend
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.env
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
.DS_Store
|
.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
|
- Backend: Python FastAPI server with direct GPIO control
|
||||||
- Event logging
|
- Frontend: React/TypeScript web interface
|
||||||
- Configurable settings
|
- Database: SQLite for event logging and settings
|
||||||
- GPIO control for relay
|
- Service: Systemd service for automatic startup and monitoring
|
||||||
- Simple web interface
|
|
||||||
|
|
||||||
## Installation
|
## Development Setup
|
||||||
|
|
||||||
1. Clone the repository
|
### Backend (Python)
|
||||||
2. Install dependencies:
|
|
||||||
|
1. Install Python dependencies:
|
||||||
```bash
|
```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
|
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
|
2. Start the development server:
|
||||||
|
|
||||||
- `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:
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security
|
## Deployment
|
||||||
|
|
||||||
- JWT authentication (to be implemented)
|
1. Deploy to Raspberry Pi:
|
||||||
- Rate limiting enabled
|
```bash
|
||||||
- CORS protection
|
./deploy.sh
|
||||||
- Helmet security headers
|
```
|
||||||
|
|
||||||
|
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"
|
"react-dom": "^18.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.45",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
|
@ -1161,6 +1162,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.14",
|
"version": "15.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
||||||
|
|
@ -2921,6 +2932,13 @@
|
||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"@heroicons/react": "^2.0.18",
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"@heroicons/react": "^2.0.18"
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.45",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,228 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { GateEvent, Settings } from './types';
|
import { GateEvent, Settings, GateStatus } from './types';
|
||||||
import { triggerGate, getRecentEvents, getSettings } from './api';
|
import * as api from './api';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [events, setEvents] = useState<GateEvent[]>([]);
|
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 [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
|
||||||
const fetchEvents = async () => {
|
const formatDate = (isoString: string) => {
|
||||||
const data = await getRecentEvents();
|
const date = new Date(isoString);
|
||||||
setEvents(data);
|
return date.toLocaleString();
|
||||||
};
|
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
|
||||||
const data = await getSettings();
|
|
||||||
setSettings(data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEvents();
|
const loadData = async () => {
|
||||||
fetchSettings();
|
try {
|
||||||
|
const [eventsData, settingsData, statusData] = await Promise.all([
|
||||||
// Refresh events every 30 seconds
|
api.getEvents(),
|
||||||
const interval = setInterval(fetchEvents, 30000);
|
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);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTrigger = async (direction: 'open' | 'close') => {
|
const handleGateControl = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await triggerGate(direction);
|
const result = await api.triggerGate();
|
||||||
await fetchEvents(); // Refresh events after trigger
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const handleSettingsSave = async () => {
|
||||||
return new Date(timestamp).toLocaleString();
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 py-6 flex flex-col justify-center sm:py-12">
|
<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-3 sm:max-w-xl sm:mx-auto">
|
<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-10 bg-white shadow-lg sm:rounded-3xl sm:p-20">
|
<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="max-w-md mx-auto">
|
||||||
<div className="divide-y divide-gray-200">
|
<div className="space-y-6">
|
||||||
<div className="py-8 text-base leading-6 space-y-4 text-gray-700 sm:text-lg sm:leading-7">
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold mb-8">Gate Control</h1>
|
<h1 className="text-2xl font-medium text-macos-text text-center">Gate Control</h1>
|
||||||
|
|
||||||
{/* Control Buttons */}
|
{/* Gate Status */}
|
||||||
<div className="flex space-x-4 mb-8">
|
<div className="p-4 bg-white/30 backdrop-blur-md rounded-xl border border-white/40 shadow-xl">
|
||||||
<button
|
<h2 className="text-lg font-medium text-macos-text mb-3">Gate Status</h2>
|
||||||
onClick={() => handleTrigger('open')}
|
<div className="flex flex-col space-y-2">
|
||||||
disabled={loading}
|
<div className="flex items-center space-x-2">
|
||||||
className="flex-1 bg-green-500 text-white px-4 py-2 rounded-md hover:bg-green-600 disabled:opacity-50"
|
<div className={`w-3 h-3 rounded-full ${gateStatus.isOpen ? 'bg-macos-green' : 'bg-macos-red'} shadow-lg`}></div>
|
||||||
>
|
<span className="text-macos-text">
|
||||||
Open Gate
|
{gateStatus.isOpen ? 'Open' : 'Closed'}
|
||||||
</button>
|
</span>
|
||||||
<button
|
</div>
|
||||||
onClick={() => handleTrigger('close')}
|
<div className="text-sm text-macos-subtext">
|
||||||
disabled={loading}
|
Last changed: {formatDate(gateStatus.lastChanged)}
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Settings Display */}
|
{/* Gate Controls */}
|
||||||
{settings && (
|
<div className="flex justify-center space-x-4 py-4">
|
||||||
<div className="mt-8">
|
<button
|
||||||
<h2 className="text-xl font-semibold mb-4">Settings</h2>
|
onClick={handleGateControl}
|
||||||
<div className="space-y-2 text-sm">
|
disabled={loading}
|
||||||
<p>
|
className={`px-8 py-4 text-base sm:text-lg font-semibold w-full sm:w-auto
|
||||||
Max Open Time: {parseInt(settings.maxOpenTime) / 1000} seconds
|
${gateStatus.isOpen
|
||||||
</p>
|
? 'bg-white/30 hover:bg-white/40 text-macos-text border border-white/50'
|
||||||
<p>
|
: 'bg-macos-blue hover:bg-macos-blue/90 text-white'
|
||||||
Trigger Duration: {parseInt(settings.triggerDuration)} ms
|
}
|
||||||
</p>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,51 @@
|
||||||
import axios from 'axios';
|
import { GateEvent, Settings, GateStatus } from './types';
|
||||||
import { GateEvent, Settings } from './types';
|
|
||||||
|
|
||||||
// In development, Vite will proxy /api requests to the backend
|
const API_BASE = process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:443/api';
|
||||||
const API_URL = '/api';
|
|
||||||
|
|
||||||
export const triggerGate = async (direction: 'open' | 'close'): Promise<boolean> => {
|
export async function triggerGate(): Promise<{ success: boolean, currentStatus: boolean }> {
|
||||||
try {
|
const response = await fetch(`${API_BASE}/trigger`, {
|
||||||
const response = await axios.post(`${API_URL}/trigger`, { direction });
|
method: 'POST',
|
||||||
return response.data.success;
|
});
|
||||||
} catch (error) {
|
if (!response.ok) {
|
||||||
console.error('Error triggering gate:', error);
|
throw new Error(`Failed to trigger gate: ${response.statusText}`);
|
||||||
return false;
|
}
|
||||||
}
|
return response.json();
|
||||||
};
|
}
|
||||||
|
|
||||||
export const getRecentEvents = async (): Promise<GateEvent[]> => {
|
export async function getGateStatus(): Promise<GateStatus> {
|
||||||
try {
|
const response = await fetch(`${API_BASE}/status`);
|
||||||
const response = await axios.get(`${API_URL}/events`);
|
if (!response.ok) {
|
||||||
return response.data;
|
throw new Error(`Failed to get gate status: ${response.statusText}`);
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Error fetching events:', error);
|
return response.json();
|
||||||
return [];
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSettings = async (): Promise<Settings | null> => {
|
export async function getEvents(limit: number = 10): Promise<GateEvent[]> {
|
||||||
try {
|
const response = await fetch(`${API_BASE}/events?limit=${limit}`);
|
||||||
const response = await axios.get(`${API_URL}/settings`);
|
if (!response.ok) {
|
||||||
return response.data;
|
throw new Error(`Failed to get events: ${response.statusText}`);
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Error fetching settings:', error);
|
return response.json();
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateSettings = async (settings: Partial<Settings>): Promise<boolean> => {
|
export async function getSettings(): Promise<Settings> {
|
||||||
try {
|
const response = await fetch(`${API_BASE}/settings`);
|
||||||
const response = await axios.post(`${API_URL}/settings`, settings);
|
if (!response.ok) {
|
||||||
return response.data.success;
|
throw new Error(`Failed to get settings: ${response.statusText}`);
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Error updating settings:', error);
|
return response.json();
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
};
|
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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 {
|
export interface GateEvent {
|
||||||
id: number;
|
id?: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
action: string;
|
action: string;
|
||||||
source: string;
|
source: string;
|
||||||
|
|
@ -7,6 +7,11 @@ export interface GateEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
maxOpenTime: string;
|
maxOpenTimeSeconds: string; // Open time in seconds
|
||||||
triggerDuration: string;
|
triggerDuration: string; // Trigger duration in milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GateStatus {
|
||||||
|
isOpen: boolean;
|
||||||
|
lastChanged: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,34 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
"./src/**/*.{js,jsx,ts,tsx}",
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
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: [],
|
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