// === Início de: ./src/server.js === // src/server.js const http = require('http'); const { Server } = require('socket.io'); const jwt = require('jsonwebtoken'); const app = require('./app'); const db = require('./db/knex'); const config = require('./config'); // ✅ mqtt exports { on, ... } const mqttClient = require('./mqtt'); const { normalizeChargingStatus } = require('./domain/normalize/chargingStatus'); const { normalizeChargingConfig } = require('./domain/normalize/chargingConfig'); const server = http.createServer(app); const io = new Server(server, { cors: { origin: config.corsOrigins, methods: ['GET', 'POST'], credentials: true, }, }); console.log('MQTT client exports=', Object.keys(mqttClient || {})); if (typeof mqttClient?.on !== 'function') { console.error('[server] mqttClient.on não existe. Verifica src/mqtt/index.js'); } // --------------------------- // auth middleware do socket // --------------------------- io.use((socket, next) => { const token = socket.handshake.auth?.token; if (!token) return next(new Error('Authentication error: token required')); try { const payload = jwt.verify(token, config.jwtSecret); socket.user = payload; next(); } catch (err) { next(new Error('Authentication error')); } }); io.on('connection', (socket) => { console.log(`Client connected: ${socket.id}, user: ${socket.user.username}`); socket.on('joinChargers', async (chargerIds = []) => { try { if (!Array.isArray(chargerIds) || chargerIds.length === 0) return; const rows = await db('chargers') .whereIn('id', chargerIds) .andWhere({ user_id: socket.user.id }) .select('id'); const allowed = rows.map((r) => r.id); allowed.forEach((id) => socket.join(id)); console.log(`Socket ${socket.id} joined chargers: ${allowed}`); } catch (err) { console.error('joinChargers error:', err); } }); socket.on('disconnect', (reason) => { console.log(`Client disconnected: ${socket.id}, reason: ${reason}`); }); }); // --------------------------- // Relay MQTT -> Socket.IO // --------------------------- if (typeof mqttClient?.on === 'function') { mqttClient.on('charging-status', (data) => { const normalized = normalizeChargingStatus(data); const chargerId = normalized.charger_id; if (!chargerId) return; io.to(chargerId).emit('charging-status', normalized); }); mqttClient.on('charging-config', (data) => { const normalized = normalizeChargingConfig(data); const chargerId = normalized.charger_id; if (!chargerId) return; io.to(chargerId).emit('charging-config', normalized); }); mqttClient.on('scheduler-state', (evt) => { const chargerId = evt?.charger_id; if (!chargerId) return; io.to(chargerId).emit('evse-scheduler', evt); io.to(chargerId).emit('scheduler-state', evt); }); mqttClient.on('loadbalancing-state', (evt) => { const chargerId = evt?.charger_id; if (!chargerId) return; io.to(chargerId).emit('evse-loadbalancing', evt); io.to(chargerId).emit('loadbalancing-state', evt); }); mqttClient.on('meter-live', (evt) => { const chargerId = evt?.charger_id; if (!chargerId) return; io.to(chargerId).emit('meter-live', evt); }); mqttClient.on('auth-state', (evt) => { const chargerId = evt?.charger_id; if (!chargerId) return; io.to(chargerId).emit('auth-state', evt); }); mqttClient.on('meters-config', (evt) => { const chargerId = evt?.charger_id; if (!chargerId) return; io.to(chargerId).emit('meters-config', evt); }); } server.listen(config.port, () => { console.log(`Server listening on http://localhost:${config.port}`); }); // === Fim de: ./src/server.js === // === Início de: ./src/app.js === // src/app.js const express = require('express'); const config = require('./config'); const usersRouter = require('./routes/users.routes'); const chargersRouter = require('./routes/chargers.routes'); const sessionsRouter = require('./routes/sessions.routes'); const pushRouter = require('./routes/push.routes'); const errorHandler = require('./middleware/errorHandler'); const app = express(); // body parser app.use(express.json({ limit: '1mb' })); // CORS simples sem dependência extra app.use((req, res, next) => { const origin = req.headers.origin; if (origin && config.corsOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); if (req.method === 'OPTIONS') return res.sendStatus(204); next(); }); // health app.get('/health', (req, res) => res.json({ ok: true })); // routes app.use('/api/users', usersRouter); app.use('/api/chargers', chargersRouter); app.use('/api/charger_sessions', sessionsRouter); app.use('/api/push', pushRouter); // 404 app.use((req, res) => { res.status(404).json({ success: false, message: 'Not found' }); }); // error handler app.use(errorHandler); module.exports = app; // === Fim de: ./src/app.js === // === Início de: ./src/services/push.service.js === // src/services/push.service.js const webpush = require('web-push'); const config = require('../config'); const pushRepo = require('../repositories/push.repo'); const hasVapid = !!config.vapid.publicKey && !!config.vapid.privateKey; if (!hasVapid) { console.warn('[Push] VAPID keys não definidas. Push desativado.'); } else { webpush.setVapidDetails(config.vapid.subject, config.vapid.publicKey, config.vapid.privateKey); } async function sendWithRetry(subscription, message, tries = 2) { try { return await webpush.sendNotification(subscription, message); } catch (err) { const code = err?.statusCode; if ((code === 429 || code >= 500) && tries > 1) { await new Promise((r) => setTimeout(r, 1000)); return sendWithRetry(subscription, message, tries - 1); } throw err; } } async function sendPushToUser(userId, payload) { if (!hasVapid) return; const subs = await pushRepo.listByUser(userId); if (!subs.length) return; const message = JSON.stringify(payload); await Promise.allSettled( subs.map(async (s) => { const subscription = { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth }, }; try { await sendWithRetry(subscription, message); } catch (err) { const code = err?.statusCode; if (code === 404 || code === 410) { await pushRepo.deleteById(s.id); } else { console.error('[Push] erro ao enviar:', err.message); } } }) ); } module.exports = { sendPushToUser }; // === Fim de: ./src/services/push.service.js === // === Início de: ./src/services/sessions.service.js === // src/services/sessions.service.js const chargersRepo = require('../repositories/chargers.repo'); const sessionsRepo = require('../repositories/sessions.repo'); const { httpError } = require('../utils/httpError'); function stripUndef(obj) { return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); } async function listByCharger(userId, chargerId) { const charger = await chargersRepo.findByIdForUser(chargerId, userId); if (!charger) throw httpError(403, 'Acesso não autorizado'); return sessionsRepo.listByCharger(chargerId); } async function history(userId, chargerId, viewMode) { const charger = await chargersRepo.findByIdForUser(chargerId, userId); if (!charger) throw httpError(403, 'Acesso não autorizado'); const rows = await sessionsRepo.historyAgg(chargerId, viewMode); if (!rows.length) return []; return rows.map((r) => ({ started_at: r.period, kwh: parseFloat(r.total_kwh) || 0, })); } async function getById(userId, sessionId) { const session = await sessionsRepo.findByIdForUser(sessionId, userId); if (!session) throw httpError(404, 'Sessão não encontrada'); return session; } async function create(userId, charger_id) { const charger = await chargersRepo.findByIdForUser(charger_id, userId); if (!charger) throw httpError(403, 'Acesso não autorizado'); return sessionsRepo.insertSession({ charger_id, started_at: new Date() }); } async function update(userId, sessionId, patch) { const session = await sessionsRepo.findByIdForUser(sessionId, userId); if (!session) throw httpError(404, 'Sessão não encontrada'); const clean = stripUndef(patch); return sessionsRepo.updateById(sessionId, clean); } async function remove(userId, sessionId) { const deleted = await sessionsRepo.deleteByIdForUser(sessionId, userId); if (!deleted) throw httpError(404, 'Sessão não encontrada'); return true; } module.exports = { listByCharger, history, getById, create, update, remove }; // === Fim de: ./src/services/sessions.service.js === // === Início de: ./src/services/pushHttp.service.js === // src/services/pushHttp.service.js const pushRepo = require('../repositories/push.repo'); const { httpError } = require('../utils/httpError'); async function subscribe(userId, endpoint, keys, userAgent) { if (!endpoint || !keys?.p256dh || !keys?.auth) { throw httpError(400, 'Subscription inválida'); } // dedupe (mesmo user) const existing = await pushRepo.findByUserAndEndpoint(userId, endpoint); if (existing) return { row: existing, created: false }; // como endpoint é UNIQUE na tabela, evita conflito com outro user const usedByOther = await pushRepo.findByEndpoint(endpoint); if (usedByOther && usedByOther.user_id !== userId) { throw httpError(409, 'Este endpoint já está associado a outro utilizador'); } const inserted = await pushRepo.insertSubscription({ user_id: userId, endpoint, p256dh: keys.p256dh, auth: keys.auth, user_agent: userAgent || null, created_at: new Date().toISOString(), }); return { row: inserted, created: true }; } async function unsubscribe(userId, endpoint) { if (!endpoint) return { ok: true, message: 'No subscription' }; await pushRepo.deleteByUserAndEndpoint(userId, endpoint); return { ok: true, message: 'Unsubscribed' }; } module.exports = { subscribe, unsubscribe }; // === Fim de: ./src/services/pushHttp.service.js === // === Início de: ./src/services/configs.service.js === // === Fim de: ./src/services/configs.service.js === // === Início de: ./src/services/chargers.service.js === // src/services/chargers.service.js const crypto = require('crypto'); const axios = require('axios'); const chargersRepo = require('../repositories/chargers.repo'); const { httpError } = require('../utils/httpError'); const mqttClient = require('../mqtt'); function stripUndef(obj) { return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); } // throttle in-memory (por charger) const lastConfigUpdateAt = new Map(); function clampAmp(v) { const n = Number(v); return Math.max(6, Math.min(n, 64)); } function clampTemp(v) { const n = Number(v); return Math.max(0, Math.min(n, 120)); } function normalizeNumericFields(charger) { const numericFields = [ 'power_l1', 'power_l2', 'power_l3', 'voltage_l1', 'voltage_l2', 'voltage_l3', 'current_l1', 'current_l2', 'current_l3', 'charging_current', 'consumption', ]; numericFields.forEach((field) => { const v = charger[field]; charger[field] = v === null || v === undefined || v === '' ? 0 : Number(v); if (Number.isNaN(charger[field])) charger[field] = 0; }); return charger; } async function list(userId) { return chargersRepo.listByUser(userId); } async function getOne(userId, id) { const charger = await chargersRepo.findByIdForUser(id, userId); if (!charger) throw httpError(404, 'Carregador não encontrado'); let config = await chargersRepo.getConfig(charger.id); if (!config) { config = { charger_id: charger.id, max_charging_current: 32, require_auth: false, rcm_enabled: false, temperature_limit: 60, }; } return { ...normalizeNumericFields(charger), config }; } async function create(userId, location) { if (!location || typeof location !== 'string' || location.trim().length < 1) { throw httpError(400, 'O campo location é obrigatório'); } const now = new Date().toISOString(); let chargerID; do { chargerID = crypto.randomBytes(6).toString('hex'); } while (await chargersRepo.findByMqttTopic(chargerID)); const mqtt_topic = chargerID; const mqtt_user = chargerID; const mqtt_pass = crypto.randomBytes(6).toString('hex'); const charger = await chargersRepo.insertCharger({ user_id: userId, location: location.trim(), status: 'offline', charging_current: 0, charging_time: 0, consumption: 0, power_l1: 0.0, power_l2: 0.0, power_l3: 0.0, voltage_l1: 0.0, voltage_l2: 0.0, voltage_l3: 0.0, current_l1: 0.0, current_l2: 0.0, current_l3: 0.0, mqtt_user, mqtt_pass, mqtt_topic, updated_at: now, }); await chargersRepo.insertConfig({ charger_id: charger.id, max_charging_current: 32, require_auth: false, rcm_enabled: false, temperature_limit: 60, config_received_at: now, }); // mosquitto mgmt (mantém como tinhas) try { await axios.post( 'http://localhost:7000/client/create', { client_name: charger.mqtt_user, chargeID: charger.mqtt_topic, password: charger.mqtt_pass, }, { timeout: 5000 } ); } catch (err) { console.error('Erro ao criar cliente Mosquitto:', err?.response?.data || err.message); } return charger; } async function update(userId, id, payload = {}) { let { charger = {}, config = {} } = payload; // compat: { location } no root if (payload.location && !charger.location) charger.location = payload.location; // só permitimos location por agora const safeChargerUpdate = {}; if (charger.location !== undefined) safeChargerUpdate.location = charger.location; let updatedCharger = null; if (Object.keys(safeChargerUpdate).length > 0) { updatedCharger = await chargersRepo.updateChargerForUser(id, userId, { ...safeChargerUpdate, updated_at: new Date().toISOString(), }); } else { updatedCharger = await chargersRepo.findByIdForUser(id, userId); } if (!updatedCharger) throw httpError(404, 'Carregador não encontrado'); // ✅ config patch com whitelist (para evitar lixo no DB) if (config && Object.keys(config).length > 0) { const ALLOWED = ['max_charging_current', 'require_auth', 'rcm_enabled', 'temperature_limit']; let safeConfig = Object.fromEntries( Object.entries(config || {}).filter(([k]) => ALLOWED.includes(k)) ); // clamp leve (evita valores inválidos no DB) if (safeConfig.max_charging_current !== undefined) { safeConfig.max_charging_current = clampAmp(safeConfig.max_charging_current); } if (safeConfig.temperature_limit !== undefined) { safeConfig.temperature_limit = clampTemp(safeConfig.temperature_limit); } await chargersRepo.upsertConfig(id, { ...stripUndef(safeConfig), config_received_at: new Date().toISOString(), }); } return updatedCharger; } async function remove(userId, id) { const charger = await chargersRepo.findByIdForUser(id, userId); if (!charger) throw httpError(404, 'Carregador não encontrado'); try { await axios.post( 'http://localhost:7000/client/delete', { client_name: charger.mqtt_user, chargeID: charger.mqtt_topic, }, { timeout: 5000 } ); } catch (err) { console.error('Erro ao deletar cliente Mosquitto:', err?.response?.data || err.message); } await chargersRepo.deleteChargerForUser(id, userId); return true; } async function updateConfig(userId, id, incomingConfig = {}) { const charger = await chargersRepo.findByIdForUser(id, userId); if (!charger) throw httpError(404, 'Charger not found or unauthorized'); const existing = await chargersRepo.getConfig(id); const nowMs = Date.now(); const lastMs = lastConfigUpdateAt.get(id) || 0; const tooSoon = nowMs - lastMs < 800; const ALLOWED = ['max_charging_current', 'require_auth', 'rcm_enabled', 'temperature_limit']; let safeConfig = Object.fromEntries( Object.entries(incomingConfig || {}).filter(([k]) => ALLOWED.includes(k)) ); if (safeConfig.max_charging_current !== undefined) { safeConfig.max_charging_current = clampAmp(safeConfig.max_charging_current); } if (safeConfig.temperature_limit !== undefined) { safeConfig.temperature_limit = clampTemp(safeConfig.temperature_limit); } const onlyAmp = Object.keys(safeConfig).length === 1 && safeConfig.max_charging_current !== undefined; if ( existing && onlyAmp && Number(existing.max_charging_current) === Number(safeConfig.max_charging_current) ) { return { data: existing, message: 'Config unchanged' }; } if (tooSoon && existing && onlyAmp) { return { data: existing, message: 'Throttled' }; } const updated = await chargersRepo.upsertConfig(id, { ...safeConfig, config_received_at: new Date().toISOString(), }); lastConfigUpdateAt.set(id, nowMs); // publica p/ firmware novo: cmd/evse/settings const evseSettings = {}; if (safeConfig.max_charging_current !== undefined) { evseSettings.currentLimit = Number(safeConfig.max_charging_current); } if (safeConfig.temperature_limit !== undefined) { evseSettings.temperatureLimit = Number(safeConfig.temperature_limit); } if (Object.keys(evseSettings).length > 0) { mqttClient.sendEvseSettings(charger.mqtt_topic, evseSettings); } return { data: updated }; } async function getSchedules(userId, id) { const charger = await chargersRepo.findByIdForUser(id, userId); if (!charger) throw httpError(404, 'Carregador não encontrado'); return chargersRepo.listSchedules(id); } async function createSchedule(userId, id, start, end, repeat) { const charger = await chargersRepo.findByIdForUser(id, userId); if (!charger) throw httpError(404, 'Carregador não encontrado'); const row = await chargersRepo.insertSchedule({ charger_id: id, start, end, repeat, created_at: new Date().toISOString(), }); return row; } async function action(userId, id, actionName, ampLimit) { const charger = await chargersRepo.findByIdForUser(id, userId); if (!charger) throw httpError(404, 'Carregador não encontrado ou não autorizado'); if (ampLimit !== undefined) { const safeAmp = clampAmp(ampLimit); await chargersRepo.upsertConfig(id, { max_charging_current: safeAmp, config_received_at: new Date().toISOString(), }); mqttClient.sendEvseSettings(charger.mqtt_topic, { currentLimit: safeAmp }); } const enable = actionName === 'start'; mqttClient.sendEnable(charger.mqtt_topic, enable); return true; } module.exports = { list, getOne, create, update, remove, updateConfig, getSchedules, createSchedule, action, }; // === Fim de: ./src/services/chargers.service.js === // === Início de: ./src/services/users.service.js === // src/services/users.service.js const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const config = require('../config'); const usersRepo = require('../repositories/users.repo'); const { httpError } = require('../utils/httpError'); async function login(username, password) { if (!username || !password) { throw httpError(400, 'Usuário e senha são obrigatórios'); } const user = await usersRepo.findByUsername(username); if (!user) throw httpError(401, 'Credenciais inválidas'); const ok = await bcrypt.compare(password, user.password); if (!ok) throw httpError(401, 'Credenciais inválidas'); const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: '24h', }); return { token }; } async function register(username, password) { if ( !username || !password || typeof username !== 'string' || typeof password !== 'string' || username.length < 3 || password.length < 4 ) { throw httpError( 400, 'Nome de usuário deve ter pelo menos 3 caracteres e senha pelo menos 4 caracteres' ); } const existing = await usersRepo.findByUsername(username); if (existing) throw httpError(409, 'Nome de usuário já está em uso'); const hashed = await bcrypt.hash(password, 10); const id = await usersRepo.insertUser({ username, passwordHash: hashed }); const token = jwt.sign({ id, username }, config.jwtSecret, { expiresIn: '24h' }); return { token }; } module.exports = { login, register }; // === Fim de: ./src/services/users.service.js === // === Início de: ./src/utils/httpError.js === // src/utils/httpError.js class HttpError extends Error { constructor(statusCode, message) { super(message); this.statusCode = statusCode; } } function httpError(statusCode, message) { return new HttpError(statusCode, message); } module.exports = { HttpError, httpError }; // === Fim de: ./src/utils/httpError.js === // === Início de: ./src/middleware/auth.js === // src/middleware/auth.js const jwt = require('jsonwebtoken'); const config = require('../config'); function verifyToken(req, res, next) { const authHeader = req.headers['authorization'] || req.headers['Authorization']; if (!authHeader) { return res.status(401).json({ error: 'Token não fornecido' }); } const match = authHeader.match(/^Bearer\s+(.+)$/i); if (!match) { return res.status(401).json({ error: 'Token malformado. Use "Bearer "' }); } const token = match[1]; jwt.verify(token, config.jwtSecret, (err, payload) => { if (err) { if (err.name === 'TokenExpiredError') { return res.status(401).json({ error: 'Sessão expirada' }); } return res.status(401).json({ error: 'Token inválido' }); } if (!payload?.id) { return res.status(401).json({ error: 'Token inválido' }); } req.user = payload; next(); }); } module.exports = verifyToken; // === Fim de: ./src/middleware/auth.js === // === Início de: ./src/middleware/validate.js === // src/middleware/validate.js const { validationResult } = require('express-validator'); function handleValidation(req, res, next) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } next(); } module.exports = handleValidation; // === Fim de: ./src/middleware/validate.js === // === Início de: ./src/middleware/errorHandler.js === // src/middleware/errorHandler.js function errorHandler(err, req, res, next) { console.error('[errorHandler]', err); if (res.headersSent) return next(err); const status = err.statusCode || err.status || 500; const message = err.message || 'Erro interno do servidor'; res.status(status).json({ success: false, message }); } module.exports = errorHandler; // === Fim de: ./src/middleware/errorHandler.js === // === Início de: ./src/db/knex.js === // src/db/knex.js const knex = require('knex'); const path = require('path'); const knexfile = require(path.join(__dirname, '../../knexfile.js')); const env = process.env.NODE_ENV || 'development'; const db = knex(knexfile[env] || knexfile); module.exports = db; // === Fim de: ./src/db/knex.js === // === Início de: ./src/db/migrations/20251123_create_charger_schedules.js === exports.up = async function (knex) { const exists = await knex.schema.hasTable('charger_schedules'); if (exists) return; return knex.schema.createTable('charger_schedules', (t) => { t.uuid('id') .primary() .defaultTo(knex.raw('gen_random_uuid()')); t.uuid('charger_id') .notNullable() .references('id') .inTable('chargers') .onDelete('CASCADE'); t.string('start', 5).notNullable(); t.string('end', 5).notNullable(); t.enu('repeat', ['everyday', 'weekdays', 'weekends']) .notNullable() .defaultTo('everyday'); t.timestamp('created_at').defaultTo(knex.fn.now()); }); }; exports.down = function (knex) { return knex.schema.dropTableIfExists('charger_schedules'); }; // === Fim de: ./src/db/migrations/20251123_create_charger_schedules.js === // === Início de: ./src/db/migrations/20251123_create_push_subscriptions.js === exports.up = function (knex) { return knex.schema.createTable('push_subscriptions', (t) => { t.uuid('id') .primary() .defaultTo(knex.raw('gen_random_uuid()')); // ✅ users.id é integer no teu caso t.integer('user_id') .notNullable() .references('id') .inTable('users') .onDelete('CASCADE'); t.text('endpoint').notNullable().unique(); t.text('p256dh').notNullable(); t.text('auth').notNullable(); t.text('user_agent'); t.timestamp('created_at').defaultTo(knex.fn.now()); }); }; exports.down = function (knex) { return knex.schema.dropTableIfExists('push_subscriptions'); }; // === Fim de: ./src/db/migrations/20251123_create_push_subscriptions.js === // === Início de: ./src/db/migrations/20250619_create_tables.js === // migrations/20250619_create_tables.js exports.up = async function(knex) { // Create 'users' table await knex.schema.createTable('users', (table) => { table.increments('id').primary(); table.string('username', 255).notNullable().unique(); table.string('password', 255).notNullable(); table.timestamp('created_at').defaultTo(knex.fn.now()); }); // Create 'chargers' table with new fields await knex.schema.createTable('chargers', (table) => { table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); table.integer('user_id').unsigned().notNullable() .references('id').inTable('users').onDelete('CASCADE'); table.string('location', 255).notNullable(); table.string('status', 50).notNullable().defaultTo('offline'); table.integer('charging_current').notNullable().defaultTo(32); table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now()); table.string('mqtt_user', 255).notNullable(); table.string('mqtt_pass', 255).notNullable(); table.string('mqtt_topic', 255).notNullable().unique(); table.timestamp('created_at').defaultTo(knex.fn.now()); // Add power and current for 3 phases, voltage and other new fields table.integer('charging_time').notNullable().defaultTo(0); // Total charging time table.decimal('consumption', 8, 2).notNullable().defaultTo(0); // Consumption (kWh) // Power for 3 phases (L1, L2, L3) table.decimal('power_l1', 8, 2).notNullable().defaultTo(0); table.decimal('power_l2', 8, 2).notNullable().defaultTo(0); table.decimal('power_l3', 8, 2).notNullable().defaultTo(0); // Voltage for 3 phases (L1, L2, L3) table.decimal('voltage_l1', 8, 2).notNullable().defaultTo(0); table.decimal('voltage_l2', 8, 2).notNullable().defaultTo(0); table.decimal('voltage_l3', 8, 2).notNullable().defaultTo(0); // Current for 3 phases (L1, L2, L3) table.decimal('current_l1', 8, 2).notNullable().defaultTo(0); table.decimal('current_l2', 8, 2).notNullable().defaultTo(0); table.decimal('current_l3', 8, 2).notNullable().defaultTo(0); }); // Create 'charger_configs' table await knex.schema.createTable('charger_configs', (table) => { table.uuid('charger_id').primary() .references('id').inTable('chargers').onDelete('CASCADE'); table.integer('max_charging_current').notNullable().defaultTo(32); table.boolean('require_auth').notNullable().defaultTo(false); table.boolean('rcm_enabled').notNullable().defaultTo(false); table.integer('temperature_limit').notNullable().defaultTo(60); table.timestamp('config_received_at').notNullable().defaultTo(knex.fn.now()); }); // Create 'charger_sessions' table await knex.schema.createTable('charger_sessions', (table) => { table.increments('id').primary(); table.uuid('charger_id').notNullable() .references('id').inTable('chargers').onDelete('CASCADE'); table.timestamp('started_at').notNullable(); table.timestamp('ended_at'); table.decimal('kwh', 8, 2).notNullable().defaultTo(0); table.decimal('cost', 10, 2); table.timestamp('created_at').defaultTo(knex.fn.now()); }); }; exports.down = async function(knex) { await knex.schema.dropTableIfExists('charger_sessions'); await knex.schema.dropTableIfExists('charger_configs'); await knex.schema.dropTableIfExists('chargers'); await knex.schema.dropTableIfExists('users'); }; // === Fim de: ./src/db/migrations/20250619_create_tables.js === // === Início de: ./src/db/migrations/20250618_enable_pgcrypto.js === exports.up = async function (knex) { await knex.raw('CREATE EXTENSION IF NOT EXISTS pgcrypto'); }; exports.down = async function (knex) { // normalmente não se remove extensão em down, mas deixo seguro: // await knex.raw('DROP EXTENSION IF EXISTS pgcrypto'); }; // === Fim de: ./src/db/migrations/20250618_enable_pgcrypto.js === // === Início de: ./src/db/migrations/20251123084023_create_charger_schedules.js === // shim para compatibilidade com o nome antigo registado no knex_migrations module.exports = require('./20251123_create_charger_schedules'); // === Fim de: ./src/db/migrations/20251123084023_create_charger_schedules.js === // === Início de: ./src/domain/normalize/chargingConfig.js === // src/domain/normalize/chargingConfig.js /** * Normaliza eventos de config (quando o carregador manda config) */ function normalizeChargingConfig(data = {}) { const chargerId = data.charger_id || data.chargerId || data.id; const cfg = data.config || data.raw?.config || data; return { charger_id: chargerId, mqtt_topic: data.mqtt_topic || data.mqttTopic, config: { max_charging_current: cfg.max_charging_current ?? cfg.maxChargingCurrent ?? cfg.max_current ?? cfg.maxCurrent ?? undefined, require_auth: cfg.require_auth ?? cfg.requireAuth ?? undefined, rcm_enabled: cfg.rcm_enabled ?? cfg.rcmEnabled ?? undefined, temperature_limit: cfg.temperature_limit ?? cfg.temperatureThreshold ?? cfg.temp_limit ?? undefined, }, raw: data.raw || data, updated_at: new Date().toISOString(), }; } module.exports = { normalizeChargingConfig }; // === Fim de: ./src/domain/normalize/chargingConfig.js === // === Início de: ./src/domain/normalize/chargingStatus.js === // src/domain/normalize/chargingStatus.js const toNum = (v) => { if (v === null || v === undefined || v === '') return 0; const n = typeof v === 'number' ? v : parseFloat(v); return Number.isFinite(n) ? n : 0; }; const toArr3 = (v) => { if (Array.isArray(v)) return [toNum(v[0]), toNum(v[1]), toNum(v[2])]; if (v && typeof v === 'object') return [toNum(v.l1), toNum(v.l2), toNum(v.l3)]; return [0, 0, 0]; }; const normalizeStatusText = (rawStatus) => { const s = String(rawStatus || '').toLowerCase(); if (s.includes('charging')) return '⚡ Charging'; if (s.includes('ready')) return '🟢 Ready'; if (s.includes('fault') || s.includes('error')) return '⚠️ Fault'; if (s.includes('wait')) return '⚡ Wait'; if (s.includes('not conn') || s.includes('disconnected')) return '🔌 Not Conn.'; if (s.includes('vent')) return '💨 Vent'; return rawStatus || '—'; }; /** * Normaliza eventos de status (realtime) vindos do mqtt -> socket */ function normalizeChargingStatus(data = {}) { const chargerId = data.charger_id || data.chargerId || data.id; const powerArr = toArr3(data.power || data.raw?.power); const voltageArr = toArr3(data.voltage || data.raw?.voltage); const currentArr = toArr3(data.current || data.raw?.current); const status = normalizeStatusText(data.status || data.state || data.raw?.state); const chargingTime = toNum(data.charging_time) || toNum(data.chargingTime) || toNum(data.raw?.chargingTime) || toNum(data.raw?.sessionTime); const consumption = toNum(data.consumption) || toNum(data.raw?.consumption); const chargingCurrent = toNum(data.charging_current) || toNum(data.chargingCurrent) || currentArr[0]; return { charger_id: chargerId, mqtt_topic: data.mqtt_topic || data.mqttTopic, status, stateCode: data.stateCode || data.raw?.stateCode || undefined, consumption, charging_time: chargingTime, charging_current: chargingCurrent, power: powerArr, voltage: voltageArr, current: currentArr, raw: data.raw || data, updated_at: new Date().toISOString(), }; } module.exports = { normalizeChargingStatus }; // === Fim de: ./src/domain/normalize/chargingStatus.js === // === Início de: ./src/routes/chargers.routes.js === // src/routes/chargers.routes.js const express = require('express'); const { body, param } = require('express-validator'); const verifyToken = require('../middleware/auth'); const handleValidation = require('../middleware/validate'); const chargersService = require('../services/chargers.service'); const router = express.Router(); router.use(verifyToken); router.get('/', async (req, res, next) => { try { const data = await chargersService.list(req.user.id); res.json({ success: true, data }); } catch (err) { next(err); } }); router.get( '/:id', [param('id').isUUID()], handleValidation, async (req, res, next) => { try { const data = await chargersService.getOne(req.user.id, req.params.id); res.json({ success: true, data }); } catch (err) { next(err); } } ); router.post( '/', [body('location').exists().isString().isLength({ min: 1 }).trim()], handleValidation, async (req, res, next) => { try { const data = await chargersService.create(req.user.id, req.body.location); res.status(201).json({ success: true, data }); } catch (err) { next(err); } } ); router.put( '/:id', [ param('id').isUUID(), body('charger').optional().isObject(), body('config').optional().isObject(), body('location').optional().isString(), ], handleValidation, async (req, res, next) => { try { const data = await chargersService.update(req.user.id, req.params.id, req.body); res.json({ success: true, data }); } catch (err) { next(err); } } ); router.delete( '/:id', [param('id').isUUID()], handleValidation, async (req, res, next) => { try { await chargersService.remove(req.user.id, req.params.id); res.json({ success: true, message: 'Carregador excluído com sucesso' }); } catch (err) { next(err); } } ); router.put( '/:id/config', [param('id').isUUID(), body('config').isObject()], handleValidation, async (req, res, next) => { try { const out = await chargersService.updateConfig(req.user.id, req.params.id, req.body.config); res.json({ success: true, data: out.data, message: out.message }); } catch (err) { next(err); } } ); router.get( '/:id/schedule', [param('id').isUUID()], handleValidation, async (req, res, next) => { try { const data = await chargersService.getSchedules(req.user.id, req.params.id); res.json({ success: true, data }); } catch (err) { next(err); } } ); router.post( '/:id/schedule', [ param('id').isUUID(), body('start').matches(/^\d{2}:\d{2}$/), body('end').matches(/^\d{2}:\d{2}$/), body('repeat').isIn(['everyday', 'weekdays', 'weekends']), ], handleValidation, async (req, res, next) => { try { const { start, end, repeat } = req.body; const data = await chargersService.createSchedule(req.user.id, req.params.id, start, end, repeat); res.status(201).json({ success: true, data }); } catch (err) { next(err); } } ); router.post( '/:id/action', [ param('id').isUUID(), body('action').isIn(['start', 'stop']), body('ampLimit').optional().isInt({ min: 6, max: 64 }), ], handleValidation, async (req, res, next) => { try { const { action, ampLimit } = req.body; await chargersService.action(req.user.id, req.params.id, action, ampLimit); res.json({ success: true, message: `Comando '${action}' enviado com sucesso` }); } catch (err) { next(err); } } ); module.exports = router; // === Fim de: ./src/routes/chargers.routes.js === // === Início de: ./src/routes/sessions.routes.js === // src/routes/sessions.routes.js const express = require('express'); const { param, query, body } = require('express-validator'); const verifyToken = require('../middleware/auth'); const handleValidation = require('../middleware/validate'); const sessionsService = require('../services/sessions.service'); const router = express.Router(); router.use(verifyToken); // GET /api/charger_sessions?chargerId=... router.get( '/', [query('chargerId').isUUID().withMessage('chargerId deve ser UUID válido')], handleValidation, async (req, res, next) => { try { const { chargerId } = req.query; const data = await sessionsService.listByCharger(req.user.id, chargerId); res.json({ success: true, data }); } catch (err) { next(err); } } ); // ✅ /history antes de /:id router.get( '/history/:chargerId', [ param('chargerId').isUUID().withMessage('chargerId deve ser UUID válido'), query('viewMode').isIn(['Day', 'Week', 'Month']).withMessage('viewMode inválido'), ], handleValidation, async (req, res, next) => { try { const { chargerId } = req.params; const { viewMode } = req.query; const data = await sessionsService.history(req.user.id, chargerId, viewMode); res.json({ success: true, data }); } catch (err) { next(err); } } ); router.get( '/:id', [param('id').isInt().withMessage('ID de sessão inválido')], handleValidation, async (req, res, next) => { try { const data = await sessionsService.getById(req.user.id, Number(req.params.id)); res.json({ success: true, data }); } catch (err) { next(err); } } ); router.post( '/', [body('charger_id').isUUID().withMessage('charger_id deve ser UUID válido')], handleValidation, async (req, res, next) => { try { const data = await sessionsService.create(req.user.id, req.body.charger_id); res.status(201).json({ success: true, data }); } catch (err) { next(err); } } ); router.put( '/:id', [ param('id').isInt().withMessage('ID de sessão inválido'), body('ended_at').optional().isISO8601().toDate(), body('kwh').optional().isFloat({ min: 0 }), body('cost').optional().isFloat({ min: 0 }), ], handleValidation, async (req, res, next) => { try { const id = Number(req.params.id); const { ended_at, kwh, cost } = req.body; const data = await sessionsService.update(req.user.id, id, { ended_at, kwh, cost }); res.json({ success: true, data }); } catch (err) { next(err); } } ); router.delete( '/:id', [param('id').isInt().withMessage('ID de sessão inválido')], handleValidation, async (req, res, next) => { try { const ok = await sessionsService.remove(req.user.id, Number(req.params.id)); res.json({ success: true, message: ok ? 'Sessão excluída com sucesso' : 'OK' }); } catch (err) { next(err); } } ); module.exports = router; // === Fim de: ./src/routes/sessions.routes.js === // === Início de: ./src/routes/push.routes.js === // src/routes/push.routes.js const express = require('express'); const { body } = require('express-validator'); const verifyToken = require('../middleware/auth'); const handleValidation = require('../middleware/validate'); const config = require('../config'); const pushHttpService = require('../services/pushHttp.service'); const { sendPushToUser } = require('../services/push.service'); const router = express.Router(); router.use(verifyToken); // GET /api/push/vapid-public-key router.get('/vapid-public-key', (req, res) => { if (!config.vapid.publicKey) { return res.status(503).json({ success: false, message: 'Push indisponível' }); } res.json({ success: true, data: { key: config.vapid.publicKey } }); }); // POST /api/push/subscribe router.post( '/subscribe', [ body('endpoint').isString(), body('keys.p256dh').isString(), body('keys.auth').isString(), ], handleValidation, async (req, res, next) => { try { const userId = req.user.id; const { endpoint, keys } = req.body; const ua = req.headers['user-agent'] || null; const out = await pushHttpService.subscribe(userId, endpoint, keys, ua); res.status(out.created ? 201 : 200).json({ success: true, data: out.row }); } catch (err) { next(err); } } ); // POST /api/push/unsubscribe router.post( '/unsubscribe', [body('endpoint').optional().isString()], handleValidation, async (req, res, next) => { try { const userId = req.user.id; const { endpoint } = req.body || {}; const out = await pushHttpService.unsubscribe(userId, endpoint); res.json({ success: true, message: out.message }); } catch (err) { next(err); } } ); // POST /api/push/test router.post('/test', async (req, res, next) => { try { const userId = req.user.id; await sendPushToUser(userId, { title: '📬 Teste EV Station', body: 'Push notifications estão a funcionar!', url: '/', }); res.json({ success: true, message: 'Push enviado' }); } catch (err) { next(err); } }); module.exports = router; // === Fim de: ./src/routes/push.routes.js === // === Início de: ./src/routes/users.routes.js === // src/routes/users.routes.js const express = require('express'); const rateLimit = require('express-rate-limit'); const usersService = require('../services/users.service'); const router = express.Router(); const authLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, }); router.post('/login', authLimiter, async (req, res, next) => { try { const { username, password } = req.body; const data = await usersService.login(username, password); res.json({ success: true, data }); } catch (err) { next(err); } }); router.post('/register', authLimiter, async (req, res, next) => { try { const { username, password } = req.body; const data = await usersService.register(username, password); res.status(201).json({ success: true, data }); } catch (err) { next(err); } }); module.exports = router; // === Fim de: ./src/routes/users.routes.js === // === Início de: ./src/repositories/chargers.repo.js === // src/repositories/chargers.repo.js const db = require('../db/knex'); async function listByUser(userId) { return db('chargers').where({ user_id: userId }).select('*'); } async function findByIdForUser(id, userId) { return db('chargers').where({ id, user_id: userId }).first(); } async function findByMqttTopic(mqtt_topic) { return db('chargers').where({ mqtt_topic }).first(); } async function insertCharger(data) { const [row] = await db('chargers').insert(data).returning('*'); return row; } async function updateChargerForUser(id, userId, patch) { const [row] = await db('chargers') .where({ id, user_id: userId }) .update(patch) .returning('*'); return row; } async function deleteChargerForUser(id, userId) { // devolve o charger antes para poderes usar mqtt_user/pass/topic etc const charger = await findByIdForUser(id, userId); if (!charger) return null; await db('chargers').where({ id, user_id: userId }).del(); return charger; } async function getConfig(charger_id) { return db('charger_configs').where({ charger_id }).first(); } async function insertConfig(data) { const [row] = await db('charger_configs').insert(data).returning('*'); return row; } async function updateConfig(charger_id, patch) { const [row] = await db('charger_configs') .where({ charger_id }) .update(patch) .returning('*'); return row; } async function upsertConfig(charger_id, patch) { const existing = await getConfig(charger_id); if (existing) return updateConfig(charger_id, patch); return insertConfig({ charger_id, ...patch }); } async function listSchedules(charger_id) { return db('charger_schedules').where({ charger_id }).orderBy('created_at', 'desc'); } async function insertSchedule(data) { const [row] = await db('charger_schedules').insert(data).returning('*'); return row; } module.exports = { listByUser, findByIdForUser, findByMqttTopic, insertCharger, updateChargerForUser, deleteChargerForUser, getConfig, insertConfig, updateConfig, upsertConfig, listSchedules, insertSchedule, }; // === Fim de: ./src/repositories/chargers.repo.js === // === Início de: ./src/repositories/push.repo.js === // src/repositories/push.repo.js const db = require('../db/knex'); async function findByUserAndEndpoint(user_id, endpoint) { return db('push_subscriptions').where({ user_id, endpoint }).first(); } async function findByEndpoint(endpoint) { return db('push_subscriptions').where({ endpoint }).first(); } async function listByUser(user_id) { return db('push_subscriptions') .where({ user_id }) .select('id', 'endpoint', 'p256dh', 'auth'); } async function insertSubscription(data) { const [row] = await db('push_subscriptions').insert(data).returning('*'); return row; } async function deleteByUserAndEndpoint(user_id, endpoint) { return db('push_subscriptions').where({ user_id, endpoint }).del(); } async function deleteById(id) { return db('push_subscriptions').where({ id }).del(); } module.exports = { findByUserAndEndpoint, findByEndpoint, listByUser, insertSubscription, deleteByUserAndEndpoint, deleteById, }; // === Fim de: ./src/repositories/push.repo.js === // === Início de: ./src/repositories/sessions.repo.js === // src/repositories/sessions.repo.js const db = require('../db/knex'); async function listByCharger(charger_id) { return db('charger_sessions') .where({ charger_id }) .orderBy('started_at', 'desc'); } async function findByIdForUser(id, userId) { return db('charger_sessions') .join('chargers', 'charger_sessions.charger_id', 'chargers.id') .where({ 'charger_sessions.id': id, 'chargers.user_id': userId }) .select('charger_sessions.*') .first(); } async function insertSession(data) { const [row] = await db('charger_sessions').insert(data).returning('*'); return row; } async function updateById(id, patch) { const [row] = await db('charger_sessions').where({ id }).update(patch).returning('*'); return row; } async function deleteByIdForUser(id, userId) { return db('charger_sessions') .join('chargers', 'charger_sessions.charger_id', 'chargers.id') .where({ 'charger_sessions.id': id, 'chargers.user_id': userId }) .del(); } async function historyAgg(chargerId, viewMode) { let qb = db('charger_sessions') .where({ charger_id: chargerId }) .sum('kwh as total_kwh'); switch (viewMode) { case 'Day': qb = qb .select(db.raw('DATE(started_at) AS period')) .groupBy(db.raw('DATE(started_at)')) .orderBy('period', 'desc'); break; case 'Week': qb = qb .select( db.raw('EXTRACT(ISOYEAR FROM started_at) AS y'), db.raw('EXTRACT(WEEK FROM started_at) AS w'), db.raw( "EXTRACT(ISOYEAR FROM started_at)||'-'||LPAD(EXTRACT(WEEK FROM started_at)::text,2,'0') AS period" ) ) .groupBy('y', 'w') .orderBy([{ column: 'y', order: 'desc' }, { column: 'w', order: 'desc' }]); break; case 'Month': qb = qb .select( db.raw('EXTRACT(YEAR FROM started_at) AS y'), db.raw('EXTRACT(MONTH FROM started_at) AS m'), db.raw( "EXTRACT(YEAR FROM started_at)||'-'||LPAD(EXTRACT(MONTH FROM started_at)::text,2,'0') AS period" ) ) .groupBy('y', 'm') .orderBy([{ column: 'y', order: 'desc' }, { column: 'm', order: 'desc' }]); break; } return qb; } module.exports = { listByCharger, findByIdForUser, insertSession, updateById, deleteByIdForUser, historyAgg, }; // === Fim de: ./src/repositories/sessions.repo.js === // === Início de: ./src/repositories/users.repo.js === // src/repositories/users.repo.js const db = require('../db/knex'); async function findByUsername(username) { return db('users').where({ username }).first(); } async function insertUser({ username, passwordHash }) { const [row] = await db('users') .insert({ username, password: passwordHash }) .returning('id'); // pg pode devolver {id} ou o valor direto (depende config) return row?.id ?? row; } module.exports = { findByUsername, insertUser }; // === Fim de: ./src/repositories/users.repo.js === // === Início de: ./src/mqtt/index.js === // src/mqtt/index.js const mqtt = require('mqtt'); const EventEmitter = require('events'); const db = require('../db/knex'); const config = require('../config'); const { sendPushToUser } = require('../services/push.service'); const { createPublishers } = require('./publishers'); const { createMqttContext } = require('./context'); const { handleStateEvse, handleStateScheduler, handleStateLoadbalancing, handleStateAuth, handleStateMetersConfig, } = require('./handlers/evse.handler'); const { handleStateMeter } = require('./handlers/meter.handler'); const { handleLegacyState, handleLegacyConfigResponse } = require('./handlers/legacy.handler'); const emitter = new EventEmitter(); // -------------------- // MQTT client // -------------------- const MQTT_URL = config.mqtt.url; const mqttUser = config.mqtt.user; const mqttPass = config.mqtt.pass; const client = mqtt.connect(MQTT_URL, { username: mqttUser, password: mqttPass, reconnectPeriod: 2000, }); // publishers separados const publishers = createPublishers(client); // ctx separado (helpers + caches + db helpers) const ctx = createMqttContext({ db, config, emitter, sendPushToUser }); // -------------------- // Subscribe // -------------------- client.on('connect', () => { console.log('[MQTT] Conectado ao broker:', MQTT_URL); const fixedTopics = [ '+/state/#', // state/evse, state/scheduler, state/loadbalancing, state/meter/... '+/state', // legacy '+/response/#', // legacy '+/response/config/evse', // legacy ]; const envTopics = config.mqtt.subTopics || []; const topicsToSub = [...new Set([...fixedTopics, ...envTopics])]; topicsToSub.forEach((t) => { client.subscribe(t, { qos: 0 }, (err, granted) => { if (err) { console.error('[MQTT] Falha ao subscrever', t, err.message); } else { console.log('[MQTT] Subscrito:', granted?.map((g) => g.topic).join(', ') || t); } }); }); }); // -------------------- // Messages // -------------------- client.on('message', async (topic, message) => { const parts = topic.split('/'); const mqttTopic = parts[0]; const subtopic = parts.slice(1).join('/'); const payload = ctx.safeJsonParse(message); if (!payload) { console.warn('[MQTT] JSON inválido em', topic, 'payload=', message.toString()); return; } try { // -------- NOVOS TÓPICOS DO EVSE -------- if (subtopic === 'state/evse') return await handleStateEvse(ctx, mqttTopic, payload); if (subtopic === 'state/scheduler') return await handleStateScheduler(ctx, mqttTopic, payload); if (subtopic === 'state/loadbalancing') return await handleStateLoadbalancing(ctx, mqttTopic, payload); if (subtopic === 'state/auth') return await handleStateAuth(ctx, mqttTopic, payload); if (subtopic === 'state/meters-config') return await handleStateMetersConfig(ctx, mqttTopic, payload); if (subtopic === 'state/meter/evse') return await handleStateMeter(ctx, mqttTopic, payload, 'evse'); if (subtopic === 'state/meter/grid') return await handleStateMeter(ctx, mqttTopic, payload, 'grid'); // -------- LEGACY COMPAT -------- if (subtopic === 'state') return await handleLegacyState(ctx, mqttTopic, payload); if (subtopic === 'response/config/evse') return await handleLegacyConfigResponse(ctx, mqttTopic, payload); // fallback ignorado } catch (err) { console.error('[MQTT] Erro ao processar', topic, err); } }); // -------------------- // Broker offline / checker // -------------------- client.on('offline', async () => { console.warn('[MQTT] Broker offline'); try { const chargers = await ctx.db('chargers').select('id', 'user_id', 'location'); const uniqueUsers = [...new Set(chargers.map((c) => c.user_id))]; await Promise.allSettled( uniqueUsers.map((userId) => ctx.sendPushToUser(userId, { title: '📡 Broker MQTT offline', body: 'O sistema perdeu ligação ao broker. Alguns estados podem estar desatualizados.', url: '/', }) ) ); } catch (err) { console.error('[MQTT] erro offline push:', err.message); } }); setInterval(async () => { try { const timeoutMinutes = config.chargerOfflineMinutes; const limitDate = new Date(Date.now() - timeoutMinutes * 60 * 1000); const offlineChargers = await ctx.db('chargers') .where('updated_at', '<', limitDate.toISOString()) .andWhereNot({ status: 'offline' }) .select('*'); for (const ch of offlineChargers) { await ctx.db('chargers').where({ id: ch.id }).update({ status: 'offline' }); ctx.lastDbStateByChargerId.delete(ch.id); ctx.lastMetaByChargerId.delete(ch.id); ctx.lastTotalEnergyByChargerId.delete(ch.id); ctx.sessionStartEnergyByChargerId.delete(ch.id); ctx.lastEnabled[ch.id] = false; await ctx.sendPushToUser(ch.user_id, { title: '🔌 Carregador offline', body: `${ch.location || 'Carregador'} está offline há mais de ${timeoutMinutes} min.`, url: `/charger/${ch.id}`, }); } } catch (err) { console.error('[MQTT] offline checker erro:', err.message); } }, 60 * 1000); // -------------------- // API pública (compat com o resto do projeto) // -------------------- function on(event, handler) { emitter.on(event, handler); } module.exports = { on, ...publishers, }; // === Fim de: ./src/mqtt/index.js === // === Início de: ./src/mqtt/publishers.js === // src/mqtt/publishers.js function createPublishers(client) { /** * ✅ Firmware novo: * Para settings: /cmd/evse/settings * payload: { currentLimit, temperatureLimit } */ function sendEvseSettings(chargerTopic, settings = {}) { const payload = {}; if (settings.currentLimit !== undefined) payload.currentLimit = Number(settings.currentLimit); if (settings.temperatureLimit !== undefined) payload.temperatureLimit = Number(settings.temperatureLimit); if (!Object.keys(payload).length) return; client.publish(`${chargerTopic}/cmd/evse/settings`, JSON.stringify(payload), { qos: 1 }); } // compat + mapeamento function sendConfig(chargerTopic, property, value) { const map = { maxChargingCurrent: 'currentLimit', temperatureThreshold: 'temperatureLimit', }; if (map[property]) { return sendEvseSettings(chargerTopic, { [map[property]]: value }); } // fallback legacy const payload = { [property]: value }; client.publish(`${chargerTopic}/set/config/evse`, JSON.stringify(payload), { qos: 1 }); } // legacy (mantidos) function sendEnable(chargerTopic, enable) { client.publish(`${chargerTopic}/enable`, JSON.stringify({ enable: !!enable }), { qos: 1 }); } function requestConfig(chargerTopic) { client.publish(`${chargerTopic}/request/config/evse`, null, { qos: 1 }); } // helper genérico novo function sendCmd(chargerTopic, cmdSubtopic, obj) { const t = `${chargerTopic}/cmd/${cmdSubtopic}`; const msg = obj ? JSON.stringify(obj) : ''; client.publish(t, msg, { qos: 1 }); } return { sendConfig, sendEvseSettings, sendEnable, requestConfig, sendCmd }; } module.exports = { createPublishers }; // === Fim de: ./src/mqtt/publishers.js === // === Início de: ./src/mqtt/context.js === // src/mqtt/context.js function createMqttContext({ db, config, emitter, sendPushToUser }) { // -------------------- // Helpers // -------------------- const toNum = (v) => { if (v === null || v === undefined || v === '') return 0; const n = typeof v === 'number' ? v : parseFloat(v); return Number.isFinite(n) ? n : 0; }; const round1 = (v) => Math.round(toNum(v) * 10) / 10; const round2 = (v) => Math.round(toNum(v) * 100) / 100; const wToKw2 = (w) => round2(toNum(w) / 1000); const toArr3 = (v) => { if (Array.isArray(v)) return [round1(v[0]), round1(v[1]), round1(v[2])]; if (v && typeof v === 'object') return [round1(v.l1), round1(v.l2), round1(v.l3)]; return [0, 0, 0]; }; const toArr3Kw2 = (v) => { if (Array.isArray(v)) return [wToKw2(v[0]), wToKw2(v[1]), wToKw2(v[2])]; if (v && typeof v === 'object') return [wToKw2(v.l1), wToKw2(v.l2), wToKw2(v.l3)]; return [0, 0, 0]; }; function safeJsonParse(buf) { try { return JSON.parse(buf.toString()); } catch { return null; } } function shallowEqual(a, b) { if (a === b) return true; if (!a || !b) return false; const ak = Object.keys(a); const bk = Object.keys(b); if (ak.length !== bk.length) return false; for (const k of ak) { const av = a[k]; const bv = b[k]; if (Array.isArray(av) || Array.isArray(bv)) { if (!Array.isArray(av) || !Array.isArray(bv)) return false; if (av.length !== bv.length) return false; for (let i = 0; i < av.length; i++) { if (av[i] !== bv[i]) return false; } } else if (av !== bv) { return false; } } return true; } function inferStateCode(raw) { const s = String(raw || '').trim(); if (!s) return ''; return s.split(/\s+/)[0] || ''; } function getStatusFromStateCode(code) { const map = { A: '🔌 Not Conn.', A1: '🔌 Not Conn.', B1: '🟡 Unauth.', B2: '🟢 Ready', C1: '⚡ Wait', C2: '⚡ Charging', D1: '💨 Vent (req)', D2: '💨 Vent', E: '❌ CP Error', F: '⚠️ Fault', }; return map[code] || '❓ Unknown'; } function stripUndef(obj) { return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); } // -------------------- // Caches // -------------------- const lastDbStateByChargerId = new Map(); // chargerId -> { ...dbFields } const lastMetaByChargerId = new Map(); // chargerId -> { status, stateCode, rawStatus } const lastTotalEnergyByChargerId = new Map(); // chargerId -> totalEnergy (kWh) const sessionStartEnergyByChargerId = new Map(); // chargerId -> totalEnergy at session start const lastEnabled = {}; // chargerId -> boolean const chargerCache = new Map(); // mqttTopic -> { charger, fetchedAt } const CHARGER_CACHE_TTL_MS = config.chargerCacheTtlMs; // -------------------- // DB helpers // -------------------- async function getChargerByMqttTopic(mqttTopic) { const cached = chargerCache.get(mqttTopic); const now = Date.now(); if (cached && now - cached.fetchedAt < CHARGER_CACHE_TTL_MS) { return cached.charger; } const charger = await db('chargers').where({ mqtt_topic: mqttTopic }).first(); if (charger) chargerCache.set(mqttTopic, { charger, fetchedAt: now }); return charger; } async function updateChargerDbIfChanged(chargerId, partialUpdate) { const prev = lastDbStateByChargerId.get(chargerId) || null; const cleanPartial = stripUndef(partialUpdate); const next = { ...(prev || {}), ...cleanPartial }; const changed = !prev || !shallowEqual(prev, next); if (!changed) return false; await db('chargers').where({ id: chargerId }).update(cleanPartial); lastDbStateByChargerId.set(chargerId, next); return true; } // ctx é o “contrato” que os handlers usam return { db, config, emitter, sendPushToUser, // helpers toNum, round1, round2, wToKw2, toArr3, toArr3Kw2, safeJsonParse, shallowEqual, inferStateCode, getStatusFromStateCode, stripUndef, // caches lastDbStateByChargerId, lastMetaByChargerId, lastTotalEnergyByChargerId, sessionStartEnergyByChargerId, lastEnabled, // db helpers getChargerByMqttTopic, updateChargerDbIfChanged, }; } module.exports = { createMqttContext }; // === Fim de: ./src/mqtt/context.js === // === Início de: ./src/mqtt/handlers/legacy.handler.js === // src/mqtt/handlers/legacy.handler.js async function handleLegacyState(ctx, mqttTopic, payload) { const charger = await ctx.getChargerByMqttTopic(mqttTopic); if (!charger) { console.warn(`[MQTT] Charger não encontrado para topic: ${mqttTopic}`); return; } const chargerId = charger.id; const now = new Date(); const stateCode = ctx.inferStateCode(payload?.state); if (!stateCode) return; const status = ctx.getStatusFromStateCode(stateCode); ctx.lastMetaByChargerId.set(chargerId, { status, stateCode, rawStatus: payload?.state }); // legacy arrays (assumimos W -> converter p/ kW) const powerArrKw = ctx.toArr3Kw2(payload?.power); const voltageArr = ctx.toArr3(payload?.voltage); const currentArr = ctx.toArr3(payload?.current); const consumption = ctx.round2(payload?.consumption); const chargingTime = ctx.round1(payload?.chargingTime ?? payload?.sessionTime); const dbUpdate = { status, charging_current: currentArr[0], consumption, charging_time: chargingTime, power_l1: powerArrKw[0], power_l2: powerArrKw[1], power_l3: powerArrKw[2], voltage_l1: voltageArr[0], voltage_l2: voltageArr[1], voltage_l3: voltageArr[2], current_l1: currentArr[0], current_l2: currentArr[1], current_l3: currentArr[2], updated_at: now.toISOString(), }; await ctx.updateChargerDbIfChanged(chargerId, dbUpdate); ctx.emitter.emit('charging-status', { charger_id: chargerId, mqtt_topic: mqttTopic, status, stateCode, consumption, chargingTime, power: powerArrKw, voltage: voltageArr, current: currentArr, raw: payload, }); } async function handleLegacyConfigResponse(ctx, mqttTopic, payload) { const charger = await ctx.getChargerByMqttTopic(mqttTopic); if (!charger) return; const configData = { charger_id: charger.id, max_charging_current: payload?.maxChargingCurrent || 32, require_auth: !!payload?.requireAuth, rcm_enabled: !!payload?.rcm, temperature_limit: payload?.temperatureThreshold || 60, config_received_at: new Date().toISOString(), }; const existingConfig = await ctx.db('charger_configs').where({ charger_id: charger.id }).first(); if (existingConfig) { await ctx.db('charger_configs').where({ charger_id: charger.id }).update(configData); } else { await ctx.db('charger_configs').insert(configData); } ctx.emitter.emit('charging-config', { ...configData, mqtt_topic: mqttTopic, raw: payload, }); } module.exports = { handleLegacyState, handleLegacyConfigResponse }; // === Fim de: ./src/mqtt/handlers/legacy.handler.js === // === Início de: ./src/mqtt/handlers/evse.handler.js === // src/mqtt/handlers/evse.handler.js async function handleStateEvse(ctx, mqttTopic, payload) { const charger = await ctx.getChargerByMqttTopic(mqttTopic); if (!charger) { console.warn(`[MQTT] Charger não encontrado para topic: ${mqttTopic}`); return; } const chargerId = charger.id; const now = new Date(); const rawStatus = payload?.status || payload?.state || ''; const stateCode = ctx.inferStateCode(rawStatus); if (!stateCode) { console.warn(`[MQTT] state/evse sem stateCode válido (charger ${chargerId})`); return; } const status = ctx.getStatusFromStateCode(stateCode); ctx.lastMetaByChargerId.set(chargerId, { status, stateCode, rawStatus }); const isCharging = stateCode === 'C2'; // do firmware: chargers[0].current / power (W) const ch0 = Array.isArray(payload?.chargers) ? payload.chargers[0] : null; // ⚠️ current no state/evse costuma ser limite (mesmo sem estar a carregar) const currentA = isCharging ? ctx.round1(ch0?.current) : 0; // ⚠️ power no state/evse vem em W -> guardar kW (e só quando está a carregar) const powerKw = isCharging ? ctx.wToKw2(ch0?.power) : 0; const dbUpdate = { status, charging_current: currentA, power_l1: powerKw, power_l2: 0, power_l3: 0, current_l1: currentA, current_l2: 0, current_l3: 0, updated_at: now.toISOString(), }; await ctx.updateChargerDbIfChanged(chargerId, dbUpdate); // Sessões start/stop baseado em C2 const previouslyEnabled = ctx.lastEnabled[chargerId] || false; const currentlyEnabled = isCharging; if (!previouslyEnabled && currentlyEnabled) { const startEnergy = ctx.lastTotalEnergyByChargerId.get(chargerId) ?? 0; ctx.sessionStartEnergyByChargerId.set(chargerId, startEnergy); const activeSession = await ctx.db('charger_sessions') .where({ charger_id: chargerId }) .whereNull('ended_at') .first(); if (!activeSession) { await ctx.db('charger_sessions').insert({ charger_id: chargerId, started_at: now, kwh: 0, }); console.log(`[DB] Sessão iniciada para charger ID ${chargerId}`); } } if (previouslyEnabled && !currentlyEnabled) { const session = await ctx.db('charger_sessions') .where({ charger_id: chargerId }) .whereNull('ended_at') .first(); if (session) { const endEnergy = ctx.lastTotalEnergyByChargerId.get(chargerId) ?? 0; const startEnergy = ctx.sessionStartEnergyByChargerId.get(chargerId) ?? 0; const delta = Math.max(0, ctx.round2(endEnergy - startEnergy)); await ctx.db('charger_sessions') .where({ id: session.id }) .update({ ended_at: now, kwh: delta }); console.log(`[DB] Sessão finalizada para charger ID ${chargerId} (kWh=${delta})`); } ctx.sessionStartEnergyByChargerId.delete(chargerId); await ctx.sendPushToUser(charger.user_id, { title: '✅ Carregamento concluído', body: `${charger.location || 'Carregador'} terminou o carregamento.`, url: `/history`, }); } ctx.lastEnabled[chargerId] = currentlyEnabled; ctx.emitter.emit('charging-status', { charger_id: chargerId, mqtt_topic: mqttTopic, status, stateCode, consumption: ctx.lastTotalEnergyByChargerId.get(chargerId) ?? 0, chargingTime: ctx.toNum(payload?.chargingTime) || 0, power: [powerKw, 0, 0], voltage: [0, 0, 0], current: [currentA, 0, 0], raw: payload, }); if (status === '⚠️ Fault' || status === '❌ CP Error') { await ctx.sendPushToUser(charger.user_id, { title: '⚠️ Erro no carregador', body: `${charger.location || 'Carregador'} entrou em falha.`, url: `/charger/${charger.id}`, }); } } async function handleStateScheduler(ctx, mqttTopic, payload) { const charger = await ctx.getChargerByMqttTopic(mqttTopic); if (!charger) return; ctx.emitter.emit('scheduler-state', { charger_id: charger.id, mqtt_topic: mqttTopic, ...payload, updated_at: new Date().toISOString(), }); } async function handleStateLoadbalancing(ctx, mqttTopic, payload) { const charger = await ctx.getChargerByMqttTopic(mqttTopic); if (!charger) return; ctx.emitter.emit('loadbalancing-state', { charger_id: charger.id, mqtt_topic: mqttTopic, ...payload, updated_at: new Date().toISOString(), }); } async function handleStateAuth(ctx, mqttTopic, payload) { const charger = await ctx.getChargerByMqttTopic(mqttTopic); if (!charger) return; ctx.emitter.emit('auth-state', { charger_id: charger.id, mqtt_topic: mqttTopic, ...payload, updated_at: new Date().toISOString(), }); } async function handleStateMetersConfig(ctx, mqttTopic, payload) { const charger = await ctx.getChargerByMqttTopic(mqttTopic); if (!charger) return; ctx.emitter.emit('meters-config', { charger_id: charger.id, mqtt_topic: mqttTopic, ...payload, updated_at: new Date().toISOString(), }); } module.exports = { handleStateEvse, handleStateScheduler, handleStateLoadbalancing, handleStateAuth, handleStateMetersConfig, }; // === Fim de: ./src/mqtt/handlers/evse.handler.js === // === Início de: ./src/mqtt/handlers/meter.handler.js === // src/mqtt/handlers/meter.handler.js async function handleStateMeter(ctx, mqttTopic, payload, meterKind /* 'evse'|'grid' */) { const charger = await ctx.getChargerByMqttTopic(mqttTopic); if (!charger) { console.warn(`[MQTT] Charger não encontrado para topic: ${mqttTopic}`); return; } const chargerId = charger.id; const now = new Date(); const vrms = ctx.toArr3(payload?.vrms); const irms = ctx.toArr3(payload?.irms); // ✅ watt vem em W -> guardar kW no DB const wattKw = ctx.toArr3Kw2(payload?.watt); const totalEnergy = ctx.round2(payload?.totalEnergy); // acumulado (kWh) const source = String(payload?.source || meterKind || '').toUpperCase(); if (Number.isFinite(totalEnergy) && totalEnergy >= 0) { ctx.lastTotalEnergyByChargerId.set(chargerId, totalEnergy); } // só gravamos métricas no DB para EVSE (normalmente o que interessa) if (meterKind === 'evse' || source === 'EVSE') { const dbUpdate = { consumption: totalEnergy, charging_current: irms[0], power_l1: wattKw[0], power_l2: wattKw[1], power_l3: wattKw[2], voltage_l1: vrms[0], voltage_l2: vrms[1], voltage_l3: vrms[2], current_l1: irms[0], current_l2: irms[1], current_l3: irms[2], updated_at: now.toISOString(), }; await ctx.updateChargerDbIfChanged(chargerId, dbUpdate); // atualiza sessão ativa com delta (sem fechar) const currentlyEnabled = ctx.lastEnabled[chargerId] || false; if (currentlyEnabled) { const session = await ctx.db('charger_sessions') .where({ charger_id: chargerId }) .whereNull('ended_at') .first(); if (session) { const startEnergy = ctx.sessionStartEnergyByChargerId.get(chargerId) ?? 0; const delta = Math.max( 0, ctx.round2((ctx.lastTotalEnergyByChargerId.get(chargerId) ?? 0) - startEnergy) ); await ctx.db('charger_sessions').where({ id: session.id }).update({ kwh: delta }); } } const meta = ctx.lastMetaByChargerId.get(chargerId) || { status: '—', stateCode: undefined }; ctx.emitter.emit('charging-status', { charger_id: chargerId, mqtt_topic: mqttTopic, status: meta.status, stateCode: meta.stateCode, consumption: totalEnergy, chargingTime: 0, power: wattKw, voltage: vrms, current: irms, raw: payload, }); } ctx.emitter.emit('meter-live', { charger_id: chargerId, mqtt_topic: mqttTopic, meter: meterKind, vrms, irms, watt: wattKw, totalEnergy, raw: payload, updated_at: now.toISOString(), }); } module.exports = { handleStateMeter }; // === Fim de: ./src/mqtt/handlers/meter.handler.js === // === Início de: ./src/config/index.js === // src/config/index.js require('dotenv').config(); function must(name) { const v = process.env[name]; if (!v) throw new Error(`${name} não definido no .env`); return v; } const config = { env: process.env.NODE_ENV || 'development', port: Number(process.env.PORT || 4000), jwtSecret: must('JWT_SECRET'), corsOrigins: (process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',').map((s) => s.trim()) : ['http://localhost:5173'] ).filter(Boolean), mqtt: { url: process.env.MQTT_URL || 'mqtt://localhost:1883', user: process.env.MQTT_USER || 'admin', pass: process.env.MQTT_PASS || '123QWEasd', subTopics: (process.env.MQTT_SUB_TOPICS || '') .split(',') .map((s) => s.trim()) .filter(Boolean), }, chargerOfflineMinutes: Number(process.env.CHARGER_OFFLINE_MINUTES || 5), chargerCacheTtlMs: Number(process.env.CHARGER_CACHE_TTL_MS || 30000), vapid: { publicKey: process.env.VAPID_PUBLIC_KEY || '', privateKey: process.env.VAPID_PRIVATE_KEY || '', subject: process.env.VAPID_SUBJECT || 'mailto:admin@evstation.local', }, }; module.exports = config; // === Fim de: ./src/config/index.js === // === Início de: ./knexfile.js === // knexfile.js require('dotenv').config(); function must(name) { const v = process.env[name]; if (!v) throw new Error(`${name} não definido no .env`); return v; } const shared = { client: 'pg', migrations: { directory: './src/db/migrations', }, }; function buildConnectionFromEnv() { // Se houver DATABASE_URL, usa-o. // Se PGSSL=true, aplica ssl no formato esperado pelo driver pg (dentro de connection). const ssl = process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined; if (process.env.DATABASE_URL) { // knex aceita string, mas o ssl precisa estar no objeto: return ssl ? { connectionString: process.env.DATABASE_URL, ssl } : process.env.DATABASE_URL; } // fallback para vars soltas return { host: process.env.PGHOST || '127.0.0.1', port: Number(process.env.PGPORT || 5432), user: process.env.PGUSER || 'postgres', password: process.env.PGPASSWORD || 'postgres', database: process.env.PGDATABASE || 'evse', ...(ssl ? { ssl } : {}), }; } module.exports = { development: { ...shared, connection: buildConnectionFromEnv(), }, production: { ...shared, // Em produção normalmente queres obrigar DATABASE_URL (se for o teu caso): // connection: must('DATABASE_URL'), // Mas mantendo compatível com vars soltas: connection: buildConnectionFromEnv(), pool: { min: 2, max: 10 }, }, }; // === Fim de: ./knexfile.js === // === Início de: ./package.json === { "name": "evse-backend", "version": "1.0.0", "main": "src/server.js", "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", "migrate": "knex migrate:latest", "rollback": "knex migrate:rollback" }, "keywords": [], "author": "", "license": "ISC", "description": "", "dependencies": { "axios": "^1.13.2", "bcryptjs": "^3.0.2", "dotenv": "^16.5.0", "express": "^5.1.0", "express-rate-limit": "^8.2.1", "express-validator": "^7.2.1", "jsonwebtoken": "^9.0.2", "knex": "^3.1.0", "mqtt": "^5.13.1", "pg": "^8.16.0", "socket.io": "^4.8.1", "web-push": "^3.6.7" }, "devDependencies": { "nodemon": "^3.1.10" } } // === Fim de: ./package.json ===