(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);