adaptorex
Version:
Connect all your live interactive storytelling devices and software
389 lines (338 loc) • 14 kB
JavaScript
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
}