esp/assets/js/mqttRealtime.js

286 lines
9.3 KiB
JavaScript

(function(global) {
'use strict';
const state = {
client: null,
config: null,
connected: false,
handlers: [],
connecting: null,
connectionType: null
};
function unique(values) {
const seen = {};
return values.filter(function(value) {
if (!value || seen[value]) return false;
seen[value] = true;
return true;
});
}
function candidateUrls(url, port) {
const base = String(url || '').trim();
const candidates = [base];
const configuredPort = parseInt(port, 10);
try {
const parsed = new URL(base);
const origins = [parsed.origin];
if (configuredPort && !parsed.port) {
origins.push(parsed.protocol + '//' + parsed.hostname + ':' + configuredPort);
}
const normalizedPath = parsed.pathname.replace(/\/+$/g, '');
origins.forEach(function(origin) {
if (normalizedPath) {
candidates.push(origin + normalizedPath);
candidates.push(origin + normalizedPath + '/');
}
candidates.push(origin + '/ws');
candidates.push(origin + '/ws/');
candidates.push(origin + '/mqtt');
candidates.push(origin + '/mqtt/');
});
} catch (_) {}
return unique(candidates);
}
function trimSlashes(value) {
return String(value || '').replace(/^\/+|\/+$/g, '');
}
function withPrefix(topic) {
const prefix = trimSlashes(state.config && state.config.topicPrefix);
const cleanTopic = trimSlashes(topic);
if (!prefix) return cleanTopic;
if (cleanTopic === prefix || cleanTopic.indexOf(prefix + '/') === 0) return cleanTopic;
return prefix + '/' + cleanTopic;
}
function stripPrefix(topic) {
const prefix = trimSlashes(state.config && state.config.topicPrefix);
let cleanTopic = trimSlashes(topic);
if (prefix && cleanTopic.indexOf(prefix + '/') === 0) cleanTopic = cleanTopic.slice(prefix.length + 1);
return cleanTopic;
}
function updateIndicator(connected) {
state.connected = !!connected;
if (typeof global.updateWSIndicator === 'function') global.updateWSIndicator(state.connected);
}
function normalizeState(value) {
const v = String(value || '').trim().toLowerCase();
if (['online', 'connected', 'available', '1', 'true', 'on'].indexOf(v) !== -1) return 'online';
if (['offline', 'disconnected', 'unavailable', '0', 'false', 'off'].indexOf(v) !== -1) return 'offline';
return v || 'desconocido';
}
function decodePayload(message) {
if (message && typeof message === 'object') return message;
const text = String(message || '');
try { return JSON.parse(text); } catch (_) { return text; }
}
function emit(event) {
state.handlers.forEach(function(handler) {
try { handler(event); } catch (err) { console.error('[MQTT realtime] handler error', err); }
});
}
function handleMessage(topic, message) {
const cleanTopic = stripPrefix(topic);
const parts = cleanTopic.split('/');
const deviceIndex = parts.indexOf('dispositivo');
if (deviceIndex === -1 || parts.length < deviceIndex + 3) return;
const chipid = String(parts[deviceIndex + 1] || '').toUpperCase();
const type = parts[deviceIndex + 2];
const subtype = parts[deviceIndex + 3] || null;
const payload = decodePayload(message);
const payloadText = typeof payload === 'string' ? payload : JSON.stringify(payload || '');
if (type === 'state') {
emit({ type: 'state', chipid: chipid, estado: normalizeState(payloadText), topic: cleanTopic });
return;
}
if (type === 'ip') {
emit({ type: 'ip', chipid: chipid, ip: payloadText.trim(), topic: cleanTopic });
return;
}
if ((type === 'valores' || type === 'pin') && subtype) {
const valor = payload && typeof payload === 'object' && payload.valor !== undefined ? payload.valor : payloadText;
emit({ type: 'pin', chipid: chipid, alias: subtype, valor: String(valor), topic: cleanTopic });
return;
}
if (type === 'ota' && subtype === 'status') {
emit({ type: 'ota', chipid: chipid, status: payloadText.trim(), topic: cleanTopic });
return;
}
if (type === 'comando' && payload && typeof payload === 'object' && payload.alias !== undefined && payload.valor !== undefined) {
emit({ type: 'pin', chipid: chipid, alias: String(payload.alias), valor: String(payload.valor), topic: cleanTopic });
}
}
function connect() {
if (state.client && state.connected) return Promise.resolve(state.client);
if (state.connecting) return state.connecting;
state.connecting = fetch('/api/mqtt_config', { cache: 'no-store', credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(function(r) { return r.json(); })
.then(function(response) {
state.config = response.data || response;
if (!state.config.url) throw new Error('WS_URL no esta configurado.');
const urls = candidateUrls(state.config.url, state.config.port);
return connectNativeWebSocket(urls, 0);
}).catch(function(err) {
updateIndicator(false);
console.error('[MQTT realtime] init error', err);
throw err;
}).finally(function() {
state.connecting = null;
});
return state.connecting;
}
function connectNativeWebSocket(urls, index) {
if (index >= urls.length) {
throw new Error('No se pudo conectar a ningun endpoint WebSocket JSON.');
}
const url = urls[index];
console.info('[MQTT realtime] conectando WebSocket JSON', { url: url });
return new Promise(function(resolve, reject) {
let settled = false;
const socket = new WebSocket(url);
const timer = setTimeout(function() {
fail(new Error('timeout'));
}, 7000);
function fail(err) {
if (settled) return;
settled = true;
clearTimeout(timer);
try { socket.close(); } catch (_) {}
console.warn('[MQTT realtime] fallo WebSocket JSON', url, err && (err.message || err));
connectNativeWebSocket(urls, index + 1).then(resolve).catch(reject);
}
socket.addEventListener('open', function() {
if (settled) return;
settled = true;
clearTimeout(timer);
state.client = socket;
state.connectionType = 'websocket-json';
state.config.url = url;
updateIndicator(true);
console.info('[MQTT realtime] WebSocket JSON conectado', url);
resolve(socket);
}, { once: true });
socket.addEventListener('message', function(event) {
try {
const data = JSON.parse(String(event.data || ''));
if (data && data.topic !== undefined) {
handleMessage(String(data.topic), data.payload !== undefined ? data.payload : '');
}
} catch (err) {
console.warn('[MQTT realtime] mensaje WebSocket JSON invalido', err);
}
});
socket.addEventListener('close', function() {
updateIndicator(false);
if (!settled) fail(new Error('close antes de connect'));
});
socket.addEventListener('error', function() {
if (!settled) fail(new Error('error de WebSocket'));
});
});
}
function onMessage(handler) {
if (typeof handler === 'function') state.handlers.push(handler);
connect().catch(function() {});
return function unsubscribe() {
state.handlers = state.handlers.filter(function(item) { return item !== handler; });
};
}
function publish(topic, payload, options) {
return connect().then(function(client) {
return publishViaWebSocket(client, topic, payload, options).catch(function(err) {
console.warn('[MQTT realtime] publish WebSocket fallo, probando backend', err && (err.message || err));
return publishViaBackend(topic, payload, options);
});
});
}
function publishViaWebSocket(socket, topic, payload, options) {
return new Promise(function(resolve, reject) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket no conectado.'));
return;
}
const publishOptions = options || {};
const body = {
action: 'publish',
topic: withPrefix(topic),
payload: typeof payload === 'string' ? payload : JSON.stringify(payload),
qos: publishOptions.qos || 0,
retain: !!publishOptions.retain
};
try {
socket.send(JSON.stringify(body));
resolve(body);
} catch (err) {
reject(err);
}
});
}
function publishViaBackend(topic, payload, options) {
const publishOptions = options || {};
return fetch('/api/mqtt_publish', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
topic: withPrefix(topic),
payload: payload,
qos: publishOptions.qos || 0,
retain: !!publishOptions.retain
})
}).then(function(response) {
return response.json().then(function(data) {
if (!response.ok || data.success === false) {
throw new Error((data && data.message) || 'Fallo al publicar MQTT desde backend.');
}
return data;
});
});
}
global.mqttRealtime = {
connect: connect,
onMessage: onMessage,
publish: publish,
withPrefix: withPrefix,
stripPrefix: stripPrefix
};
})(window);