286 lines
9.3 KiB
JavaScript
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);
|