Initial commit - DLB Gatekeeper project setup

This commit is contained in:
Josh Finlay 2025-01-06 10:52:30 +10:00
commit 596fbac864
24 changed files with 10540 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store

58
README.md Normal file
View File

@ -0,0 +1,58 @@
# DLB Gatekeeper
A Node.js application for controlling a gate via Raspberry Pi GPIO.
## Features
- REST API for gate control
- Event logging
- Configurable settings
- GPIO control for relay
- Simple web interface
## Installation
1. Clone the repository
2. Install dependencies:
```bash
npm install
```
3. Copy `.env.example` to `.env` and configure your settings:
```bash
cp .env.example .env
```
4. Build the TypeScript code:
```bash
npm run build
```
5. Start the server:
```bash
npm start
```
## API Endpoints
- `POST /api/trigger` - Trigger the gate
- `GET /api/events` - Get recent gate events
- `GET /api/settings` - Get current settings
- `POST /api/settings` - Update settings
## Hardware Setup
- Connect relay control to GPIO pin (default: 18)
- Ensure proper power supply for the relay
- Ground connections as needed
## Development
Run in development mode with auto-reload:
```bash
npm run dev
```
## Security
- JWT authentication (to be implemented)
- Rate limiting enabled
- CORS protection
- Helmet security headers

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DLB Gate Controller</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3157
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "dlb-gatekeeper-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.2",
"@heroicons/react": "^2.0.18"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

114
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import { GateEvent, Settings } from './types';
import { triggerGate, getRecentEvents, getSettings } from './api';
function App() {
const [events, setEvents] = useState<GateEvent[]>([]);
const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(false);
const fetchEvents = async () => {
const data = await getRecentEvents();
setEvents(data);
};
const fetchSettings = async () => {
const data = await getSettings();
setSettings(data);
};
useEffect(() => {
fetchEvents();
fetchSettings();
// Refresh events every 30 seconds
const interval = setInterval(fetchEvents, 30000);
return () => clearInterval(interval);
}, []);
const handleTrigger = async (direction: 'open' | 'close') => {
setLoading(true);
try {
await triggerGate(direction);
await fetchEvents(); // Refresh events after trigger
} finally {
setLoading(false);
}
};
const formatTimestamp = (timestamp: string) => {
return new Date(timestamp).toLocaleString();
};
return (
<div className="min-h-screen bg-gray-100 py-6 flex flex-col justify-center sm:py-12">
<div className="relative py-3 sm:max-w-xl sm:mx-auto">
<div className="relative px-4 py-10 bg-white shadow-lg sm:rounded-3xl sm:p-20">
<div className="max-w-md mx-auto">
<div className="divide-y divide-gray-200">
<div className="py-8 text-base leading-6 space-y-4 text-gray-700 sm:text-lg sm:leading-7">
<h1 className="text-2xl font-bold mb-8">Gate Control</h1>
{/* Control Buttons */}
<div className="flex space-x-4 mb-8">
<button
onClick={() => handleTrigger('open')}
disabled={loading}
className="flex-1 bg-green-500 text-white px-4 py-2 rounded-md hover:bg-green-600 disabled:opacity-50"
>
Open Gate
</button>
<button
onClick={() => handleTrigger('close')}
disabled={loading}
className="flex-1 bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 disabled:opacity-50"
>
Close Gate
</button>
</div>
{/* Recent Events */}
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">Recent Events</h2>
<div className="space-y-2">
{events.map((event) => (
<div
key={event.id}
className={`p-2 rounded-md ${
event.success ? 'bg-green-50' : 'bg-red-50'
}`}
>
<p className="text-sm">
<span className="font-semibold">{event.action}</span> at{' '}
{formatTimestamp(event.timestamp)}
{event.success ? ' ✓' : ' ✗'}
</p>
</div>
))}
</div>
</div>
{/* Settings Display */}
{settings && (
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">Settings</h2>
<div className="space-y-2 text-sm">
<p>
Max Open Time: {parseInt(settings.maxOpenTime) / 1000} seconds
</p>
<p>
Trigger Duration: {parseInt(settings.triggerDuration)} ms
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default App;

45
frontend/src/api.ts Normal file
View File

@ -0,0 +1,45 @@
import axios from 'axios';
import { GateEvent, Settings } from './types';
// In development, Vite will proxy /api requests to the backend
const API_URL = '/api';
export const triggerGate = async (direction: 'open' | 'close'): Promise<boolean> => {
try {
const response = await axios.post(`${API_URL}/trigger`, { direction });
return response.data.success;
} catch (error) {
console.error('Error triggering gate:', error);
return false;
}
};
export 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> => {
try {
const response = await axios.get(`${API_URL}/settings`);
return response.data;
} catch (error) {
console.error('Error fetching settings:', error);
return null;
}
};
export const updateSettings = async (settings: Partial<Settings>): Promise<boolean> => {
try {
const response = await axios.post(`${API_URL}/settings`, settings);
return response.data.success;
} catch (error) {
console.error('Error updating settings:', error);
return false;
}
};

3
frontend/src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

14
frontend/src/index.tsx Normal file
View File

@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

12
frontend/src/types.ts Normal file
View File

@ -0,0 +1,12 @@
export interface GateEvent {
id: number;
timestamp: string;
action: string;
source: string;
success: boolean;
}
export interface Settings {
maxOpenTime: string;
triggerDuration: string;
}

View File

@ -0,0 +1,9 @@
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

20
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

15
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})

6646
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"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"
}
}

69
src/autoClose.ts Normal file
View File

@ -0,0 +1,69 @@
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();

17
src/config.ts Normal file
View File

@ -0,0 +1,17 @@
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'
};

97
src/database.ts Normal file
View File

@ -0,0 +1,97 @@
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();
}
);
});
}

44
src/hardware.ts Normal file
View File

@ -0,0 +1,44 @@
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 Normal file
View File

@ -0,0 +1,44 @@
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}`);
});

70
src/routes.ts Normal file
View File

@ -0,0 +1,70 @@
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' });
}
});

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}