adaptorex
Version:
Connect all your live interactive storytelling devices and software
431 lines (383 loc) • 12.5 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
}