From 98825db0722849ccd1b15662dc429845423ebf76 Mon Sep 17 00:00:00 2001 From: apon Date: Sun, 29 Mar 2026 14:03:19 +0200 Subject: [PATCH] Ajout des nouvelles modifications --- docker-compose.yml | 52 ++++- js/map.js | 380 ++++++++++++++++---------------- mosquitto/config/mosquitto.conf | 24 ++ package-lock.json | 6 - server/db/database.js | 104 ++++++--- server/package.json | 10 +- server/server.js | 222 +++++++++++++++---- 7 files changed, 514 insertions(+), 284 deletions(-) create mode 100644 mosquitto/config/mosquitto.conf delete mode 100644 package-lock.json diff --git a/docker-compose.yml b/docker-compose.yml index 4451efd..4544c92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,22 @@ version: '3.8' +# ============================================================ +# Smart Parking v2.0 — Docker Compose +# Services : MariaDB + App Node.js + Mosquitto MQTT +# ============================================================ + services: + + # ── Base de données MariaDB ──────────────────────────────── db: image: mariadb:10.11 container_name: smartparking-db restart: always environment: - MARIADB_ROOT_PASSWORD: rootpassword # À changer + MARIADB_ROOT_PASSWORD: rootpassword # ⚠️ À changer en production MARIADB_DATABASE: smartparking MARIADB_USER: smartparking_user - MARIADB_PASSWORD: smartparking_pass # À changer + MARIADB_PASSWORD: smartparking_pass # ⚠️ À changer en production volumes: - db_data:/var/lib/mysql - ./init.sql:/docker-entrypoint-initdb.d/init.sql @@ -20,6 +27,25 @@ services: timeout: 10s retries: 5 + # ── Broker MQTT Mosquitto ────────────────────────────────── + # Si vous avez déjà Mosquitto installé directement sur le Pi + # (pas dans Docker), commentez ce bloc et mettez + # MQTT_HOST=localhost dans la section "app" ci-dessous. + mqtt: + image: eclipse-mosquitto:2 + container_name: smartparking-mqtt + restart: always + ports: + - "1883:1883" # Port MQTT (Arduino se connecte ici) + - "9001:9001" # Port WebSocket (optionnel) + volumes: + - ./mosquitto/config/mosquitto.conf:/mosquitto/config/mosquitto.conf + - mosquitto_data:/mosquitto/data + - mosquitto_log:/mosquitto/log + networks: + - smartparking-network + + # ── Application Node.js ──────────────────────────────────── app: build: . container_name: smartparking-app @@ -29,19 +55,31 @@ services: depends_on: db: condition: service_healthy + mqtt: + condition: service_started environment: - DB_HOST: db - DB_PORT: 3306 - DB_USER: smartparking_user + # Base de données + DB_HOST: db + DB_PORT: 3306 + DB_USER: smartparking_user DB_PASSWORD: smartparking_pass - DB_NAME: smartparking - JWT_SECRET: ${JWT_SECRET:-une_chaine_tres_longue_et_secrete} + DB_NAME: smartparking + # JWT + JWT_SECRET: ${JWT_SECRET:-une_chaine_tres_longue_et_secrete_changez_moi} + # MQTT — utiliser "mqtt" si Mosquitto est dans Docker + # utiliser "localhost" si Mosquitto est installé directement sur le Pi + MQTT_HOST: mqtt + MQTT_PORT: 1883 + # Environnement NODE_ENV: production + PORT: 3000 networks: - smartparking-network volumes: db_data: + mosquitto_data: + mosquitto_log: networks: smartparking-network: \ No newline at end of file diff --git a/js/map.js b/js/map.js index 1b71d88..0319154 100644 --- a/js/map.js +++ b/js/map.js @@ -1,120 +1,163 @@ /** * ============================================ * MAP.JS - Carte des places de parking - * Smart Parking - BTS CIEL IR + * Smart Parking v2.0 + * MODIFIÉ : polling API toutes les 3s pour recevoir + * les mises à jour en temps réel depuis Arduino * ============================================ */ -// Configuration const MAP_CONFIG = { - totalSpots: 10, // Nombre total de places - updateInterval: 5000 // Intervalle de mise à jour + totalSpots: 10, + updateInterval: 3000 // Refresh depuis l'API toutes les 3 secondes }; -// État des places let spotsState = { - spots: [], + spots: [], selectedSpot: null }; -// Types de places -const SPOT_STATUS = { - FREE: 'free', - OCCUPIED: 'occupied', - RESERVED: 'reserved' -}; +const SPOT_STATUS = { FREE: 'free', OCCUPIED: 'occupied', RESERVED: 'reserved' }; + +// ============================================ +// INITIALISATION +// ============================================ -// Initialisation document.addEventListener('DOMContentLoaded', () => { console.log('🗺️ Initialisation de la carte...'); initParkingMap(); }); -/** - * Initialise la carte du parking - */ -function initParkingMap() { - // Charger les places depuis le stockage local ou créer les places par défaut - loadSpots(); - - // Rendre la carte +async function initParkingMap() { + // Essayer d'abord de charger depuis l'API (données MariaDB + Arduino) + const loaded = await loadSpotsFromAPI(); + + // Si pas d'API disponible, utiliser le localStorage (mode offline) + if (!loaded) { + loadSpotsFromStorage(); + } + renderMap(); - - // Mettre à jour les statistiques updateStats(); - - // Mettre à jour le formulaire de réservation updateReservationForm(); - - // Démarrer la simulation (si pas admin) - if (!isAdmin()) { - startSimulation(); + + // ⭐ Polling toutes les 3 secondes pour recevoir les updates Arduino + startAPIPolling(); +} + +// ============================================ +// CHARGEMENT DES PLACES +// ============================================ + +/** + * Charge les places depuis l'API (MariaDB) + * Retourne true si succès, false si hors-ligne + */ +async function loadSpotsFromAPI() { + try { + const token = localStorage.getItem('smart_parking_token'); + if (!token) return false; + + const response = await fetch('/api/spots', { + headers: { 'Authorization': 'Bearer ' + token } + }); + + if (!response.ok) return false; + + const data = await response.json(); + if (!data.success || !data.data.length) return false; + + // Convertir le format API → format interne + spotsState.spots = data.data.map(s => ({ + id: s.id, + number: s.number, + status: s.status, + lastUpdate: s.last_update, + sensorId: s.sensor_id + })); + + // Synchroniser avec localStorage pour le mode offline + saveSpots(); + return true; + + } catch (_err) { + // Serveur non joignable → mode offline + return false; } } /** - * Charge les places + * Charge les places depuis le localStorage (mode offline) */ -function loadSpots() { +function loadSpotsFromStorage() { const stored = localStorage.getItem('smart_parking_spots'); - if (stored) { spotsState.spots = JSON.parse(stored); } else { - // Créer les places par défaut createDefaultSpots(); } } -/** - * Crée les places par défaut - */ function createDefaultSpots() { spotsState.spots = []; - for (let i = 1; i <= MAP_CONFIG.totalSpots; i++) { - // Distribution: 60% libre, 25% occupé, 15% réservé - const rand = Math.random(); - let status = SPOT_STATUS.FREE; - - if (rand > 0.85) { - status = SPOT_STATUS.RESERVED; - } else if (rand > 0.60) { - status = SPOT_STATUS.OCCUPIED; - } - spotsState.spots.push({ - id: i, - number: i, - status: status, + id: i, + number: i, + status: SPOT_STATUS.FREE, lastUpdate: new Date().toISOString(), - sensorId: `SENSOR_${String(i).padStart(3, '0')}` + sensorId: `SENSOR_${String(i).padStart(3, '0')}` }); } - saveSpots(); } -/** - * Sauvegarde les places - */ function saveSpots() { localStorage.setItem('smart_parking_spots', JSON.stringify(spotsState.spots)); } +// ============================================ +// ⭐ POLLING TEMPS RÉEL (mises à jour Arduino) +// ============================================ + /** - * Rend la carte + * Interroge l'API toutes les 3 secondes. + * Si l'Arduino a changé l'état d'une place via MQTT, + * la carte se met à jour automatiquement. */ +function startAPIPolling() { + setInterval(async () => { + const loaded = await loadSpotsFromAPI(); + if (loaded) { + renderMap(); + updateStats(); + updateReservationForm(); + // Rafraîchir les détails si une place est sélectionnée + if (spotsState.selectedSpot) { + const updated = spotsState.spots.find(s => s.id === spotsState.selectedSpot.id); + if (updated) { + spotsState.selectedSpot = updated; + showSpotDetails(updated); + } + } + } + }, MAP_CONFIG.updateInterval); +} + +// ============================================ +// RENDU DE LA CARTE +// ============================================ + function renderMap() { const mapContainer = document.getElementById('parkingMap'); if (!mapContainer) return; - + mapContainer.innerHTML = spotsState.spots.map(spot => ` -
${spot.number} ${getStatusIcon(spot.status)} @@ -122,27 +165,21 @@ function renderMap() { `).join(''); } -/** - * Gère le clic sur une place - */ function handleSpotClick(spotId) { const spot = spotsState.spots.find(s => s.id === spotId); if (!spot) return; - spotsState.selectedSpot = spot; showSpotDetails(spot); } -/** - * Affiche les détails d'une place - */ function showSpotDetails(spot) { const container = document.getElementById('spotDetails'); if (!container) return; - - const isReserved = spot.status === SPOT_STATUS.RESERVED; - const reservation = isReserved ? findReservationForSpot(spot.id) : null; - + + const reservation = spot.status === SPOT_STATUS.RESERVED + ? findReservationForSpot(spot.id) + : null; + container.innerHTML = `
@@ -157,7 +194,7 @@ function showSpotDetails(spot) {
Capteur - ${spot.sensorId} + ${spot.sensorId || 'N/A'}
Dernière mise à jour @@ -174,7 +211,8 @@ function showSpotDetails(spot) {
` : ''} ${spot.status === SPOT_STATUS.FREE ? ` - @@ -183,185 +221,145 @@ function showSpotDetails(spot) { `; } -/** - * Trouve la réservation pour une place - */ function findReservationForSpot(spotId) { const reservations = JSON.parse(localStorage.getItem('smart_parking_reservations') || '[]'); return reservations.find(r => r.spotId === spotId && r.status === 'active'); } -/** - * Met à jour les statistiques - */ +// ============================================ +// STATISTIQUES & FORMULAIRE +// ============================================ + function updateStats() { - const free = spotsState.spots.filter(s => s.status === SPOT_STATUS.FREE).length; + const free = spotsState.spots.filter(s => s.status === SPOT_STATUS.FREE).length; const occupied = spotsState.spots.filter(s => s.status === SPOT_STATUS.OCCUPIED).length; const reserved = spotsState.spots.filter(s => s.status === SPOT_STATUS.RESERVED).length; - - document.getElementById('freeCount').textContent = free; + + document.getElementById('freeCount').textContent = free; document.getElementById('occupiedCount').textContent = occupied; document.getElementById('reservedCount').textContent = reserved; - document.getElementById('totalCount').textContent = spotsState.spots.length; + document.getElementById('totalCount').textContent = spotsState.spots.length; } -/** - * Met à jour le formulaire de réservation - */ function updateReservationForm() { const select = document.getElementById('resSpot'); if (!select) return; - - // Garder la première option + const firstOption = select.options[0]; select.innerHTML = ''; select.appendChild(firstOption); - - // Ajouter uniquement les places libres - const freeSpots = spotsState.spots.filter(s => s.status === SPOT_STATUS.FREE); - - freeSpots.forEach(spot => { - const option = document.createElement('option'); - option.value = spot.id; - option.textContent = `Place ${spot.number}`; - select.appendChild(option); - }); + + spotsState.spots + .filter(s => s.status === SPOT_STATUS.FREE) + .forEach(spot => { + const option = document.createElement('option'); + option.value = spot.id; + option.textContent = `Place ${spot.number}`; + select.appendChild(option); + }); } -/** - * Sélectionne une place pour la réservation - */ function selectSpotForReservation(spotId) { const select = document.getElementById('resSpot'); - if (select) { - select.value = spotId; - } + if (select) select.value = spotId; } -/** - * Définit le statut d'une place - */ -function setSpotStatus(spotId, status) { +// ============================================ +// ACTIONS ADMIN & SIMULATION +// ============================================ + +async function setSpotStatus(spotId, status) { const spot = spotsState.spots.find(s => s.id === spotId || s.number === spotId); - if (spot) { - spot.status = status; - spot.lastUpdate = new Date().toISOString(); - saveSpots(); - renderMap(); - updateStats(); - updateReservationForm(); - } -} + if (!spot) return; -/** - * Change le nombre total de places - */ -function setTotalSpots(count) { - MAP_CONFIG.totalSpots = count; - - // Ajuster le tableau des places - if (count > spotsState.spots.length) { - // Ajouter des places - for (let i = spotsState.spots.length + 1; i <= count; i++) { - spotsState.spots.push({ - id: i, - number: i, - status: SPOT_STATUS.FREE, - lastUpdate: new Date().toISOString(), - sensorId: `SENSOR_${String(i).padStart(3, '0')}` + spot.status = status; + spot.lastUpdate = new Date().toISOString(); + saveSpots(); + renderMap(); + updateStats(); + updateReservationForm(); + + // Synchroniser avec l'API si connecté + try { + const token = localStorage.getItem('smart_parking_token'); + if (token) { + await fetch(`/api/spots/${spot.id}/status`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token + }, + body: JSON.stringify({ status }) }); } - } else if (count < spotsState.spots.length) { - // Supprimer des places + } catch (_err) { /* mode offline */ } +} + +function setTotalSpots(count) { + MAP_CONFIG.totalSpots = count; + if (count > spotsState.spots.length) { + for (let i = spotsState.spots.length + 1; i <= count; i++) { + spotsState.spots.push({ + id: i, number: i, + status: SPOT_STATUS.FREE, + lastUpdate: new Date().toISOString(), + sensorId: `SENSOR_${String(i).padStart(3, '0')}` + }); + } + } else { spotsState.spots = spotsState.spots.slice(0, count); } - saveSpots(); renderMap(); updateStats(); updateReservationForm(); } -/** - * Simulation automatique - */ -function startSimulation() { - setInterval(() => { - // 20% de chance de changer une place - if (Math.random() > 0.8) { - const randomSpot = spotsState.spots[Math.floor(Math.random() * spotsState.spots.length)]; - - if (randomSpot.status === SPOT_STATUS.FREE && Math.random() > 0.5) { - setSpotStatus(randomSpot.id, SPOT_STATUS.OCCUPIED); - } else if (randomSpot.status === SPOT_STATUS.OCCUPIED && Math.random() > 0.3) { - setSpotStatus(randomSpot.id, SPOT_STATUS.FREE); - } - } - }, MAP_CONFIG.updateInterval); -} +// ============================================ +// UTILITAIRES +// ============================================ -/** - * Vérifie si l'utilisateur est admin - */ function isAdmin() { const user = JSON.parse(localStorage.getItem('smart_parking_user') || 'null'); return user && user.role === 'admin'; } -/** - * Retourne le label du statut - */ function getStatusLabel(status) { - const labels = { - [SPOT_STATUS.FREE]: 'Libre', - [SPOT_STATUS.OCCUPIED]: 'Occupée', - [SPOT_STATUS.RESERVED]: 'Réservée' - }; - return labels[status] || 'Inconnu'; + return { free: 'Libre', occupied: 'Occupée', reserved: 'Réservée' }[status] || 'Inconnu'; } -/** - * Retourne l'icône du statut - */ function getStatusIcon(status) { - const icons = { - [SPOT_STATUS.FREE]: '✓', - [SPOT_STATUS.OCCUPIED]: '🚗', - [SPOT_STATUS.RESERVED]: '📅' - }; - return icons[status] || '?'; + return { free: '✓', occupied: '🚗', reserved: '📅' }[status] || '?'; } -/** - * Formate une date - */ function formatDate(dateString) { - const date = new Date(dateString); - return date.toLocaleString('fr-FR', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' + if (!dateString) return 'N/A'; + return new Date(dateString).toLocaleString('fr-FR', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit' }); } -// Exporter les fonctions +// ============================================ +// EXPORT +// ============================================ + window.ParkingMap = { - refresh: () => { - loadSpots(); + refresh: async () => { + const loaded = await loadSpotsFromAPI(); + if (!loaded) loadSpotsFromStorage(); renderMap(); updateStats(); updateReservationForm(); }, setSpotStatus, setTotalSpots, - getSpots: () => spotsState.spots, + getSpots: () => spotsState.spots, getFreeSpots: () => spotsState.spots.filter(s => s.status === SPOT_STATUS.FREE), getStats: () => ({ - total: spotsState.spots.length, - free: spotsState.spots.filter(s => s.status === SPOT_STATUS.FREE).length, + total: spotsState.spots.length, + free: spotsState.spots.filter(s => s.status === SPOT_STATUS.FREE).length, occupied: spotsState.spots.filter(s => s.status === SPOT_STATUS.OCCUPIED).length, reserved: spotsState.spots.filter(s => s.status === SPOT_STATUS.RESERVED).length }) -}; +}; \ No newline at end of file diff --git a/mosquitto/config/mosquitto.conf b/mosquitto/config/mosquitto.conf new file mode 100644 index 0000000..2493f74 --- /dev/null +++ b/mosquitto/config/mosquitto.conf @@ -0,0 +1,24 @@ +# ============================================================ +# Configuration Mosquitto - Smart Parking +# Fichier : mosquitto/config/mosquitto.conf +# ============================================================ + +# Port d'écoute MQTT standard +listener 1883 + +# Port WebSocket (optionnel, pour tests depuis navigateur) +listener 9001 +protocol websockets + +# Autoriser les connexions sans authentification +# (à sécuriser en production avec un fichier password) +allow_anonymous true + +# Persistance des messages +persistence true +persistence_location /mosquitto/data/ + +# Logs +log_dest file /mosquitto/log/mosquitto.log +log_dest stdout +log_type all \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index c68b397..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Parking", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/server/db/database.js b/server/db/database.js index 11b1f42..f877c0e 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -1,8 +1,8 @@ /** * ============================================ * DATABASE.JS - Gestion MariaDB - * Smart Parking - BTS CIEL IR - * MODIFIÉ : ajout de getReservationById + * Smart Parking v2.0 + * AJOUTÉ : expireReservations() — libère auto les places * ============================================ */ @@ -11,16 +11,20 @@ const bcrypt = require('bcryptjs'); require('dotenv').config(); const pool = mysql.createPool({ - host: process.env.DB_HOST || 'localhost', - port: process.env.DB_PORT || 3306, - user: process.env.DB_USER || 'smartparking_user', - password: process.env.DB_PASSWORD || 'smartparking_pass', - database: process.env.DB_NAME || 'smartparking', + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 3306, + user: process.env.DB_USER || 'smartparking_user', + password: process.env.DB_PASSWORD || 'smartparking_pass', + database: process.env.DB_NAME || 'smartparking', waitForConnections: true, - connectionLimit: 10, - queueLimit: 0 + connectionLimit: 10, + queueLimit: 0 }); +// ============================================ +// INITIALISATION +// ============================================ + async function initDatabase() { try { await pool.query(` @@ -97,9 +101,9 @@ async function initDatabase() { ) `); - console.log('✅ Tables vérifiées/créées avec succès'); + console.log('✅ Tables vérifiées/créées'); - // Compte admin par défaut + // Admin par défaut const [rows] = await pool.query('SELECT id FROM users WHERE email = ?', ['admin@smartparking.fr']); if (rows.length === 0) { const hashed = await bcrypt.hash('admin123', 10); @@ -107,27 +111,23 @@ async function initDatabase() { 'INSERT INTO users (name, email, phone, password, role) VALUES (?, ?, ?, ?, ?)', ['Administrateur', 'admin@smartparking.fr', '01 23 45 67 89', hashed, 'admin'] ); - console.log('✅ Administrateur par défaut créé'); + console.log('✅ Admin par défaut créé'); } - // Places par défaut + // 10 places par défaut const [spots] = await pool.query('SELECT COUNT(*) AS count FROM spots'); if (spots[0].count === 0) { for (let i = 1; i <= 10; i++) { - let status = 'free'; - const rand = Math.random(); - if (rand > 0.85) status = 'reserved'; - else if (rand > 0.60) status = 'occupied'; await pool.query( 'INSERT INTO spots (number, sensor_id, status) VALUES (?, ?, ?)', - [i, `SENSOR_${String(i).padStart(3, '0')}`, status] + [i, `SENSOR_${String(i).padStart(3, '0')}`, 'free'] ); } - console.log('✅ 10 places par défaut créées'); + console.log('✅ 10 places créées (toutes libres)'); } } catch (err) { - console.error("❌ Erreur lors de l'initialisation de la base :", err.message); + console.error("❌ Erreur init base :", err.message); throw err; } } @@ -166,8 +166,10 @@ async function getAllUsers() { async function updateUser(id, updates) { const fields = Object.keys(updates).map(k => `${k} = ?`).join(', '); - const values = Object.values(updates); - const [result] = await pool.query(`UPDATE users SET ${fields} WHERE id = ?`, [...values, id]); + const [result] = await pool.query( + `UPDATE users SET ${fields} WHERE id = ?`, + [...Object.values(updates), id] + ); return { changed: result.affectedRows }; } @@ -225,10 +227,6 @@ async function createReservation(userId, spotId, date, startTime, endTime, durat return { id: result.insertId }; } -/** - * Récupère une réservation par son ID - * AJOUTÉ : nécessaire pour l'annulation/complétion (libération de la place) - */ async function getReservationById(id) { const [rows] = await pool.query( `SELECT r.*, s.number AS spot_number @@ -271,6 +269,53 @@ async function updateReservationStatus(id, status) { return { changed: result.affectedRows }; } +/** + * ⭐ NOUVELLE FONCTION — Expiration automatique des réservations + * + * Cherche toutes les réservations actives dont la date+heure de fin + * est déjà dépassée, les passe en "completed" et libère les places. + * + * Appelée toutes les 60 secondes par server.js. + * Cela résout le problème des places qui restent "réservées" indéfiniment. + */ +async function expireReservations() { + // Trouver les réservations actives dont l'heure de fin est passée + const [expiredRows] = await pool.query(` + SELECT r.id, r.spot_id, r.user_id, s.number AS spot_number + FROM reservations r + JOIN spots s ON r.spot_id = s.id + WHERE r.status = 'active' + AND TIMESTAMP(r.date, r.end_time) < NOW() + `); + + for (const res of expiredRows) { + // Passer la réservation en "completed" + await pool.query( + "UPDATE reservations SET status = 'completed' WHERE id = ?", + [res.id] + ); + + // Libérer la place (la passer en "free") + // (le capteur Arduino prendra le relais ensuite si une voiture est encore là) + await pool.query( + "UPDATE spots SET status = 'free', last_update = NOW() WHERE id = ?", + [res.spot_id] + ); + + // Ajouter à l'historique + await pool.query( + 'INSERT INTO history (action, details, user_id) VALUES (?, ?, ?)', + [ + 'Expiration réservation', + `Réservation #${res.id} expirée — place ${res.spot_number} libérée automatiquement`, + res.user_id + ] + ); + } + + return expiredRows.length; +} + // ============================================ // HISTORIQUE // ============================================ @@ -319,7 +364,7 @@ async function getStats(days = 7) { } // ============================================ -// MQTT +// MQTT EVENTS // ============================================ async function recordMqttEvent(topic, message) { @@ -336,11 +381,11 @@ async function recordMqttEvent(topic, message) { async function closeDatabase() { await pool.end(); - console.log('🔌 Connexions à la base fermées'); + console.log('🔌 Connexions base fermées'); } module.exports = { - pool, // exposé pour les requêtes directes si besoin + pool, initDatabase, closeDatabase, // Utilisateurs @@ -350,6 +395,7 @@ module.exports = { // Réservations createReservation, getReservationById, getReservationsByUser, getAllReservations, updateReservationStatus, + expireReservations, // ← NOUVEAU // Historique addHistory, getHistory, // Stats diff --git a/server/package.json b/server/package.json index 3c164bf..4fa942a 100644 --- a/server/package.json +++ b/server/package.json @@ -1,12 +1,11 @@ { "name": "smart-parking-server", - "version": "1.0.0", - "description": "Backend Smart Parking avec MariaDB et Docker", + "version": "2.0.0", + "description": "Backend Smart Parking avec MariaDB, Docker et MQTT Arduino", "main": "server.js", "scripts": { "start": "node server.js", - "dev": "nodemon server.js", - "init-db": "node db/init.js" + "dev": "nodemon server.js" }, "dependencies": { "express": "^4.18.2", @@ -16,7 +15,8 @@ "bcryptjs": "^2.4.3", "jsonwebtoken": "^9.0.2", "dotenv": "^16.3.1", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "mqtt": "^5.3.4" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/server/server.js b/server/server.js index 45d8d9d..2dee5e4 100644 --- a/server/server.js +++ b/server/server.js @@ -1,70 +1,200 @@ -const express = require('express'); -const cors = require('cors'); +/** + * ============================================ + * SERVER.JS - Serveur principal Smart Parking + * VERSION 2.0 - MQTT Arduino + Expiration réservations + * ============================================ + */ + +const express = require('express'); +const cors = require('cors'); const bodyParser = require('body-parser'); -const path = require('path'); +const path = require('path'); +const mqtt = require('mqtt'); require('dotenv').config(); -const db = require('./db/database'); +const db = require('./db/database'); const apiRoutes = require('./routes/api'); const PORT = process.env.PORT || 3000; - -const app = express(); +const app = express(); app.use(cors()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); - app.use(express.static(path.join(__dirname, '..'))); - app.use('/api', apiRoutes); -app.get('/', (req, res) => { - res.sendFile(path.join(__dirname, '..', 'index.html')); -}); +app.get('/', (_req, res) => res.sendFile(path.join(__dirname, '..', 'index.html'))); +app.get('/dashboard', (_req, res) => res.sendFile(path.join(__dirname, '..', 'pages', 'dashboard.html'))); -app.get('/dashboard', (req, res) => { - res.sendFile(path.join(__dirname, '..', 'pages', 'dashboard.html')); -}); +// ============================================ +// CONNEXION MQTT (Mosquitto sur le Raspberry Pi) +// ============================================ -async function startServer() { - try { - await db.initDatabase(); - app.listen(PORT, () => { - console.log(` -╔══════════════════════════════════════════════════╗ -║ 🅿️ SMART PARKING SERVER - PRÊT POUR DOCKER ║ -╠══════════════════════════════════════════════════╣ -║ 🌐 Port : ${PORT} -║ 🗄️ Base : MariaDB (${process.env.DB_HOST}) -║ 🔐 JWT sécurisé -╚══════════════════════════════════════════════════╝ - `); +/** + * Topics attendus depuis l'Arduino : + * + * smartparking/sensor/1 → "1" = voiture détectée (occupée) + * smartparking/sensor/1 → "0" = place libre + * + * L'Arduino publie sur ce topic via le shield Ethernet/WiFi. + * Le numéro à la fin correspond au numéro de place (1 à N). + * + * Pour tester sans Arduino (ligne de commande sur le Pi) : + * mosquitto_pub -h localhost -t "smartparking/sensor/1" -m "1" + * mosquitto_pub -h localhost -t "smartparking/sensor/1" -m "0" + */ + +const MQTT_HOST = process.env.MQTT_HOST || 'localhost'; +const MQTT_PORT = process.env.MQTT_PORT || 1883; +const MQTT_TOPIC = 'smartparking/sensor/#'; // # = wildcard, reçoit tous les capteurs + +let mqttClient = null; + +function connectMQTT() { + const brokerUrl = `mqtt://${MQTT_HOST}:${MQTT_PORT}`; + console.log(`🔌 Connexion au broker MQTT : ${brokerUrl}`); + + mqttClient = mqtt.connect(brokerUrl, { + clientId: 'smartparking-server-' + Math.random().toString(16).slice(3), + keepalive: 60, + reconnectPeriod: 5000, // Reconnexion automatique toutes les 5s si coupure + connectTimeout: 10000 }); - setInterval(async () => { - try { - const spots = await db.getAllSpots(); - const total = spots.length; - const free = spots.filter(s => s.status === 'free').length; - const occupied = spots.filter(s => s.status === 'occupied').length; - const reserved = spots.filter(s => s.status === 'reserved').length; - await db.recordStats(total, free, occupied, reserved); - } catch (err) { - console.error('❌ Erreur stats:', err.message); - } - }, 5 * 60 * 1000); + mqttClient.on('connect', () => { + console.log('✅ MQTT connecté au broker Mosquitto'); + mqttClient.subscribe(MQTT_TOPIC, (err) => { + if (err) { + console.error('❌ Erreur abonnement MQTT :', err.message); + } else { + console.log(`📡 Abonné au topic : ${MQTT_TOPIC}`); + } + }); + }); - } catch (err) { - console.error('❌ Erreur au démarrage :', err); - process.exit(1); - } + // Message reçu depuis un capteur Arduino + mqttClient.on('message', async (topic, messageBuffer) => { + const message = messageBuffer.toString().trim(); + const topicParts = topic.split('/'); // ['smartparking', 'sensor', '1'] + const spotNumber = parseInt(topicParts[2]); // numéro de place + + if (isNaN(spotNumber)) { + console.warn(`⚠️ Topic MQTT invalide : ${topic}`); + return; + } + + console.log(`📩 MQTT reçu → topic: ${topic} | valeur: ${message}`); + + // "1" = voiture présente → occupée | "0" = libre + const newStatus = message === '1' ? 'occupied' : 'free'; + + try { + // Récupérer la place par son numéro + const spots = await db.getAllSpots(); + const spot = spots.find(s => s.number === spotNumber); + + if (!spot) { + console.warn(`⚠️ Place numéro ${spotNumber} introuvable en base`); + return; + } + + // Ne pas écraser une place RÉSERVÉE avec un simple signal capteur + // (la réservation prime sur le capteur physique) + if (spot.status === 'reserved' && newStatus === 'occupied') { + console.log(`ℹ️ Place ${spotNumber} déjà réservée — capteur ignoré`); + await db.recordMqttEvent(topic, message); + return; + } + + // Mettre à jour en base + await db.updateSpotStatus(spot.id, newStatus); + await db.recordMqttEvent(topic, message); + + console.log(`✅ Place ${spotNumber} mise à jour → ${newStatus}`); + } catch (err) { + console.error('❌ Erreur traitement message MQTT :', err.message); + } + }); + + mqttClient.on('error', (err) => { + console.error('❌ Erreur MQTT :', err.message); + }); + + mqttClient.on('reconnect', () => { + console.log('🔄 Reconnexion MQTT en cours...'); + }); + + mqttClient.on('offline', () => { + console.warn('⚠️ Client MQTT hors-ligne'); + }); } +// ============================================ +// DÉMARRAGE DU SERVEUR +// ============================================ + +async function startServer() { + try { + // 1. Initialiser la base de données + await db.initDatabase(); + + // 2. Démarrer le serveur HTTP + app.listen(PORT, () => { + console.log(` +╔══════════════════════════════════════════════════════╗ +║ 🅿️ SMART PARKING SERVER v2.0 - PRÊT ║ +╠══════════════════════════════════════════════════════╣ +║ 🌐 Port HTTP : ${PORT} +║ 🗄️ Base : MariaDB (${process.env.DB_HOST || 'localhost'}) +║ 📡 MQTT : ${MQTT_HOST}:${MQTT_PORT} +║ 🔐 JWT sécurisé +╚══════════════════════════════════════════════════════╝ + `); + }); + + // 3. Connecter le client MQTT (broker Mosquitto) + connectMQTT(); + + // 4. Enregistrer les statistiques toutes les 5 minutes + setInterval(async () => { + try { + const spots = await db.getAllSpots(); + const total = spots.length; + const free = spots.filter(s => s.status === 'free').length; + const occupied = spots.filter(s => s.status === 'occupied').length; + const reserved = spots.filter(s => s.status === 'reserved').length; + await db.recordStats(total, free, occupied, reserved); + } catch (err) { + console.error('❌ Erreur stats :', err.message); + } + }, 5 * 60 * 1000); + + // 5. ⭐ EXPIRATION AUTOMATIQUE DES RÉSERVATIONS toutes les minutes + // Libère les places dont l'heure de fin est dépassée + setInterval(async () => { + try { + const count = await db.expireReservations(); + if (count > 0) { + console.log(`⏰ ${count} réservation(s) expirée(s) — places libérées`); + } + } catch (err) { + console.error('❌ Erreur expiration réservations :', err.message); + } + }, 60 * 1000); // toutes les 60 secondes + + } catch (err) { + console.error('❌ Erreur au démarrage :', err); + process.exit(1); + } +} + +// Arrêt propre process.on('SIGINT', async () => { - console.log('\n🛑 Arrêt du serveur...'); - await db.closeDatabase(); - process.exit(0); + console.log('\n🛑 Arrêt du serveur...'); + if (mqttClient) mqttClient.end(); + await db.closeDatabase(); + process.exit(0); }); startServer(); \ No newline at end of file