/* Magic Mirror * Node Helper: MMM-HomeAssistantDisplay * * By Brian Towles * MIT Licensed. * * Resilience improvements by James (2026-02-27): * - Circuit breaker: stops all template evals when HA is unreachable * - Exponential backoff on retries (no more 30 simultaneous retry timers) * - Coalesced health checks instead of per-section retries * - Graceful degradation: sections show stale data instead of freezing */ var backoff = require('backoff') const NodeHelper = require("node_helper"); const HomeAssistant = require("homeassistant"); const HomeAssistantWS = require("homeassistant-ws"); const Logger = require("./helpers/Logger"); util = require('util'), module.exports = NodeHelper.create({ start, stop, socketNotificationReceived, connect, reconnectWebsocket, connectWebsocket, buildHttpUrl, onStateChangedEvent, evaluateTemplate, onWebsocketCloseEvent, backoffWSConnection, _getBreaker, _openCircuit, _closeCircuit, _healthCheck, }); function start() { this.logger = new Logger(this.name); if (config.debuglogging) { this.logger.debug("MMM-HomeAssistantDisplay helper started..."); } this.connections = {}; // Circuit breaker state: tracks HA reachability per connection // When open, template evaluations are skipped entirely (no requests, no error spam) this._circuitBreaker = {}; // identifier -> { state: 'closed'|'open', failCount: 0, nextRetryAt: 0, retryTimer: null } } function stop() { for (const connection in this.connections) { this.connections[connection].websocket.unsubscribeFromEvent("state_changed"); } } function socketNotificationReceived(notification, payload) { if (config.debuglogging) { this.logger.debug(`Recieved notification ${notification}`, payload); } if (notification !== "CONNECT" && (!payload.identifier || !this.connections[payload.identifier])) { this.logger.error(`No connection for ${payload.identifier} found`); return; } switch (notification) { case "CONNECT": this.connect(payload); break; case "RECONNECT_WS": this.reconnectWebsocket(payload); break; case "SET_WATCHED_ENTITY": if (!this.connections[payload.identifier].entities.includes(payload.entity)) { if (config.debuglogging) { this.logger.debug(`Registering entity ${payload.entity}`); } this.connections[payload.identifier].entities.push(payload.entity); } break; case "RENDER_MODULE_DISPLAY_TEMPLATE": this.evaluateTemplate(payload).then((ret) => { this.sendSocketNotification("MODULE_DISPLAY_RENDERED", ret); }).catch((err) => { // Only log if circuit is closed (avoid spam when HA is down) const cb = this._getBreaker(payload.identifier); if (cb.state === 'closed') { this.logger.error("Unable to evaluate template", err); } }); break; case "RENDER_SECTION_DISPLAY_TEMPLATE": this.evaluateTemplate(payload).then((ret) => { this.sendSocketNotification("SECTION_DISPLAY_RENDERED", { ...ret, section: payload.section }); }).catch((err) => { const cb = this._getBreaker(payload.identifier); if (cb.state === 'closed') { this.logger.error("unable to evaluate section template", err); } }); break; } } // Circuit breaker helpers function _getBreaker(identifier) { if (!this._circuitBreaker[identifier]) { this._circuitBreaker[identifier] = { state: 'closed', // closed = healthy, open = HA unreachable failCount: 0, nextRetryAt: 0, retryTimer: null, pendingQueue: [], // queued payloads to retry when circuit closes }; } return this._circuitBreaker[identifier]; } function _openCircuit(identifier) { const breaker = this._getBreaker(identifier); if (breaker.state === 'open') return; // already open // Accepts 'closed' (first failure) or 'half-open' (health check retry failed) breaker.state = 'open'; breaker.failCount++; // Exponential backoff: 15s, 30s, 60s, 120s, max 300s const delay = Math.min(15000 * Math.pow(2, breaker.failCount - 1), 300000); breaker.nextRetryAt = Date.now() + delay; this.logger.info(`Circuit OPEN for ${identifier} — HA unreachable. Next health check in ${delay / 1000}s`); // Single coalesced health check timer (not per-section!) if (breaker.retryTimer) clearTimeout(breaker.retryTimer); breaker.retryTimer = setTimeout(() => { this._healthCheck(identifier); }, delay); } function _closeCircuit(identifier) { const breaker = this._getBreaker(identifier); if (breaker.state === 'closed') return; this.logger.info(`Circuit CLOSED for ${identifier} — HA is reachable again`); breaker.state = 'closed'; breaker.failCount = 0; breaker.nextRetryAt = 0; if (breaker.retryTimer) { clearTimeout(breaker.retryTimer); breaker.retryTimer = null; } // Reconnect WebSocket — it likely died during the outage and won't // recover on its own because the frontend only sends RECONNECT_WS in // response to HASSWS_DISCONNECTED, which the node_helper may never // have emitted if the socket died silently. const conn = this.connections[identifier]; if (conn && conn.connectionConfig) { this.logger.info(`Reconnecting WebSocket for ${identifier} after circuit recovery`); this.backoffWSConnection(identifier, conn.connectionConfig); } // Replay queued template evaluations const queue = breaker.pendingQueue.splice(0); if (queue.length > 0) { this.logger.info(`Replaying ${queue.length} queued template evaluations for ${identifier}`); for (const item of queue) { this.evaluateTemplate(item.payload).then((ret) => { if (item.payload.section !== undefined) { this.sendSocketNotification("SECTION_DISPLAY_RENDERED", { ...ret, section: item.payload.section }); } else { this.sendSocketNotification("MODULE_DISPLAY_RENDERED", ret); } }).catch(() => { // Circuit will re-open if this fails }); } } } async function _healthCheck(identifier) { const breaker = this._getBreaker(identifier); const hass = this.connections[identifier] && this.connections[identifier].hass; if (!hass) return; this.logger.info(`Health check for ${identifier}...`); try { // Simple template render as health probe await Promise.race([ hass.templates.render("{{ 1 }}"), new Promise((_, reject) => setTimeout(() => reject(new Error('Health check timeout')), 10000) ) ]); // Success — close the circuit this._closeCircuit(identifier); } catch (err) { this.logger.info(`Health check failed for ${identifier}: ${err.message}`); // Re-arm with increased backoff — reset state so _openCircuit can fire breaker.state = 'half-open'; this._openCircuit(identifier); } } async function evaluateTemplate(payload) { if (config.debuglogging) { this.logger.debug(`Evaluating template for ${payload.template}`); } // Circuit breaker check: if HA is known-unreachable, skip entirely const breaker = this._getBreaker(payload.identifier); if (breaker.state === 'open') { // Queue this request for replay when circuit closes (deduplicated by section) const isDuplicate = breaker.pendingQueue.some( (item) => item.payload.section === payload.section && item.payload.template === payload.template ); if (!isDuplicate) { breaker.pendingQueue.push({ payload }); // Cap queue size to prevent memory growth if (breaker.pendingQueue.length > 20) { breaker.pendingQueue.shift(); } } throw new Error('Circuit open — HA unreachable, skipping template evaluation'); } const hass = this.connections[payload.identifier].hass; try { // Wrap template call with timeout const response = await Promise.race([ hass.templates.render(payload.template), new Promise((_, reject) => setTimeout(() => reject(new Error('Template evaluation timeout')), 10000) ) ]); // Success — ensure circuit is closed (resets fail count) if (breaker.failCount > 0) { this._closeCircuit(payload.identifier); } return { identifier: payload.identifier, render: response }; } catch (err) { // Check if this is a connectivity error (not a template syntax error) const isConnectivityError = /ENETUNREACH|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|timeout/i.test(err.message); if (isConnectivityError) { this.logger.error(`Template evaluation failed (connectivity): ${err.message}`); this._openCircuit(payload.identifier); } else { // Template/logic error — log but don't trip the breaker this.logger.error(`Template evaluation failed: ${err.message}`); } throw err; } } function buildHttpUrl(config) { if (config.useTLS){ schema = "https" } else { schema = "http" } var url = `${schema}://${config.host}`; return url; } async function connect(payload) { const connectionConfig = { host: payload.host, port: payload.port, token: payload.token, ignoreCert: payload.ignoreCert, useTLS: payload.useTLS, }; const hass = new HomeAssistant({...connectionConfig, host: this.buildHttpUrl(connectionConfig)}); this.logger.info(`HomeAssistant connected for ${payload.identifier}`); this.connections[payload.identifier] = { hass, entities: [], connectionConfig, }; await this.backoffWSConnection(payload.identifier, connectionConfig) } async function backoffWSConnection(identifier, connectionConfig) { self = this; var call = backoff.call(this.connectWebsocket, this, identifier, connectionConfig, function(err, res) { if (err) { self.logger.info(`Unable to connect to Home Assistant for ${identifier}: ` + err.message); } else { self.logger.info(`Conected to Home Assistant for ${identifier} after ${call.getNumRetries()} retries`); } }); call.retryIf(function(err) { return true; }); call.setStrategy(new backoff.ExponentialStrategy({ initialDelay: 10, maxDelay: 10000 })); call.start(); } function connectWebsocket(obj, identifier, connectionConfig, callback) { var self = obj; HomeAssistantWS.default({ ...connectionConfig, protocol: ((connectionConfig.useTLS) ? "wss" : "ws") }) .then((hassWs) => { self.connections[identifier].websocket = hassWs; hassWs.on("state_changed", onStateChangedEvent.bind(self)); hassWs.on("ws_close", onWebsocketCloseEvent.bind(self)); callback(null, hassWs); }) .catch((err) => { self.logger.error( `Unable to connect to Home Assistant for module ${identifier} failed with message: `, err.message ); callback(err, null); }); return; } async function reconnectWebsocket(payload) { const connectionConfig = { host: payload.host, port: payload.port, token: payload.token, useTLS: payload.useTLS, ignoreCert: payload.ignoreCert }; for (const connection in this.connections) { if (connection == payload.identifier){ this.logger.info(`Reconnecting to Home Assistant websocket for ${payload.identifier}`); await this.backoffWSConnection(payload.identifier, connectionConfig) } } } function onStateChangedEvent(event) { for (const connection in this.connections) { if (this.connections[connection].entities.includes(event.data.entity_id)) { this.sendSocketNotification("CHANGED_STATE", { identifier: connection, cause: event.data.entity_id, }); } } } function onWebsocketCloseEvent(event) { for (const connection in this.connections) { if (event.target == this.connections[connection].websocket.rawClient.ws) { this.logger.info(`Hass WS Disconnected (${connection})`); this.sendSocketNotification("HASSWS_DISCONNECTED", { identifier: connection, }); } } }