2694 lines
76 KiB
C
Executable File
2694 lines
76 KiB
C
Executable File
|
||
|
||
// === 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 <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 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: <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 ===
|