UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

431 lines (383 loc) 12.5 kB
const { Plugin, PluginSetup } = require("../plugin.js") const { Condition } = require("../logic/logic.js") const schema = require("./schema.json") const mqtt = require("mqtt") /** @typedef {import('../../types').Game} Game */ /** @typedef {import('../../types').SessionInterface} Session */ /** @typedef {import('../../types').AdaptorAction} Action */ class MQTTClient extends Plugin { constructor() { super(schema) this.core = false this.autoconnect = true this.client } /** @type {PluginSetup} */ async setup(config, game) { super.setup(config, game) this.addTemplate("onMQTTMessage", (payload, action) => { const title = `MQTT ${action.name}` let subtitle = `topic: ${payload.topic}` const body = [] // prettier-ignore const types = ["equals", "contains", "lessThan", "greaterThan", "regex", "javascript", "media", "find", "query"]; const hasProp = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key) if (hasProp(payload, "if")) { for (let i = 0; i < payload.if.length; i++) { const condition = payload.if[i] let type = types.find((t) => hasProp(condition, t)) if (!type) break // ignore unknown types let text = "" if (hasProp(condition, "topic_contains")) { text += `${condition.topic_contains} ` } if (hasProp(condition, "field")) { text += `${condition.field} ` } if ( hasProp(condition, "field") || hasProp(condition, "topic_contains") ) { text += `${type}: ` } else { text += `${type[0].toUpperCase() + type.substring(1)}: ` } if (Array.isArray(condition[type])) text += condition[type].join(", ") else if (typeof condition[type] === "string") text += condition[type] else if (typeof condition[type] === "object") text += JSON.stringify(condition[type]) let next if (hasProp(condition, "next")) next = condition.next body.push({ text, next }) } } if (hasProp(payload, "else")) { let text = `Else` let next if (hasProp(payload.else, "next")) { next = payload.else.next text += ` go to: ${next}` } body.push({ text, next }) } return { title, subtitle, body } }) this.addTemplate("sendMQTTMessage", (payload, action) => { const title = "MQTT " + action.name let subtitle = `topic: ${payload.topic}` const body = [{ text: payload.message }] return { title, subtitle, body } }) await this.close() return this.schema } connect() { if (!this.settings.url) { throw new adaptor.InvalidError( `Unable to connect to MQTT Broker. No URL defined.` ) } return new Promise((resolve, reject) => { this.client = mqtt.connect(this.settings.url, this.settings) let connectionTimeout = setTimeout(() => { return reject( new adaptor.ConnectionError( `Failed to connect to MQTT broker ${this.settings.url}. No response for ${this.settings.connectTimeout / 1000 || 30} seconds` ) ) }, this.settings.connectTimeout || 30000) /** * List of all topics the client subscribed to since connection was established * @type {Array<string>} */ this.client.subscriptions = [] this.client.on("connect", (connack) => { clearTimeout(connectionTimeout) this.log.info(`Connected to MQTT Broker ${this.settings.url}`) this.log.trace(connack) this.setConnected(true) this.client.removeAllListeners("error") this.client.on("error", (err) => { if (this.client.latest_error !== err.message) { this.log.error(err.message) this.client.latest_error = err.message this.setConnected(false) } }) return resolve() }) this.client.on("error", (err) => { clearTimeout(connectionTimeout) this.client.removeAllListeners("error") this.client.on("error", (err) => { if (this.client.latest_error !== err.message) { this.log.error(err.message) this.client.latest_error = err.message } }) this.setConnected(false) return reject( new adaptor.ConnectionError( `Could not connect to MQTT Broker ${this.settings.url}`, { cause: err } ) ) }) }) } async update(settings) { this.settings = settings await this.close() await this.connect() } async disconnect() { await this.close() } close() { return new Promise((resolve) => { if (this.client instanceof mqtt.MqttClient) { this.client.end(() => { this.log.info( `Close connection to MQTT Broker ${this.client.options.hostname}` ) this.setConnected(false) return resolve() }) } else { return resolve() } }) } /** * Publish a MQTT Message * @type {Action} */ async sendMQTTMessage(data, session) { if (!this.connected) { throw new adaptor.ConnectionError( `Can not send MQTT message. MQTT Broker not connected` ) } data.message = session.variables.parseJSON(data.message) if (typeof data.message === "object") { data.message = JSON.stringify(data.message) } if (typeof data.message === "number") { data.message = String(data.message) } await new Promise((resolve, reject) => { this.client.publish( data.topic, data.message, { retain: data.retain }, (err) => { if (err) return reject(err) session.log( `published message on ${data.topic}. Retain: ${data.retain}\n${data.message}` ) return resolve() } ) }) } /** * Listen on incoming MQTT Messages * @type {Action} */ async onMQTTMessage(data, session) { if (!this.connected) { throw new adaptor.ConnectionError( `Can not subscribe to MQTT events. MQTT Broker not connected` ) } let listener = new MQTTListener(data, this.client, session, this.game) await listener.init() return listener.cancel.bind(listener) } } /** * Listener for incoming MQTT messages. */ class MQTTListener { constructor(properties, client, session, game) { /** @type {mqtt.MqttClient} */ this.client = client Object.assign(this, properties) /** @type {Session} */ this.session = session /** @type {Game} */ this.game = game } /** * Install mqtt client 'message' event listener * * Add subscription for topic * * Unsubscribe if there was an active subscription on the (exact) same topic to make sure any retain messages are triggered. */ async init() { this.topic = await this.session.variables.review(this.topic) this.session.log.info(`Listening on ${this.topic}`) this.listenerCallback = this.session.getCallback( this.incomingMessage.bind(this) ) this.client.on("message", this.listenerCallback) if (this.client.subscriptions.includes(this.topic)) { await this.unsubscribe(this.topic) } else { this.session.log.debug(`Add Subscription for ${this.topic}`) } await new Promise((resolve, reject) => { this.client.subscribe(this.topic, (err) => { if (err) { this.session.log.error(`Could not add subscription for ${this.topic}`) return reject(err.message) } this.client.subscriptions.push(this.topic) return resolve() }) }) } async incomingMessage(topic, message, packet) { message = message.toString() if (this.reviewTopic(topic, this.topic)) { if (packet.retain) { this.session.log(`Retained Message '${message}' on topic ${topic}`) } else { this.session.log(`Incoming Message '${message}' on topic ${topic}`) } this.session.log.trace(packet) let topic_levels = topic.split("/") message = this.session.variables.parseJSON(message) await this.session.variables.store({ topic: topic, topic_levels: topic_levels, message: message, retain: packet.retain, qos: packet.qos, dup: packet.dup, length: packet.length }) await this.reviewMessage(topic, message) } } reviewTopic(incoming_topic, match_topic) { if (incoming_topic === match_topic || match_topic === "#") { return [incoming_topic] } const res = [] const t = String(incoming_topic).split("/") const w = String(match_topic).split("/") let i = 0 for (const lt = t.length; i < lt; i++) { if (w[i] === "+") { res.push(t[i]) } else if (w[i] === "#") { res.push(t.slice(i).join("/")) return res } else if (w[i] !== t[i]) { return null } } if (w[i] === "#") { i += 1 } return i === w.length ? res : null } /** * Check for condition matches with the event payload * * If event listener condition has topic_contains property, evaluate if topic matches specifically. * * If event listener condition has field property, match condition against this field in payload. * * @param {string} topic - topic of incoming message * @param {*} message - event payload message provided with the event. * * @returns {Promise<boolean>} - true if next state was triggered by else option */ async reviewMessage(topic, message) { if (this.hasOwnProperty("if")) { for (let if_condition of this["if"]) { if (if_condition["topic_contains"]) { let topic_match = topic.match(if_condition["topic_contains"]) if (!topic_match) { this.session.log.trace( `topic ${topic} does not contain ${if_condition["topic_contains"]}.` ) continue } else { this.session.variables.store({ topic_contains: topic_match }) this.session.log.trace( `topic ${topic} contains ${if_condition["topic_contains"]}.` ) } } if (if_condition["field"]) { if (typeof message !== "object") { this.session.log(`incoming message is not of type object`) continue } if_condition["value"] = adaptor.getPath(message, if_condition.field) if (typeof if_condition.value === "undefined") { this.session.log( `incoming message has no field ${if_condition.field}` ) continue } } else { if_condition["value"] = message } let condition = new Condition(if_condition, this.session, this.game) let match = await condition.match() if (match) { this.session.next(match.next) return } } } if (this.hasOwnProperty("else")) { if (this.else.next) { this.session.next(this.else.next) return true } else { this.session.log.warn( "Can not handle incoming message. 'next' missing in else." ) } } this.session.log(`no match on incoming message and no else was defined`) } unsubscribe(topic) { return new Promise((resolve, reject) => { this.client.unsubscribe(topic, (err) => { if (err) return reject(err) return resolve() }) }) } /** * Stop listening for message on this topic * * Only unsubscribe if there is no other active listener on the same topic */ async cancel() { for (var i = 0; i < this.client.subscriptions.length; i++) { if (this.client.subscriptions[i] === this.topic) { this.client.subscriptions.splice(i, 1) } } if (!this.client.subscriptions.includes(this.topic)) { await this.unsubscribe(this.topic) this.session.log(`unsubscribe from ${this.topic}`) } this.client.removeListener("message", this.listenerCallback) this.session.log( `Stop waiting for 'message' Event. ${this.client.listenerCount("message")} 'message' listeners left open` ) } } module.exports = { Plugin: MQTTClient }