Files
evse-backend/mqtt/client.js
2025-12-07 14:32:46 +00:00

437 lines
12 KiB
JavaScript
Executable File

// mqtt/client.js
const mqtt = require('mqtt');
const EventEmitter = require('events');
const db = require('../db');
const { sendPushToUser } = require('../utils/pushService');
require('dotenv').config();
const emitter = new EventEmitter();
const MQTT_URL = process.env.MQTT_URL || 'mqtt://localhost:1883';
const mqttUser = process.env.MQTT_USER || 'admin';
const mqttPass = process.env.MQTT_PASS || '123QWEasd';
const client = mqtt.connect(MQTT_URL, {
username: mqttUser,
password: mqttPass,
reconnectPeriod: 2000,
});
// --------------------
// Helpers
// --------------------
const lastEnabled = {}; // por chargerId
function getStatusFromStateCode(code) {
const map = {
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 getTriple(arr) {
return Array.isArray(arr)
? arr.slice(0, 3).map((n) => Math.round((Number(n) || 0) * 10) / 10)
: [0, 0, 0];
}
function toOneDecimal(v) {
const n = Number(v);
if (!Number.isFinite(n)) return 0;
return Math.round(n * 10) / 10;
}
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;
}
// --------------------
// Caches
// --------------------
// último estado normalizado por chargerId
const lastStateByChargerId = new Map(); // chargerId -> normalizedState
// cache charger por mqttTopic (evita SELECT a cada msg)
const chargerCache = new Map(); // mqttTopic -> { charger, fetchedAt }
const CHARGER_CACHE_TTL_MS = Number(process.env.CHARGER_CACHE_TTL_MS || 30000);
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;
}
// --------------------
// Subscribe
// --------------------
client.on('connect', () => {
console.log('[MQTT] Conectado ao broker:', MQTT_URL);
// ✅ tópicos fixos que TU pediste manter
const fixedTopics = ['+/state', '+/response/config/evse'];
// opcional via env (mas não substitui os fixos)
const envTopics = (process.env.MQTT_SUB_TOPICS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
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) => {
// LOG para garantir que está a consumir
console.log('[MQTT] msg recebida em:', topic);
const parts = topic.split('/');
const mqttTopic = parts[0]; // ex: bf92842c365a
const subtopic = parts.slice(1).join('/'); // ex: state ou response/config/evse
// --------------------
// STATE
// --------------------
if (subtopic === 'state') {
try {
const payload = JSON.parse(message.toString());
const charger = await 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 = String(payload.state || '').split(' ')[0];
if (!stateCode) {
console.warn(`[MQTT] Estado ausente/inválido para charger ID ${chargerId}`);
return;
}
const status = getStatusFromStateCode(stateCode);
const [p1, p2, p3] = getTriple(payload.power);
const [v1, v2, v3] = getTriple(payload.voltage);
const [c1, c2, c3] = getTriple(payload.current);
const consumption = toOneDecimal(payload.consumption);
const chargingTime = toOneDecimal(
payload.chargingTime ?? payload.sessionTime
);
const normalizedState = {
status,
charging_current: c1,
consumption,
charging_time: chargingTime,
power_l1: p1,
power_l2: p2,
power_l3: p3,
voltage_l1: v1,
voltage_l2: v2,
voltage_l3: v3,
current_l1: c1,
current_l2: c2,
current_l3: c3,
};
const prevState = lastStateByChargerId.get(chargerId);
const changed = !prevState || !shallowEqual(prevState, normalizedState);
if (changed) {
await db('chargers')
.where({ id: chargerId })
.update({
...normalizedState,
updated_at: now.toISOString(),
});
lastStateByChargerId.set(chargerId, normalizedState);
console.log(`[DB] Estado atualizado para charger ID ${chargerId}`);
} else {
// só para debug — podes remover depois
console.log(`[MQTT] Estado repetido, sem write (charger ${chargerId})`);
}
// sessões start/stop (mantido)
const previouslyEnabled = lastEnabled[chargerId] || false;
const currentlyEnabled = stateCode === 'C2';
if (!previouslyEnabled && currentlyEnabled) {
const activeSession = await db('charger_sessions')
.where({ charger_id: chargerId })
.whereNull('ended_at')
.first();
if (!activeSession) {
await db('charger_sessions').insert({
charger_id: chargerId,
started_at: now,
kwh: consumption,
});
console.log(`[DB] Sessão iniciada para charger ID ${chargerId}`);
}
}
if (previouslyEnabled && !currentlyEnabled) {
const session = await db('charger_sessions')
.where({ charger_id: chargerId })
.whereNull('ended_at')
.first();
if (session) {
await db('charger_sessions')
.where({ id: session.id })
.update({ ended_at: now, kwh: consumption });
console.log(`[DB] Sessão finalizada para charger ID ${chargerId}`);
}
}
lastEnabled[chargerId] = currentlyEnabled;
// emit para socket
emitter.emit('charging-status', {
charger_id: chargerId,
mqtt_topic: mqttTopic,
status,
stateCode,
consumption,
chargingTime,
power: [p1, p2, p3],
voltage: [v1, v2, v3],
current: [c1, c2, c3],
raw: payload,
});
// push (mantido)
if (status === '⚠️ Fault' || status === '❌ CP Error') {
await sendPushToUser(charger.user_id, {
title: '⚠️ Erro no carregador',
body: `${charger.location || 'Carregador'} entrou em falha.`,
url: `/charger/${charger.id}`,
});
}
if (previouslyEnabled && !currentlyEnabled) {
await sendPushToUser(charger.user_id, {
title: '✅ Carregamento concluído',
body: `${charger.location || 'Carregador'} terminou o carregamento.`,
url: `/history`,
});
}
} catch (err) {
console.error(`[MQTT] Erro ao processar state '${mqttTopic}':`, err);
console.error('Payload recebido:', message.toString());
}
}
// --------------------
// CONFIG RESPONSE
// --------------------
else if (subtopic === 'response/config/evse') {
try {
const payload = JSON.parse(message.toString());
const charger = await getChargerByMqttTopic(mqttTopic);
if (!charger) {
console.warn(`[MQTT] Charger não encontrado para topic: ${mqttTopic}`);
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 db('charger_configs')
.where({ charger_id: charger.id })
.first();
const prevCfgNorm = existingConfig
? {
max_charging_current: Number(existingConfig.max_charging_current) || 32,
require_auth: !!existingConfig.require_auth,
rcm_enabled: !!existingConfig.rcm_enabled,
temperature_limit: Number(existingConfig.temperature_limit) || 60,
}
: null;
const nextCfgNorm = {
max_charging_current: Number(configData.max_charging_current) || 32,
require_auth: !!configData.require_auth,
rcm_enabled: !!configData.rcm_enabled,
temperature_limit: Number(configData.temperature_limit) || 60,
};
const cfgChanged = !prevCfgNorm || !shallowEqual(prevCfgNorm, nextCfgNorm);
if (cfgChanged) {
if (existingConfig) {
await db('charger_configs')
.where({ charger_id: charger.id })
.update(configData);
console.log(`[DB] Configuração atualizada para charger ID ${charger.id}`);
} else {
await db('charger_configs').insert(configData);
console.log(`[DB] Nova configuração inserida para charger ID ${charger.id}`);
}
} else {
console.log(`[MQTT] Config repetida, sem write (charger ${charger.id})`);
}
emitter.emit('charging-config', {
...configData,
mqtt_topic: mqttTopic,
raw: payload,
});
} catch (err) {
console.error(`[MQTT] Erro ao processar config '${mqttTopic}':`, err);
console.error('Payload recebido:', message.toString());
}
}
});
// --------------------
// Broker offline / checker (igual ao teu)
// --------------------
client.on('offline', async () => {
console.warn('[MQTT] Broker offline');
try {
const chargers = await db('chargers').select('id', 'user_id', 'location');
const uniqueUsers = [...new Set(chargers.map((c) => c.user_id))];
await Promise.allSettled(
uniqueUsers.map((userId) =>
sendPushToUser(userId, {
title: '📡 Broker MQTT offline',
body: 'O sistema perdeu ligação ao broker. Alguns estados podem estar desatualizados.',
url: '/',
})
)
);
} catch (err) {
console.error('[MQTT] erro offline push:', err.message);
}
});
setInterval(async () => {
try {
const timeoutMinutes = Number(process.env.CHARGER_OFFLINE_MINUTES || 5);
const limitDate = new Date(Date.now() - timeoutMinutes * 60 * 1000);
const offlineChargers = await db('chargers')
.where('updated_at', '<', limitDate.toISOString())
.andWhereNot({ status: 'offline' })
.select('*');
for (const ch of offlineChargers) {
await db('chargers')
.where({ id: ch.id })
.update({ status: 'offline' });
lastStateByChargerId.delete(ch.id);
lastEnabled[ch.id] = false;
await 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 sendConfig(chargerTopic, property, value) {
const payload = { [property]: value };
client.publish(`${chargerTopic}/set/config/evse`, JSON.stringify(payload), {
qos: 1,
});
}
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 });
}
module.exports = {
on,
sendConfig,
sendEnable,
requestConfig,
};