refact
This commit is contained in:
76
src/app.js
Executable file
76
src/app.js
Executable file
@@ -0,0 +1,76 @@
|
||||
// src/app.js
|
||||
const express = require('express');
|
||||
|
||||
const config = require('./config');
|
||||
const db = require('./db/knex');
|
||||
const mqttClient = require('./mqtt');
|
||||
|
||||
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();
|
||||
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// ✅ atrás de reverse proxy em produção (nginx/traefik/etc.)
|
||||
if (config.env === 'production') {
|
||||
app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
// liveness
|
||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
// readiness: DB + MQTT
|
||||
app.get('/ready', async (req, res) => {
|
||||
try {
|
||||
await db.raw('select 1 as ok');
|
||||
const mqttOk = typeof mqttClient?.isConnected === 'function' ? mqttClient.isConnected() : false;
|
||||
|
||||
if (!mqttOk) {
|
||||
return res.status(503).json({ ok: false, db: true, mqtt: false });
|
||||
}
|
||||
|
||||
return res.json({ ok: true, db: true, mqtt: true });
|
||||
} catch (err) {
|
||||
return res.status(503).json({ ok: false, db: false, mqtt: false });
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
65
src/config/index.js
Executable file
65
src/config/index.js
Executable file
@@ -0,0 +1,65 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
function mustInProd(name, fallback = '') {
|
||||
const v = process.env[name];
|
||||
if ((process.env.NODE_ENV || 'development') === 'production') {
|
||||
if (!v) throw new Error(`${name} não definido (obrigatório em production)`);
|
||||
return v;
|
||||
}
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
|
||||
const config = {
|
||||
env,
|
||||
|
||||
port: Number(process.env.PORT || 4000),
|
||||
|
||||
// ✅ sempre obrigatório
|
||||
jwtSecret: must('JWT_SECRET'),
|
||||
|
||||
// ✅ em produção deve estar explícito e restrito
|
||||
corsOrigins: (mustInProd('CORS_ORIGIN', 'http://localhost:5173'))
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
|
||||
mqtt: {
|
||||
// ✅ em produção não usar defaults
|
||||
url: mustInProd('MQTT_URL', 'mqtt://localhost:1883'),
|
||||
user: mustInProd('MQTT_USER', 'admin'),
|
||||
pass: mustInProd('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',
|
||||
},
|
||||
|
||||
// ✅ para remover o hardcode de localhost:7000
|
||||
mosquittoMgmt: {
|
||||
baseUrl: process.env.MOSQUITTO_MGMT_URL || '', // ex: http://mosquitto-mgmt:7000
|
||||
timeoutMs: Number(process.env.MOSQUITTO_MGMT_TIMEOUT_MS || 5000),
|
||||
// se quiseres no futuro: user/pass/token
|
||||
// user: process.env.MOSQUITTO_MGMT_USER || '',
|
||||
// pass: process.env.MOSQUITTO_MGMT_PASS || '',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
10
src/db/knex.js
Executable file
10
src/db/knex.js
Executable file
@@ -0,0 +1,10 @@
|
||||
// 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;
|
||||
9
src/db/migrations/20250618_enable_pgcrypto.js
Normal file
9
src/db/migrations/20250618_enable_pgcrypto.js
Normal file
@@ -0,0 +1,9 @@
|
||||
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');
|
||||
};
|
||||
|
||||
116
src/db/migrations/20250619_create_tables.js
Executable file
116
src/db/migrations/20250619_create_tables.js
Executable file
@@ -0,0 +1,116 @@
|
||||
// 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
|
||||
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');
|
||||
|
||||
// ✅ FIX: firmware manda amps com decimais (ex: 14.2)
|
||||
table.decimal('charging_current', 8, 2).notNullable().defaultTo(32);
|
||||
|
||||
// updated_at: usado pelo offline checker
|
||||
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());
|
||||
|
||||
// Total charging time:
|
||||
// ✅ Recomendo guardar em segundos (int).
|
||||
// Se o teu firmware mandar decimal (minutos/horas), troca para decimal(10,1).
|
||||
table.integer('charging_time').notNullable().defaultTo(0);
|
||||
// table.decimal('charging_time', 10, 1).notNullable().defaultTo(0);
|
||||
|
||||
// Consumption (kWh)
|
||||
table.decimal('consumption', 10, 3).notNullable().defaultTo(0);
|
||||
// ↑ aumentei precisão (kWh acumulado pode crescer e 8,2 é curto em alguns cenários)
|
||||
|
||||
// Power (kW)
|
||||
table.decimal('power_l1', 10, 3).notNullable().defaultTo(0);
|
||||
table.decimal('power_l2', 10, 3).notNullable().defaultTo(0);
|
||||
table.decimal('power_l3', 10, 3).notNullable().defaultTo(0);
|
||||
|
||||
// Voltage (V)
|
||||
table.decimal('voltage_l1', 10, 3).notNullable().defaultTo(0);
|
||||
table.decimal('voltage_l2', 10, 3).notNullable().defaultTo(0);
|
||||
table.decimal('voltage_l3', 10, 3).notNullable().defaultTo(0);
|
||||
|
||||
// Current (A)
|
||||
table.decimal('current_l1', 10, 3).notNullable().defaultTo(0);
|
||||
table.decimal('current_l2', 10, 3).notNullable().defaultTo(0);
|
||||
table.decimal('current_l3', 10, 3).notNullable().defaultTo(0);
|
||||
|
||||
// índices úteis (produção)
|
||||
table.index(['user_id']);
|
||||
table.index(['updated_at']);
|
||||
table.index(['status']);
|
||||
});
|
||||
|
||||
// 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', 10, 3).notNullable().defaultTo(0);
|
||||
table.decimal('cost', 12, 2);
|
||||
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
// índices úteis
|
||||
table.index(['charger_id']);
|
||||
table.index(['started_at']);
|
||||
table.index(['ended_at']);
|
||||
});
|
||||
};
|
||||
|
||||
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');
|
||||
};
|
||||
2
src/db/migrations/20251123084023_create_charger_schedules.js
Executable file
2
src/db/migrations/20251123084023_create_charger_schedules.js
Executable file
@@ -0,0 +1,2 @@
|
||||
// shim para compatibilidade com o nome antigo registado no knex_migrations
|
||||
module.exports = require('./20251123_create_charger_schedules');
|
||||
29
src/db/migrations/20251123_create_charger_schedules.js
Executable file
29
src/db/migrations/20251123_create_charger_schedules.js
Executable file
@@ -0,0 +1,29 @@
|
||||
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');
|
||||
};
|
||||
25
src/db/migrations/20251123_create_push_subscriptions.js
Executable file
25
src/db/migrations/20251123_create_push_subscriptions.js
Executable file
@@ -0,0 +1,25 @@
|
||||
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');
|
||||
};
|
||||
|
||||
38
src/db/migrations/20260110_alter_chargers_numeric_fields.js
Normal file
38
src/db/migrations/20260110_alter_chargers_numeric_fields.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/db/migrations/20260110_alter_chargers_numeric_fields.js
|
||||
|
||||
exports.up = async function (knex) {
|
||||
// charging_current: estava INTEGER, mas o firmware manda amps com decimal (ex: 14.2)
|
||||
await knex.raw(`
|
||||
ALTER TABLE chargers
|
||||
ALTER COLUMN charging_current
|
||||
TYPE numeric(8,2)
|
||||
USING charging_current::numeric
|
||||
`);
|
||||
|
||||
// charging_time: também estava INTEGER; em alguns firmwares pode vir com decimal (ex: 12.5)
|
||||
// Se tens a certeza que é sempre inteiro, podes remover este bloco.
|
||||
await knex.raw(`
|
||||
ALTER TABLE chargers
|
||||
ALTER COLUMN charging_time
|
||||
TYPE numeric(10,1)
|
||||
USING charging_time::numeric
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
// volta para INTEGER arredondando
|
||||
await knex.raw(`
|
||||
ALTER TABLE chargers
|
||||
ALTER COLUMN charging_current
|
||||
TYPE integer
|
||||
USING ROUND(charging_current)::integer
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
ALTER TABLE chargers
|
||||
ALTER COLUMN charging_time
|
||||
TYPE integer
|
||||
USING ROUND(charging_time)::integer
|
||||
`);
|
||||
};
|
||||
|
||||
34
src/domain/normalize/chargingConfig.js
Executable file
34
src/domain/normalize/chargingConfig.js
Executable file
@@ -0,0 +1,34 @@
|
||||
// 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 };
|
||||
73
src/domain/normalize/chargingStatus.js
Executable file
73
src/domain/normalize/chargingStatus.js
Executable file
@@ -0,0 +1,73 @@
|
||||
// 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 };
|
||||
36
src/middleware/auth.js
Executable file
36
src/middleware/auth.js
Executable file
@@ -0,0 +1,36 @@
|
||||
// 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;
|
||||
20
src/middleware/errorHandler.js
Executable file
20
src/middleware/errorHandler.js
Executable file
@@ -0,0 +1,20 @@
|
||||
// src/middleware/errorHandler.js
|
||||
const config = require('../config');
|
||||
|
||||
function errorHandler(err, req, res, next) {
|
||||
console.error('[errorHandler]', err);
|
||||
|
||||
if (res.headersSent) return next(err);
|
||||
|
||||
const status = err.statusCode || err.status || 500;
|
||||
|
||||
// ✅ em produção, 500+ devolve mensagem genérica
|
||||
const message =
|
||||
status >= 500 && config.env === 'production'
|
||||
? 'Erro interno do servidor'
|
||||
: err.message || 'Erro interno do servidor';
|
||||
|
||||
res.status(status).json({ success: false, message });
|
||||
}
|
||||
|
||||
module.exports = errorHandler;
|
||||
12
src/middleware/validate.js
Executable file
12
src/middleware/validate.js
Executable file
@@ -0,0 +1,12 @@
|
||||
// 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;
|
||||
174
src/mqtt/context.js
Normal file
174
src/mqtt/context.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// 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 roundN = (v, n) => {
|
||||
const num = toNum(v);
|
||||
const m = Math.pow(10, n);
|
||||
return Math.round(num * m) / m;
|
||||
};
|
||||
|
||||
// ✅ mantém round1 por compatibilidade (legacy usa)
|
||||
const round1 = (v) => roundN(v, 1);
|
||||
|
||||
// ✅ 2 casas (para DB/telemetria)
|
||||
const round2 = (v) => roundN(v, 2);
|
||||
|
||||
// W -> kW com 2 casas
|
||||
const wToKw2 = (w) => round2(toNum(w) / 1000);
|
||||
|
||||
// ✅ arrays com 2 casas (evita lixo e mantém consistência)
|
||||
const toArr3 = (v) => {
|
||||
if (Array.isArray(v)) return [round2(v[0]), round2(v[1]), round2(v[2])];
|
||||
if (v && typeof v === 'object') return [round2(v.l1), round2(v.l2), round2(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;
|
||||
}
|
||||
|
||||
return {
|
||||
db,
|
||||
config,
|
||||
emitter,
|
||||
sendPushToUser,
|
||||
|
||||
// helpers
|
||||
toNum,
|
||||
round1, // ✅ não remover (legacy)
|
||||
round2,
|
||||
wToKw2,
|
||||
toArr3,
|
||||
toArr3Kw2,
|
||||
safeJsonParse,
|
||||
shallowEqual,
|
||||
inferStateCode,
|
||||
getStatusFromStateCode,
|
||||
stripUndef,
|
||||
|
||||
// caches
|
||||
lastDbStateByChargerId,
|
||||
lastMetaByChargerId,
|
||||
lastTotalEnergyByChargerId,
|
||||
sessionStartEnergyByChargerId,
|
||||
lastEnabled,
|
||||
|
||||
// db helpers
|
||||
getChargerByMqttTopic,
|
||||
updateChargerDbIfChanged,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createMqttContext };
|
||||
182
src/mqtt/handlers/evse.handler.js
Executable file
182
src/mqtt/handlers/evse.handler.js
Executable file
@@ -0,0 +1,182 @@
|
||||
// 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 guardado com 2 casas (e só quando está a carregar)
|
||||
const currentA = isCharging ? ctx.round2(ch0?.current) : 0;
|
||||
|
||||
// ✅ power vem em W -> guardar kW com 2 casas (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;
|
||||
|
||||
// ✅ também normalizamos o que vai para o socket (opcional mas consistente)
|
||||
const consumption = ctx.round2(ctx.lastTotalEnergyByChargerId.get(chargerId) ?? 0);
|
||||
const chargingTime = ctx.round2(ctx.toNum(payload?.chargingTime) || 0);
|
||||
|
||||
ctx.emitter.emit('charging-status', {
|
||||
charger_id: chargerId,
|
||||
mqtt_topic: mqttTopic,
|
||||
status,
|
||||
stateCode,
|
||||
consumption,
|
||||
chargingTime,
|
||||
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,
|
||||
};
|
||||
95
src/mqtt/handlers/legacy.handler.js
Executable file
95
src/mqtt/handlers/legacy.handler.js
Executable file
@@ -0,0 +1,95 @@
|
||||
// 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);
|
||||
|
||||
// ✅ 2 casas
|
||||
const consumption = ctx.round2(payload?.consumption);
|
||||
|
||||
// ⚠️ se o teu charging_time no DB ainda for INTEGER, mete Math.round aqui.
|
||||
// Como disseste que ajustaste a DB para decimais, mantemos 2 casas:
|
||||
const chargingTime = ctx.round2(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 };
|
||||
94
src/mqtt/handlers/meter.handler.js
Executable file
94
src/mqtt/handlers/meter.handler.js
Executable file
@@ -0,0 +1,94 @@
|
||||
// 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); // 2 casas
|
||||
const irms = ctx.toArr3(payload?.irms); // 2 casas
|
||||
|
||||
// watt em W -> kW com 2 casas
|
||||
const wattKw = ctx.toArr3Kw2(payload?.watt);
|
||||
|
||||
const totalEnergy = ctx.round2(payload?.totalEnergy); // kWh acumulado (2 casas)
|
||||
const source = String(payload?.source || meterKind || '').toUpperCase();
|
||||
|
||||
if (Number.isFinite(totalEnergy) && totalEnergy >= 0) {
|
||||
ctx.lastTotalEnergyByChargerId.set(chargerId, totalEnergy);
|
||||
}
|
||||
|
||||
if (meterKind === 'evse' || source === 'EVSE') {
|
||||
const dbUpdate = {
|
||||
consumption: totalEnergy,
|
||||
charging_current: ctx.round2(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 };
|
||||
204
src/mqtt/index.js
Executable file
204
src/mqtt/index.js
Executable file
@@ -0,0 +1,204 @@
|
||||
// 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;
|
||||
|
||||
let connected = false;
|
||||
|
||||
const client = mqtt.connect(MQTT_URL, {
|
||||
username: mqttUser,
|
||||
password: mqttPass,
|
||||
reconnectPeriod: 2000,
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
connected = true;
|
||||
});
|
||||
|
||||
client.on('offline', () => {
|
||||
connected = false;
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
connected = false;
|
||||
});
|
||||
|
||||
// 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',
|
||||
'+/response/#',
|
||||
'+/response/config/evse',
|
||||
];
|
||||
|
||||
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 {
|
||||
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');
|
||||
|
||||
if (subtopic === 'state') return await handleLegacyState(ctx, mqttTopic, payload);
|
||||
if (subtopic === 'response/config/evse')
|
||||
return await handleLegacyConfigResponse(ctx, mqttTopic, payload);
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
const offlineTimer = 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
|
||||
// --------------------
|
||||
function on(event, handler) {
|
||||
emitter.on(event, handler);
|
||||
}
|
||||
|
||||
function isConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
try {
|
||||
clearInterval(offlineTimer);
|
||||
} catch { }
|
||||
|
||||
await new Promise((resolve) => {
|
||||
try {
|
||||
client.end(true, {}, resolve);
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
on,
|
||||
isConnected,
|
||||
shutdown,
|
||||
...publishers,
|
||||
};
|
||||
55
src/mqtt/publishers.js
Executable file
55
src/mqtt/publishers.js
Executable file
@@ -0,0 +1,55 @@
|
||||
// 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 };
|
||||
|
||||
83
src/repositories/chargers.repo.js
Executable file
83
src/repositories/chargers.repo.js
Executable file
@@ -0,0 +1,83 @@
|
||||
// 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,
|
||||
};
|
||||
38
src/repositories/push.repo.js
Executable file
38
src/repositories/push.repo.js
Executable file
@@ -0,0 +1,38 @@
|
||||
// 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,
|
||||
};
|
||||
85
src/repositories/sessions.repo.js
Executable file
85
src/repositories/sessions.repo.js
Executable file
@@ -0,0 +1,85 @@
|
||||
// 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,
|
||||
};
|
||||
17
src/repositories/users.repo.js
Normal file
17
src/repositories/users.repo.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// 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 };
|
||||
149
src/routes/chargers.routes.js
Executable file
149
src/routes/chargers.routes.js
Executable file
@@ -0,0 +1,149 @@
|
||||
// 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;
|
||||
78
src/routes/push.routes.js
Executable file
78
src/routes/push.routes.js
Executable file
@@ -0,0 +1,78 @@
|
||||
// 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;
|
||||
113
src/routes/sessions.routes.js
Executable file
113
src/routes/sessions.routes.js
Executable file
@@ -0,0 +1,113 @@
|
||||
// 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;
|
||||
35
src/routes/users.routes.js
Executable file
35
src/routes/users.routes.js
Executable file
@@ -0,0 +1,35 @@
|
||||
// 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;
|
||||
192
src/server.js
Executable file
192
src/server.js
Executable file
@@ -0,0 +1,192 @@
|
||||
// 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, isConnected, shutdown, ... }
|
||||
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}`);
|
||||
});
|
||||
|
||||
// ---------------------------
|
||||
// Graceful shutdown
|
||||
// ---------------------------
|
||||
let shuttingDown = false;
|
||||
|
||||
async function shutdown(signal) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
console.log(`[shutdown] recebido ${signal}. A encerrar...`);
|
||||
|
||||
// para aceitar novos requests/sockets
|
||||
await new Promise((resolve) => {
|
||||
try {
|
||||
server.close(resolve);
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// fecha sockets
|
||||
await new Promise((resolve) => {
|
||||
try {
|
||||
io.close(() => resolve());
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// fecha mqtt
|
||||
try {
|
||||
if (typeof mqttClient?.shutdown === 'function') {
|
||||
await mqttClient.shutdown();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[shutdown] mqtt shutdown error:', e?.message || e);
|
||||
}
|
||||
|
||||
// fecha db
|
||||
try {
|
||||
await db.destroy();
|
||||
} catch (e) {
|
||||
console.error('[shutdown] db destroy error:', e?.message || e);
|
||||
}
|
||||
|
||||
console.log('[shutdown] concluído.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
process.on('unhandledRejection', (err) => {
|
||||
console.error('[unhandledRejection]', err);
|
||||
shutdown('unhandledRejection');
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[uncaughtException]', err);
|
||||
shutdown('uncaughtException');
|
||||
});
|
||||
338
src/services/chargers.service.js
Executable file
338
src/services/chargers.service.js
Executable file
@@ -0,0 +1,338 @@
|
||||
// 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');
|
||||
const config = require('../config');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function mosquittoUrl(path) {
|
||||
const base = config.mosquittoMgmt.baseUrl;
|
||||
if (!base) return '';
|
||||
return `${base.replace(/\/+$/, '')}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
|
||||
async function mosquittoCreateClient(charger) {
|
||||
const url = mosquittoUrl('/client/create');
|
||||
if (!url) {
|
||||
console.warn('[MosquittoMgmt] MOSQUITTO_MGMT_URL não definido. Skip create.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
url,
|
||||
{
|
||||
client_name: charger.mqtt_user,
|
||||
chargeID: charger.mqtt_topic,
|
||||
password: charger.mqtt_pass,
|
||||
},
|
||||
{ timeout: config.mosquittoMgmt.timeoutMs }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[MosquittoMgmt] Erro ao criar cliente:', err?.response?.data || err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function mosquittoDeleteClient(charger) {
|
||||
const url = mosquittoUrl('/client/delete');
|
||||
if (!url) {
|
||||
console.warn('[MosquittoMgmt] MOSQUITTO_MGMT_URL não definido. Skip delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
url,
|
||||
{
|
||||
client_name: charger.mqtt_user,
|
||||
chargeID: charger.mqtt_topic,
|
||||
},
|
||||
{ timeout: config.mosquittoMgmt.timeoutMs }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[MosquittoMgmt] Erro ao deletar cliente:', err?.response?.data || err.message);
|
||||
}
|
||||
}
|
||||
|
||||
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 cfg = await chargersRepo.getConfig(charger.id);
|
||||
if (!cfg) {
|
||||
cfg = {
|
||||
charger_id: charger.id,
|
||||
max_charging_current: 32,
|
||||
require_auth: false,
|
||||
rcm_enabled: false,
|
||||
temperature_limit: 60,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...normalizeNumericFields(charger), config: cfg };
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
await mosquittoCreateClient(charger);
|
||||
|
||||
return charger;
|
||||
}
|
||||
|
||||
async function update(userId, id, payload = {}) {
|
||||
let { charger = {}, config: cfgPatch = {} } = payload;
|
||||
|
||||
if (payload.location && !charger.location) charger.location = payload.location;
|
||||
|
||||
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
|
||||
if (cfgPatch && Object.keys(cfgPatch).length > 0) {
|
||||
const ALLOWED = ['max_charging_current', 'require_auth', 'rcm_enabled', 'temperature_limit'];
|
||||
let safeConfig = Object.fromEntries(
|
||||
Object.entries(cfgPatch || {}).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);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
await mosquittoDeleteClient(charger);
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
};
|
||||
0
src/services/configs.service.js
Executable file
0
src/services/configs.service.js
Executable file
56
src/services/push.service.js
Executable file
56
src/services/push.service.js
Executable file
@@ -0,0 +1,56 @@
|
||||
// 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 };
|
||||
38
src/services/pushHttp.service.js
Normal file
38
src/services/pushHttp.service.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// 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 };
|
||||
57
src/services/sessions.service.js
Executable file
57
src/services/sessions.service.js
Executable file
@@ -0,0 +1,57 @@
|
||||
// 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 };
|
||||
51
src/services/users.service.js
Normal file
51
src/services/users.service.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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 };
|
||||
13
src/utils/httpError.js
Normal file
13
src/utils/httpError.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user