UNPKG

zwave-js-ui

Version:

Z-Wave Control Panel and MQTT Gateway

425 lines (424 loc) 15.3 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); const mqtt_1 = require("mqtt"); const utils_1 = require("./utils"); const logger_1 = require("./logger"); const EventEmitter_1 = require("./EventEmitter"); const app_1 = require("../config/app"); const fs_extra_1 = require("fs-extra"); const mqtt_jsonl_store_1 = require("mqtt-jsonl-store"); const path_1 = require("path"); // eslint-disable-next-line @typescript-eslint/no-var-requires const url = require('native-url'); const logger = (0, logger_1.module)('Mqtt'); class MqttClient extends EventEmitter_1.TypedEventEmitter { config; toSubscribe; _clientID; client; error; closed; retrySubTimeout; _closeTimeout; storeManager; static CLIENTS_PREFIX = '_CLIENTS'; static get EVENTS_PREFIX() { return '_EVENTS'; } static NAME_PREFIX = 'ZWAVE_GATEWAY-'; static ACTIONS = ['broadcast', 'api', 'multicast']; static HASS_WILL = 'homeassistant/status'; static STATUS_TOPIC = 'status'; static VERSION_TOPIC = 'version'; get clientID() { return this._clientID; } /** * The constructor */ constructor(config) { super(); this._init(config).catch((e) => { logger.error('Error while initializing MQTT Client', e); }); } get connected() { return this.client && this.client.connected; } get disabled() { return this.config.disabled; } /** * Returns the topic used to send client and devices status updateStates * if name is null the client is the gateway itself */ getClientTopic(suffix) { return `${this.config.prefix}/${MqttClient.CLIENTS_PREFIX}/${this._clientID}/${suffix}`; } /** * Returns the topic used to report client status */ getStatusTopic() { return this.getClientTopic(MqttClient.STATUS_TOPIC); } /** * Method used to close clients connection, use this before destroy */ close() { return new Promise((resolve) => { if (this.closed) { resolve(); return; } this.closed = true; if (this.retrySubTimeout) { clearTimeout(this.retrySubTimeout); this.retrySubTimeout = null; } let resolved = false; if (this.client) { const onClose = async (error) => { // prevent multiple resolve if (resolved) { return; } resolved = true; // fix error:Failed to lock DB file when force closing await this.storeManager?.close(); if (this._closeTimeout) { clearTimeout(this._closeTimeout); this._closeTimeout = null; } if (error) { logger.error('Error while closing client', error); } this.removeAllListeners(); logger.info('Client closed'); resolve(); }; this.client.end(false, {}, onClose); // in case a clean close doesn't work, force close this._closeTimeout = setTimeout(() => { this.client.end(true, {}, onClose); }, 5000); } else { this.removeAllListeners(); resolve(); } }); } /** * Method used to get status */ getStatus() { const status = {}; status.status = this.client && this.client.connected; status.error = this.error || 'Offline'; status.config = this.config; return status; } /** * Method used to update client connection status */ updateClientStatus(connected) { if (this.client) { this.client.publish(this.getClientTopic(MqttClient.STATUS_TOPIC), JSON.stringify({ value: connected, time: Date.now() }), { retain: this.config.retain, qos: this.config.qos }); } } /** * Method used to publish app version to mqtt */ publishVersion() { if (this.client) { this.client.publish(this.getClientTopic(MqttClient.VERSION_TOPIC), JSON.stringify({ value: utils_1.pkgJson.version, time: Date.now() }), { retain: this.config.retain, qos: this.config.qos }); } } /** * Method used to update client */ async update(config) { await this.close(); logger.info('Restarting Mqtt Client after update...'); await this._init(config); } /** * Method used to subscribe tags for write requests */ subscribe(topic, options = { qos: 1, addPrefix: true, }) { return new Promise((resolve, reject) => { const subOptions = { qos: options.qos, }; topic = options.addPrefix ? this.config.prefix + '/' + topic + '/set' : topic; options.addPrefix = false; // in case of retry, don't add again the prefix if (this.client && this.client.connected) { logger.log('debug', `Subscribing to ${topic} with options %o`, subOptions); this.client.subscribe(topic, subOptions, (err, granted) => { if (err) { logger.error(`Error subscribing to ${topic}`, err); this.toSubscribe.set(topic, options); reject(err); } else { for (const res of granted) { if (res.qos === 128) { logger.error(`Error subscribing to ${topic}, client doesn't have permission to subscribe to it`); } else { logger.info(`Subscribed to ${topic}`); } this.toSubscribe.delete(topic); } resolve(); } }); } else { logger.debug(`Client not connected yet, subscribing to ${topic} later...`); this.toSubscribe.set(topic, options); reject(Error('Client not connected')); } }); } /** * Method used to publish an update */ publish(topic, data, options, prefix) { if (this.client) { const settingOptions = { qos: this.config.qos, retain: this.config.retain, }; // by default use settingsOptions options = Object.assign(settingOptions, options); topic = (prefix || this.config.prefix) + '/' + topic; logger.log('debug', 'Publishing to %s: %o with options %o', topic, data, options); this.client.publish(topic, (0, utils_1.stringifyJSON)(data), options, function (err) { if (err) { logger.error(`Error while publishing a value ${err.message}`); } }); } // end if client } /** * Method used to get the topic with prefix/suffix */ getTopic(topic, set = false) { return this.config.prefix + '/' + topic + (set ? '/set' : ''); } /** * Initialize client */ async _init(config) { this.config = config; this.toSubscribe = new Map(); if (!config || config.disabled) { logger.info('MQTT is disabled'); return; } this._clientID = (0, utils_1.sanitizeTopic)(MqttClient.NAME_PREFIX + (process.env.MQTT_NAME || config.name)); const parsed = url.parse(config.host || ''); let protocol = 'mqtt'; if (parsed.protocol) protocol = parsed.protocol.replace(/:$/, ''); const options = { clientId: this._clientID, reconnectPeriod: config.reconnectPeriod, clean: config.clean, rejectUnauthorized: !config.allowSelfsigned, will: { topic: this.getStatusTopic(), payload: JSON.stringify({ value: false }), qos: this.config.qos, retain: this.config.retain, }, }; if (['mqtts', 'wss', 'wxs', 'alis', 'tls'].indexOf(protocol) >= 0) { if (!config.allowSelfsigned) options.ca = config._ca; if (config._key) { options.key = config._key; } if (config._cert) { options.cert = config._cert; } } if (config.store) { const dbDir = (0, path_1.join)(app_1.storeDir, 'mqtt-packets-store'); await (0, fs_extra_1.ensureDir)(dbDir); this.storeManager = new mqtt_jsonl_store_1.Manager(dbDir); await this.storeManager.open(); // no reason to use a memory store for incoming messages options.incomingStore = this.storeManager.incoming; options.outgoingStore = this.storeManager.outgoing; } if (config.auth) { options.username = config.username; options.password = config.password; } try { const serverUrl = `${protocol}://${parsed.hostname || config.host}:${config.port}`; logger.info(`Connecting to ${serverUrl}`); const client = (0, mqtt_1.connect)(serverUrl, options); this.client = client; client.on('connect', this._onConnect.bind(this)); client.on('message', this._onMessageReceived.bind(this)); client.on('reconnect', this._onReconnect.bind(this)); client.on('close', this._onClose.bind(this)); client.on('error', this._onError.bind(this)); client.on('offline', this._onOffline.bind(this)); } catch (e) { logger.error(`Error while connecting MQTT ${e.message}`); this.error = e.message; } } /** * Function called when MQTT client connects */ async _onConnect() { logger.info('MQTT client connected'); this.emit('connect'); const subscribePromises = []; subscribePromises.push(this.subscribe(MqttClient.HASS_WILL, { addPrefix: false, qos: 1 })); // subscribe to actions for (let i = 0; i < MqttClient.ACTIONS.length; i++) { subscribePromises.push(this.subscribe([ this.config.prefix, MqttClient.CLIENTS_PREFIX, this._clientID, MqttClient.ACTIONS[i], '#', ].join('/'), { addPrefix: false, qos: 1 })); } await (0, utils_1.allSettled)(subscribePromises); await this._retrySubscribe(); this.emit('brokerStatus', true); this.publishVersion(); // Update client status this.updateClientStatus(true); } /** * Function called when MQTT client reconnects */ _onReconnect() { logger.info('MQTT client reconnecting'); } /** * Function called when MQTT client reconnects */ _onError(error) { logger.error('Mqtt client error', error); this.error = error.message; } /** * Function called when MQTT client go offline */ _onOffline() { if (this.retrySubTimeout) { clearTimeout(this.retrySubTimeout); this.retrySubTimeout = null; } logger.info('MQTT client offline'); this.emit('brokerStatus', false); } /** * Function called when MQTT client is closed */ _onClose() { logger.info('MQTT client closed'); } /** * Function called when an MQTT message is received */ _onMessageReceived(topic, payload) { if (this.closed) return; let parsed = payload?.toString(); logger.log('info', `Message received on ${topic}: %o`, parsed); if (topic === MqttClient.HASS_WILL) { if (typeof parsed === 'string') { this.emit('hassStatus', parsed.toLowerCase() === 'online'); } else { logger.error('Invalid payload sent to Hass Will topic'); } return; } // remove prefix topic = topic.substring(this.config.prefix.length + 1); const parts = topic.split('/'); // It's not a write request if (parts.pop() !== 'set') return; if (isNaN(parseInt(parsed))) { try { parsed = (0, utils_1.parseJSON)(parsed); } catch (e) { // it' ok fallback to string } } else { parsed = Number(parsed); } // It's an action if (parts[0] === MqttClient.CLIENTS_PREFIX) { if (parts.length < 3 || parts[1] !== this._clientID) { // it could be we receive a message from another Z-UI client, ignore it return; } const action = MqttClient.ACTIONS.indexOf(parts[2]); switch (action) { case 0: // broadcast this.emit('broadcastRequest', parts.slice(3), parsed); // publish back to give a feedback the action has been received // same topic without /set suffix this.publish(parts.join('/'), parsed); break; case 1: // api this.emit('apiCall', parts.join('/'), parts[3], parsed); break; case 2: // multicast this.emit('multicastRequest', parsed); // publish back to give a feedback the action has been received // same topic without /set suffix this.publish(parts.join('/'), parsed); break; default: logger.warn(`Unknown action received ${action} ${topic}`); } } else { // It's a write request on zwave network this.emit('writeRequest', parts, parsed); } } // end onMessageReceived async _retrySubscribe() { if (this.retrySubTimeout) { clearTimeout(this.retrySubTimeout); this.retrySubTimeout = null; } if (this.toSubscribe.size === 0) { return; } logger.debug('Retry to subscribe to topics...'); const subscribePromises = []; const topics = this.toSubscribe.keys(); for (const t of topics) { subscribePromises.push(this.subscribe(t, this.toSubscribe.get(t))); } this.toSubscribe = new Map(); await (0, utils_1.allSettled)(subscribePromises); if (this.toSubscribe.size > 0) { this.retrySubTimeout = setTimeout(this._retrySubscribe.bind(this), 5000); } } } exports.default = MqttClient;