Files
evse-backend/projeto_parte1.c
2026-01-10 18:39:55 +00:00

2694 lines
76 KiB
C
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// === 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 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 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 <token>"' });
}
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 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: <id>/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 ===