@mangar2/mqttclient
Version:
communicates with a MQTT-Style HTTP broker
379 lines (351 loc) • 14.1 kB
JavaScript
/**
* @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