437 lines
12 KiB
JavaScript
Executable File
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,
|
|
};
|