UNPKG

@mangar2/mqttclient

Version:

communicates with a MQTT-Style HTTP broker

379 lines (351 loc) 14.1 kB
/** * @license * This software is licensed under the GNU LESSER GENERAL PUBLIC LICENSE Version 3. It is furnished * "as is", without any support, and with no warranty, express or implied, as to its usefulness for * any purpose. * * @author Volker Böhm * @copyright Copyright (c) 2020 Volker Böhm * @overview * Provides a standard client to communicate with the mqtt broker */ 'use strict' const MqttService = require('@mangar2/mqttservice') const Connect = require('@mangar2/connect') const TopicMatch = require('@mangar2/topicmatch') const DEBUG = true const errorLog = (error) => { require('@mangar2/errorlog')(error, DEBUG) } const { types } = require('@mangar2/utils') const shutdown = require('@mangar2/shutdown') const Callbacks = require('@mangar2/callbacks') const Message = require('@mangar2/message') /** * @typedef {Object} LogPattern * @property {string} topic log topic pattern * @property {string} module module to log, "send", "receive" or "all" (for all modules) * @property {number} level log level for the pattern */ /** * Callback to retrieve messages to be sent to the mqtt broker * @callback PollCallback * @returns {Message[], Message} messages to send to the broker */ /** * @private * @description * Pauses the execution for a while (needs to "wait") for the result. * @param {number} timeInMilliseconds delay in milliseconds * @returns {Promise} promise to wait for delay */ function delay (timeInMilliseconds) { return new Promise(resolve => setTimeout(resolve, timeInMilliseconds)) } /** * Creates a standard mqtt client, connects and subscribes to the broker * @param {Object} options options to provide for connection * @param {string} options.clientId unique id of the client * @param {Object} options.broker information of the broker to connect to * @param {string} options.broker.host hostname of the broker * @param {number} options.broker.port port of the broker * @param {number} options.listener port this client will listen to * @param {string} [options.version='1.0'] interface version * @param {string} [options.keepAliveInSeconds] connection keep alive time in seconds * @param {boolean} [options.clean=true] clean the broker session on disconnect * @param {number} options.retry amount of retries to send messages to the broker * @param {LogPattern[]} options.log logging settings */ class MqttClient { constructor (options) { if (options === undefined) { throw Error('MqttClient constructor needs a valid options object; Provided: ' + JSON.stringify(options)) } const { clientId, broker, listener, log } = options this._server = new MqttService.OnPublish(listener, log) this._broker = broker this._clientId = clientId this._closeChain = [] this._recipients = {} this._sender = [] this._client = new MqttService.Publish(broker.host, broker.port, options) this.version = options.version || '1.0' this._isShuttingDown = false this.connected = false if (!isNaN(options.keepAliveInSeconds)) { this._keepAlive = options.keepAliveInSeconds * 1000 } this._clean = options.clean || true this._callbacks = new Callbacks('shutdown') this._initCallbacks() } /** * @private * @description * Initializes the callbaks for "on publish" and "shutdown" */ _initCallbacks () { this._server.on('publish', async (message) => { for (const key in this._recipients) { const recipient = this._recipients[key] const topicMatch = new TopicMatch(recipient.subscriptions) if (topicMatch.getFirstMatch(message.topic) !== undefined) { const messageCopy = new Message(message.topic, message.value, message.reason) let returnMessage = await recipient.callback(messageCopy) if (returnMessage instanceof Message) { returnMessage = [returnMessage] } if (Array.isArray(returnMessage)) { for (const entry of returnMessage) { this.publish(entry) } } } } }) shutdown(async () => { await this.close() }) } /** * Send and receive token to be used to communicate with the mqtt broker * @type {{send:string, receive:string}} object with send and receive tokens */ get token () { return this._token } set token (token) { this._token = token } /** * Connection status. true, iff connected * @type {boolean} */ get connected () { return this._connected } set connected (connected) { this._connected = connected } /** * @description checks, if the client is shutting down. Every loop must stop once isShuttingDown is true * @returns {boolean} true, iff shutting down */ isShuttingDown () { return this._isShuttingDown } /** * @private * @description * Sets shutdown status. shutdown is cleared in the contructor and * set in the shutdown function. * @param {boolean} [isShuttingDown=true] true, iff shutting down */ signalShutdown (isShuttingDown = true) { this._isShuttingDown = isShuttingDown } /** * Gets/Sets the interface version to use * @type {string} */ get version () { return this._version } set version (version) { this._version = version } /** * Publishes a message to the broker * @param {Message} message message to publish * @param {number} message.qos quality of service (0,1,2) * @param {boolean} message.retain true to create a retain message * @param {string} serviceName name of the publishing service */ publish (message, serviceName) { this._server.logFilter.condLogMessage('send', message, message.qos, false, serviceName) const token = this.token ? this.token.send : undefined this._client.publish(token, message, message.qos, message.retain, this.version) } /** * Sets a callback. * @param {string} event supports 'shutdown' * @param {function} callback * @throws {Error} if the event is not supported * @throws {Error} if the callback is not 'function' */ on (event, callback) { this._callbacks.on(event, callback) } /** * Starts the mqttclient. Opens the listener and connects to the broker */ async run () { if (!this.isShuttingDown()) { try { this._server.listen() this._portUsed = this._server.port this._connect = new Connect(this._clientId, this._broker.host, this._broker.port, this._portUsed) await this.reconnect() this._keepConnected() } catch (error) { errorLog(error) } } } /** * closes the client by shutting down all services and loops */ async close () { try { this.signalShutdown() await this._callbacks.invokeCallbackAsync('shutdown') await delay(500) for (const closeFunction of this._closeChain) { await closeFunction() } await this._connect.disconnect(this.version) } catch (error) { errorLog(error) } await this._server.close() } /** * Registers close functions. It will be called when the client close function is called * @param {function} closeFunction function to be called on close commands */ registerCloseFunction (closeFunction) { if (types.isAsyncFunction(closeFunction)) { this._closeChain.push(closeFunction) } } /** * Connects and subscribes to the broker */ async reconnect () { try { const result = await this._connect.connect(this._clean, this.version, this._keepAlive) this.connected = true this.token = result.token for (const key in this._recipients) { const recipient = this._recipients[key] const subscriptions = recipient.subscriptions const subscribe = await this._connect.subscribe(subscriptions, this.version) const errorPosition = subscribe.qos ? subscribe.qos.indexOf(127) : -1 if (errorPosition > -1) { throw 'subscribe failed at postion: ' + errorPosition } } } catch (error) { this.connected = false errorLog(error) } } /** * @private * @description * Regularly calls a function * @param {number} intervalInMilliseconds interval between calls in milliseconds * @param {funnction} callback function to call */ async _invokeCallback (intervalInMilliseconds, callback) { while (!this.isShuttingDown()) { try { if (this.connected) { await callback() } } catch (err) { errorLog(err) } await delay(intervalInMilliseconds) } } /** * Registeres a recipient * @param {string} serviceName name of the subscribing service * @param {Object} subscriptions subscription entries of format {topic:qos, topic:qos, ...} * @param {function} callback function to send received messages to * @throws {Error} If subscriptions are not well formatted or callback is not a function */ async registerRecipient (serviceName, subscriptions, callback) { if (!this.isShuttingDown()) { try { if (typeof (callback) !== 'function') { throw Error('A callback function must be provided for registerRecipient') } this._recipients[serviceName] = { subscriptions, callback } this.updateSubscriptions(serviceName, subscriptions) } catch (error) { errorLog(error) } } } /** * Updates the subscriptions * @param {string} serviceName name of the subscribing service * @param {Object} subscriptions subscription entries of format {topic:qos, topic:qos, ...} * @throws {Error} If subscriptions are not well formatted */ async updateSubscriptions (serviceName, subscriptions) { if (!this.isShuttingDown()) { this._recipients[serviceName].subscriptions = subscriptions if (this.connected) { await this._connect.subscribe(subscriptions, this.version) } } } /** * Registeres a service sending messages in intervals. The service must provide a function (callback) * without parameters returing an array of messages. The messages will then be sent to the broker * @param {number} intervalInMilliseconds interval in milliseconds to call the senders callback * @param {PollCallback} callback function to call */ registerSender (intervalInMilliseconds, callback) { if (typeof (callback) !== 'function') { throw Error('A callback function must be provided for registerSender') } this._invokeCallback(intervalInMilliseconds, async () => { let messages = await callback() if (messages instanceof Message) { messages = [messages] } if (Array.isArray(messages)) { for (const message of messages) { this.publish(message) } } }) } /** * Creates a message showing the memory usage * @returns {Message} the memory usage message with the topic $SYS/[clientId]/memory usage */ _createMemoryUsageMessage () { const memory = process.memoryUsage() const BYTE_TO_MBYTE = 1000000 const reason = 'rss: ' + memory.rss / BYTE_TO_MBYTE + 'MB ' + 'ht: ' + memory.heapTotal / BYTE_TO_MBYTE + 'MB ' + 'hu: ' + memory.heapUsed / BYTE_TO_MBYTE + 'MB ' + 'ext: ' + memory.external / BYTE_TO_MBYTE + 'MB ' + 'aB: ' + memory.arrayBuffers const message = new Message( '$SYS/' + this._clientId + '/memory usage', memory.rss / BYTE_TO_MBYTE, reason) message.qos = 0 return message } /** * @private * @description * Ensures that the system stayes connected with the broker */ async _keepConnected () { let memoryUsageMessageCountdown = 0 const MEMORY_USAGE_TICKS = 10 while (!this.isShuttingDown()) { try { if (this.version === '0.0' || !this.connected) { this.reconnect() } else { this.connected = await this._connect.pingreq() } if (memoryUsageMessageCountdown <= 0) { this.publish(this._createMemoryUsageMessage()) memoryUsageMessageCountdown = MEMORY_USAGE_TICKS } memoryUsageMessageCountdown-- } catch (error) { this.connected = false errorLog(error) } if (!this.connected) { await delay(1000) } else { await delay(this._keepAlive / 3) } } } } module.exports = MqttClient