This commit is contained in:
2026-01-10 18:39:55 +00:00
parent 0a0969b8af
commit 70ea1894d4
928 changed files with 5187 additions and 3121 deletions

338
src/services/chargers.service.js Executable file
View 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,
};

View File

56
src/services/push.service.js Executable file
View 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 };

View 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 };

View 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 };

View 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 };