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:
Josh Finlay 2025-01-07 15:33:31 +10:00
parent 45167a0f19
commit 18389ed0cb
24 changed files with 755 additions and 7259 deletions

40
.gitignore vendored
View File

@ -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
View File

@ -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

View File

@ -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

236
backend/main.py Normal file
View File

@ -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")

6
backend/requirements.txt Normal file
View File

@ -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

View File

@ -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!"

52
deploy.sh Executable file
View File

@ -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
View File

@ -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"

View File

@ -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",

View File

@ -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",

View File

@ -1,104 +1,225 @@
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([
api.getEvents(),
api.getSettings(),
api.getGateStatus()
]);
setEvents(eventsData);
setSettings(settingsData);
setGateStatus(statusData);
} catch (err) {
setError('Failed to load initial data');
console.error(err);
}
};
loadData();
}, []);
// Refresh events every 30 seconds useEffect(() => {
const interval = setInterval(fetchEvents, 30000); 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">
<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>
{/* Gate Controls */}
<div className="flex justify-center space-x-4 py-4">
<button <button
onClick={() => handleTrigger('open')} onClick={handleGateControl}
disabled={loading} disabled={loading}
className="flex-1 bg-green-500 text-white px-4 py-2 rounded-md hover:bg-green-600 disabled:opacity-50" 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' : ''}`}
> >
Open Gate {gateStatus.isOpen ? 'Close Gate' : '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> </button>
</div> </div>
{/* Error Display */}
{error && (
<div className="text-macos-red text-sm text-center">
{error}
</div>
)}
{/* Recent Events */} {/* Recent Events */}
<div className="mt-8"> <div className="mt-6">
<h2 className="text-xl font-semibold mb-4">Recent Events</h2> <h2 className="text-lg font-medium text-macos-text mb-3">Recent Events</h2>
<div className="space-y-2"> <div className="space-y-2 max-h-64 overflow-y-auto pr-2 -mr-2">
{events.map((event) => ( {events.map((event, index) => (
<div <div key={index} className="p-3 bg-white/50 rounded-lg border border-macos-border shadow-macos-button">
key={event.id} <div className="flex justify-between items-center">
className={`p-2 rounded-md ${ <div className="flex items-center space-x-2">
event.success ? 'bg-green-50' : 'bg-red-50' <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}
<p className="text-sm"> </span>
<span className="font-semibold">{event.action}</span> at{' '} </div>
{formatTimestamp(event.timestamp)} <span className="text-xs text-macos-subtext">
{event.success ? ' ✓' : ' ✗'} {formatDate(event.timestamp)}
</p> </span>
</div>
<div className="mt-1 text-xs text-macos-subtext">
Source: {event.source}
</div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Settings Display */} {/* Settings */}
{settings && ( <div className="mt-6 flex justify-center">
<div className="mt-8"> <button
<h2 className="text-xl font-semibold mb-4">Settings</h2> onClick={() => setIsSettingsOpen(true)}
<div className="space-y-2 text-sm"> className="text-sm text-macos-subtext hover:text-macos-text transition-colors px-4 py-2 rounded-lg hover:bg-white/20"
<p> >
Max Open Time: {parseInt(settings.maxOpenTime) / 1000} seconds Settings
</p> </button>
<p> </div>
Trigger Duration: {parseInt(settings.triggerDuration)} ms
</p> {/* 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>
)} )}
@ -107,7 +228,6 @@ function App() {
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@ -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[]> => {
try {
const response = await axios.get(`${API_URL}/events`);
return response.data;
} catch (error) {
console.error('Error fetching events:', error);
return [];
} }
};
export const getSettings = async (): Promise<Settings | null> => { export async function getGateStatus(): Promise<GateStatus> {
try { const response = await fetch(`${API_BASE}/status`);
const response = await axios.get(`${API_URL}/settings`); if (!response.ok) {
return response.data; throw new Error(`Failed to get gate status: ${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 getEvents(limit: number = 10): Promise<GateEvent[]> {
try { const response = await fetch(`${API_BASE}/events?limit=${limit}`);
const response = await axios.post(`${API_URL}/settings`, settings); if (!response.ok) {
return response.data.success; throw new Error(`Failed to get events: ${response.statusText}`);
} catch (error) { }
console.error('Error updating settings:', error); return response.json();
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();
} }
};

View File

@ -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;
}

View File

@ -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;
} }

View File

@ -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: [],
} }

6646
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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();

View File

@ -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'
};

View File

@ -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();
}
);
});
}

View File

@ -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();

View File

@ -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}`);
});

View File

@ -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' });
}
});

View File

@ -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"]
}