UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

389 lines (338 loc) 14 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 }