diff --git a/Dockerfile b/Dockerfile index 8179132..2143f07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,12 @@ WORKDIR /opt/OpenHaus/backend COPY --from=builder node_modules node_modules RUN apk --no-cache add openssl +ARG version=unknown +LABEL version=$version + +ARG buildDate=unknown +LABEL buildDate=$buildDate + COPY ./build/ ./ #COPY ./package.json ./ diff --git a/Gruntfile.js b/Gruntfile.js index 9c309d6..6cbaf4b 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -88,10 +88,22 @@ module.exports = function (grunt) { grunt.registerTask("build:docker", () => { - cp.execSync(`docker build . -t openhaus/${pkg.name}:latest --build-arg version=${pkg.version}`, { + + let buildArgs = [ + `--build-arg version=${pkg.version}`, + `--build-arg buildDate=${Date.now()}`, + ].join(" "); + + cp.execSync(`docker build . -t openhaus/${pkg.name}:${pkg.version} ${buildArgs}`, { env: process.env, stdio: "inherit" }); + + cp.execSync(`docker build . -t openhaus/${pkg.name}:latest ${buildArgs}`, { + env: process.env, + stdio: "inherit" + }); + }); @@ -137,4 +149,18 @@ module.exports = function (grunt) { }); + grunt.registerTask("publish", () => { + [ + `docker push openhaus/${pkg.name}:${pkg.version}`, + `docker push openhaus/${pkg.name}:latest` + ].forEach((cmd) => { + cp.execSync(cmd, { + env: process.env, + stdio: "inherit" + }); + }); + + }); + + }; \ No newline at end of file diff --git a/README.md b/README.md index f691935..5d3640c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,18 @@ The [docs](docs) folder is soon be removed. For the HTTP API we provide a [postman collection](postman.json). +## Demo +There exsits a public demo: http://demo.open-haus.io
+It is deployed with docker and runs the frontend & backend container.
+The instance is rested to its default values every 10 Minutes.
+No authentication required, full API support. + + +## HTTP API +We provide a [postmann collection](./postman.json) that you can import.
+It containes documentation about every URL endpoint and its meaning.
+Get postman on https://www.postman.com/ its great tool for HTTP testing & documentation. + ## Contribution If you have questions, want to contribute or just wanna have a talk, open a new issue. @@ -25,16 +37,6 @@ Fork this repository, apply the changes you want to make, and create a pull requ __*Note*__: If you want to contribute, please take a look on the "documentation" repository, section "[How to document the source code](https://github.com/OpenHausIO/documentation#how-to-document-the-source-code)". -## Docker quick start: -Build the docker image: -```sh -npm run build:docker-image -``` - -Start a container: -```sh -docker run --rm -it --net host --name backend --env=UUID=c04a9aa6-7261-11ed-8578-cb6ee612422e --env=VAULT_MASTER_PASSWORD=Pa$$w0rd --env=USERS_JWT_SECRET=Pa$$w0rd --env=DATABASE_HOST=172.17.0.1 --env=API_AUTH_ENABLED=false openhaus/backend -``` ## License Im currently not sure, under what license i publish this work.
diff --git a/adapter/base64.js b/adapter/base64.js deleted file mode 100644 index 358419a..0000000 --- a/adapter/base64.js +++ /dev/null @@ -1,41 +0,0 @@ -const { Transform } = require("stream"); -const logger = require("../system/logger").create("adapter/base64"); - -module.exports = (options) => { - - const encode = new Transform({ - transform(chunk, encoding, cb) { - - let data = chunk.toString("base64"); - - logger.verbose("[encode]: %j", data); - - this.push(data); - - cb(); - - }, - ...options - }); - - const decode = new Transform({ - transform(chunk, encoding, cb) { - - let data = Buffer.from(chunk.toString(), "base64").toString("ascii"); - - logger.verbose("[decode]: %j", data); - - this.push(data); - - cb(); - - }, - ...options - }); - - return { - encode, - decode - }; - -}; \ No newline at end of file diff --git a/adapter/json.js b/adapter/json.js deleted file mode 100644 index 7e80e09..0000000 --- a/adapter/json.js +++ /dev/null @@ -1,42 +0,0 @@ -const { Transform } = require("stream"); -const logger = require("../system/logger").create("adapter/json"); - -module.exports = (options) => { - - const encode = new Transform({ - transform(chunk, encoding, cb) { - - let data = JSON.stringify(chunk); - - logger.verbose("[encode] %d", data); - - this.push(data); - - cb(); - - }, - ...options - }); - - const decode = new Transform({ - transform(chunk, encoding, cb) { - - let data = JSON.parse(chunk); - - logger.verbose("[decode] %d", data); - - this.push(data); - - cb(); - - }, - ...options - - }); - - return { - encode, - decode - }; - -}; \ No newline at end of file diff --git a/backend.service b/backend.service new file mode 100644 index 0000000..6d163d2 --- /dev/null +++ b/backend.service @@ -0,0 +1,17 @@ +[Unit] +Description=OpenHaus Backend +Wants=network-online.target +After=network-online.target + +[Service] +ExecStart=/usr/bin/node /opt/OpenHaus/backend/index.js +WorkingDirectory=/opt/OpenHaus/backend +Restart=always +RestartSec=10 +Environment=NODE_ENV=production +Environment=VAULT_MASTER_PASSWORD=Pa$$w0rd +Environment=USERS_JWT_SECRET=Pa$$w0rd +Environment=UUID=00000000-0000-0000-0000-000000000000 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/components/devices/class.interface.js b/components/devices/class.interface.js index 985ad3b..aff2909 100644 --- a/components/devices/class.interface.js +++ b/components/devices/class.interface.js @@ -87,7 +87,7 @@ module.exports = class Interface { is: "SERIAL", then: SERIAL }), - adapter: Joi.array().items("base64", "eiscp", "json", "raw").default(["raw"]), + adapter: Joi.array().items("eiscp", "raw").default(["raw"]), description: Joi.string().allow(null).default(null) }); @@ -108,7 +108,7 @@ module.exports = class Interface { options = Object.assign({ keepAlive: true, - maxSockets: 1, + //maxSockets: 1, keepAliveMsecs: 3000, // use this as websocket ping/pong value to detect broken connections? }, options); diff --git a/components/mdns/class.mdns.js b/components/mdns/class.mdns.js new file mode 100644 index 0000000..19efe27 --- /dev/null +++ b/components/mdns/class.mdns.js @@ -0,0 +1,23 @@ +class MDNS { + + constructor(obj) { + + Object.assign(this, obj); + this._id = String(obj._id); + + Object.defineProperty(this, "_matches", { + value: [], + writable: false, + configurable: false, + enumerable: false + }); + + } + + match(cb) { + this._matches.push(cb); + } + +} + +module.exports = MDNS; \ No newline at end of file diff --git a/components/mdns/index.js b/components/mdns/index.js new file mode 100644 index 0000000..5435dd4 --- /dev/null +++ b/components/mdns/index.js @@ -0,0 +1,90 @@ +const mongodb = require("mongodb"); +const Joi = require("joi"); + +//const logger = require("../../system/logger").create("rooms"); +//const COMMON_COMPONENT = require("../../system/component/common.js"); +const COMPONENT = require("../../system/component/class.component.js"); + +const MDNS = require("./class.mdns.js"); + +const messageHandler = require("./message-handler.js"); + +/** + * @description + * Listen for mdns message and sends query requests.
+ * This requires the "connector". + * + * The emitted message events is the parsed data received on the underlaying udp socket. + * + * @class C_MDNS + * @extends COMPONENT system/component/class.component.js + * + * @emits message Received message on udp socket; Arguments: [0]=parsed dns packet, [1]=raw udp message + * + * @link router.api.mdns.js routes/router.api.mdns.js + * @see https://en.wikipedia.org/wiki/Multicast_DNS + * @see https://www.npmjs.com/package/dns-packet + */ +class C_MDNS extends COMPONENT { + + constructor() { + + // inject logger, collection and schema object + super("mdns", { + _id: Joi.string().pattern(/^[0-9a-fA-F]{24}$/).default(() => { + return String(new mongodb.ObjectId()); + }), + name: Joi.string().required(), + type: Joi.string().valid("SRV", "PTR", "A", "AAAA").default("A"), + timestamps: { + announced: Joi.number().allow(null).default(null) + } + }, module); + + this.hooks.post("add", (data, next) => { + next(null, new MDNS(data)); + }); + + this.collection.createIndex({ + name: 1, + type: 1 + }, { + unique: true + }); + + // handle incoming messages + // triggers registerd callback for mdns items + messageHandler(this); + + } + +} + + +// create component instance +const instance = module.exports = new C_MDNS(); + + +// init component +// set items/build cache +instance.init((scope, ready) => { + scope.collection.find({}).toArray((err, data) => { + if (err) { + + // shit... + ready(err); + + } else { + + data = data.map((obj) => { + return new MDNS(obj); + }); + + scope.items.push(...data); + + // init done + ready(null); + + } + }); +}); \ No newline at end of file diff --git a/components/mdns/message-handler.js b/components/mdns/message-handler.js new file mode 100644 index 0000000..20d3dcc --- /dev/null +++ b/components/mdns/message-handler.js @@ -0,0 +1,101 @@ +const { encode, RECURSION_DESIRED } = require("dns-packet"); + +module.exports = (scope) => { + scope._ready(({ logger }) => { + + // proceed mdns item instance + // deconstruct properties + let matchCallbacks = scope.items.map(({ name, type, _matches }) => { + return { + name, + type, + _matches + }; + }); + + + // listen for newly added items + scope.events.on("added", ({ name, type, _matches }) => { + matchCallbacks.push({ + name, + type, + _matches + }); + }); + + + scope.events.on("connected", (ws) => { + + let questions = scope.items.map(({ type, name }) => { + return { + type, + name + }; + }); + + let query = encode({ + type: "query", + id: 1, + flags: RECURSION_DESIRED, + questions + }); + + logger.debug("Connected, send query", query, questions); + + ws.send(query); + + }); + + + scope.events.on("message", (packet, message) => { + + // feedback + logger.trace("Message received on udp socket, record:", packet, "message: ", message); + + if (packet.type === "response") { + packet.answers.forEach((record) => { + matchCallbacks.forEach(async ({ name, type, _matches }, i) => { + + // create regex from db data + // allow wildcards writen as * in items + name = name.replace(/\./, "\\."); + name = name.replace("*", ".*"); + let rexp = new RegExp(name); + + if (type === record.type && rexp.test(record.name)) { + + logger.verbose("Matching recourd found", record, name, type); + + let { timestamps, _id } = scope.items[i]; + timestamps.announced = Date.now(); + + // update mdns item timestamps + await scope.update(_id, { + timestamps: { + ...timestamps + } + }); + + _matches.forEach((cb) => { + cb(record); + }); + + } else { + + // Do nothing if nothing matches + //console.log(">> NON << MAchting record type"); + + } + + }); + }); + } else { + + logger.trace("Other packet type then response received", packet.type); + + } + + }); + + }); +}; \ No newline at end of file diff --git a/components/mqtt/class.mqtt.js b/components/mqtt/class.mqtt.js new file mode 100644 index 0000000..ecafe25 --- /dev/null +++ b/components/mqtt/class.mqtt.js @@ -0,0 +1,54 @@ +/** + * @description + * Represents a mqtt topic item + * + * @class MQTT + * + * @param {Object} obj Object that matches the item schema. See properties below: + * + * @property {String} _id MongoDB Object is as string + * @property {String} topic MQTT topic e.g. `air-sensor/sensor/particulate_matter_25m_concentration/state` + * @property {String} description Description for Admins/Topic + */ +class MQTT { + + constructor(obj) { + + Object.assign(this, obj); + this._id = String(obj._id); + + Object.defineProperty(this, "_subscriber", { + value: [], + writable: false, + configurable: false, + enumerable: false + }); + + Object.defineProperty(this, "_publisher", { + value: () => { }, + writable: true, + configurable: true, + enumerable: false + }); + + } + + /** + * Subscribe to this topic + * @param {Function} cb Callback + */ + subscribe(cb) { + this._subscriber.push(cb); + } + + /** + * Publish data on this topic + * @param {*} data Payload + */ + publish(data) { + this._publisher(data); + } + +} + +module.exports = MQTT; \ No newline at end of file diff --git a/components/mqtt/exit-codes.js b/components/mqtt/exit-codes.js new file mode 100644 index 0000000..4637797 --- /dev/null +++ b/components/mqtt/exit-codes.js @@ -0,0 +1,61 @@ +// https://mosquitto.org/man/mosquitto_passwd-1.html#idm102 + +module.exports = (version = 3) => { + if (version >= 5) { + + return { + 0: "Success", + 128: "Unspecified error", + 129: "Malformed packet", + 130: "Protocol error", + 131: "Implementation specific error", + 132: "Unsupported protocol version", + 133: "Client ID not valid", + 134: "Bad username or password", + 135: "Not authorized", + 136: "Server unavailable", + 137: "Server busy", + 138: "Banned", + 139: "Server shutting down", + 140: "Bad authentication method", + 141: "Keep alive timeout", + 142: "Session taken over", + 143: "Topic filter invalid", + 144: "Topic name invalid", + 145: "", // unused + 146: "", // unused + 147: "Receive maximum exceeded", + 148: "Topic alias invalid", + 149: "Packet too large", + 150: "", // unused + 151: "Quota exceeded", + 152: "Administrative action", + 153: "Payload format invalid", + 154: "Retain not supported", + 155: "QoS not supported", + 156: "Use another server", + 157: "Server moved", + 158: "Shared subscriptions not supported", + 159: "Connection rate exceeded", + 160: "Maximum connect time", + 161: "Subscription IDs not supported", + 162: "Wildcard subscriptions not supported", + }; + + } else if (version >= 3) { + + return { + 0: "Success", + 1: "Connection refused: Bad protocol version", + 2: "Connection refused: Identifier rejected", + 3: "Connection refused: Identifier rejected", + 4: "Connection refused: Bad username/password", + 5: "Connection refused: Not authorized" + }; + + } else { + + throw new Error(`Unsupported protocol version "${version}"`); + + } +}; \ No newline at end of file diff --git a/components/mqtt/index.js b/components/mqtt/index.js new file mode 100644 index 0000000..10deecd --- /dev/null +++ b/components/mqtt/index.js @@ -0,0 +1,106 @@ +const mongodb = require("mongodb"); +const Joi = require("joi"); + +//const logger = require("../../system/logger").create("rooms"); +//const COMMON_COMPONENT = require("../../system/component/common.js"); +const COMPONENT = require("../../system/component/class.component.js"); + +const MQTT = require("./class.mqtt.js"); + +const messageHandler = require("./message-handler.js"); +//const packetHandler = require("./packet-handler.js"); + +/** + * @description + * Receives MQTT messages from broker.
+ * It can publish and subscribe to topics. + * The emitted events are a mix from mqtt & custom ones. + * + * NOTE: Currenlty no authentication is possible. + * + * @class C_MQTT + * @extends COMPONENT system/component/class.component.js + * + * @emits message Received message over websocket/tcp connection; Arguments: [0] = tcp message + * @emits connect + * @emits connack + * @emits subscribe + * @emits suback + * @emits unsubscribe + * @emits unsuback + * @emits publish Something was published; Arguments: [0] = payload (buffer); [1] = parsed packet + * @emits puback + * @emits pubrec + * @emits pubrel + * @emits pubcomp + * @emits pingreq Ping request + * @emits pingresp Ping response + * @emits disconnect + * @emits auth + * @emits connected When websocket connected; Arguments: [0] = WebSocket client object "ws" + * @emits disconnected When websocket disconnected; Arguments: [0] = WebSocket client object "ws" + * + * @link router.api.mqtt.js routes/router.api.mqtt.js + * @see https://en.wikipedia.org/wiki/MQTT + * @see https://www.npmjs.com/package/mqtt-packet + */ +class C_MQTT extends COMPONENT { + + constructor() { + + // inject logger, collection and schema object + super("mqtt", { + _id: Joi.string().pattern(/^[0-9a-fA-F]{24}$/).default(() => { + return String(new mongodb.ObjectId()); + }), + topic: Joi.string().required(), + description: Joi.string().allow(null).default(null) + }, module); + + this.hooks.post("add", (data, next) => { + next(null, new MQTT(data)); + }); + + this.collection.createIndex({ + topic: 1 + }, { + unique: true + }); + + // handle incoming messages + // triggers registerd callback for mdns items + messageHandler(this); + //packetHandler(this); + + } + +} + + +// create component instance +const instance = module.exports = new C_MQTT(); + + +// init component +// set items/build cache +instance.init((scope, ready) => { + scope.collection.find({}).toArray((err, data) => { + if (err) { + + // shit... + ready(err); + + } else { + + data = data.map((obj) => { + return new MQTT(obj); + }); + + scope.items.push(...data); + + // init done + ready(null); + + } + }); +}); \ No newline at end of file diff --git a/components/mqtt/message-handler.js b/components/mqtt/message-handler.js new file mode 100644 index 0000000..7800041 --- /dev/null +++ b/components/mqtt/message-handler.js @@ -0,0 +1,151 @@ +const crypto = require("crypto"); +const mqtt = require("mqtt-packet"); + +const VERSION = Number(process.env.MQTT_BROKER_VERSION); + +const parser = mqtt.parser({ + protocolVersion: VERSION +}); + +const exitCodes = require("./exit-codes.js")(VERSION); + +module.exports = (scope) => { + scope._ready(({ logger, events }) => { + + // ping timer + let interval = null; + + events.on("publish", (packet) => { + scope.items.forEach(({ topic, _subscriber }) => { + + if (String(packet.topic).startsWith(topic) || packet.topic === topic) { + _subscriber.forEach((cb) => { + cb(packet.payload, packet); + }); + } + + }); + }); + + + events.on("connected", (ws) => { + + logger.debug("TCP socket connected to broker"); + + events.once("disconnected", () => { + clearInterval(interval); + logger.trace("Ping interval cleared"); + }); + + // TODO make this object configurable + let data = mqtt.generate({ + cmd: "connect", + protocolId: "MQTT", // Or "MQIsdp" in MQTT 3.1 and 5.0 + protocolVersion: VERSION, // Or 3 in MQTT 3.1, or 5 in MQTT 5.0 + clean: true, // Can also be false + clientId: process.env.MQTT_CLIENT_ID, + keepalive: 10, // Seconds which can be any positive number, with 0 as the default setting + /* + will: { + topic: "mydevice/test", + payload: Buffer.from("2134f"), // Payloads are buffers + + } + */ + }); + + ws.send(data); + + events.once("connack", (packet) => { + if (packet.returnCode === 0) { + + events.once("suback", () => { + + logger.debug("Subscribed to topic #"); + + let ping = mqtt.generate({ + cmd: "pingreq" + }); + + interval = setInterval(() => { + ws.send(ping); + }, Number(process.env.MQTT_PING_INTERVAL)); + + // monkey patch publisher function + scope.items.forEach((item) => { + item._publisher = (payload) => { + + scope.logger.verbose(`Publish on topic ${item.topic}`, payload); + + let pub = mqtt.generate({ + cmd: "publish", + messageId: crypto.randomInt(0, 65535), + qos: 0, + dup: false, + topic: item.topic, + payload: Buffer.from(`${payload}`), + retain: false + }); + + ws.send(pub); + + }; + }); + + }); + + let sub = mqtt.generate({ + cmd: "subscribe", + messageId: crypto.randomInt(0, 65535), + /* + properties: { // MQTT 5.0 properties + subscriptionIdentifier: 145, + userProperties: { + test: "shellies" + } + }, + */ + subscriptions: [{ + topic: "#", + qos: 0, + nl: false, // no Local MQTT 5.0 flag + rap: true, // Retain as Published MQTT 5.0 flag + rh: 1 // Retain Handling MQTT 5.0 + }] + }); + + ws.send(sub); + + } + }); + + }); + + + parser.on("packet", (packet) => { + + logger.verbose("Packet received", packet); + + if (packet.cmd === "connack") { + if (packet.returnCode == 0) { + + logger.debug("Connected to broker"); + + } else { + + logger.warn(`Could not connecto to broker: "${exitCodes[packet.returnCode]}"`); + + } + } + + events.emit(packet.cmd, packet); + + }); + + + events.on("message", (message) => { + parser.parse(message); + }); + + }); +}; \ No newline at end of file diff --git a/components/plugins/class.plugin.js b/components/plugins/class.plugin.js index 335413b..1b2129e 100644 --- a/components/plugins/class.plugin.js +++ b/components/plugins/class.plugin.js @@ -29,10 +29,10 @@ class Plugin { } /** - * @function boot - * Start/boot installed plugin + * @function start + * Start installed plugin */ - boot() { + start() { if (this.enabled) { let plugin = path.resolve(process.cwd(), "plugins", this.uuid); @@ -116,6 +116,13 @@ class Plugin { } } + /* + stop(){ + // TODO: Implement + // When plugins run in seperate worker process + } + */ + } module.exports = Plugin; \ No newline at end of file diff --git a/components/plugins/index.js b/components/plugins/index.js index bbc0538..77153bf 100644 --- a/components/plugins/index.js +++ b/components/plugins/index.js @@ -47,7 +47,7 @@ class C_PLUGINS extends COMPONENT { //runlevel: Joi.number().min(0).max(2).default(0), autostart: Joi.boolean().default(true), enabled: Joi.boolean().default(true), - intents: Joi.array().items("devices", "endpoints", "plugins", "rooms", "ssdp", "store", "users", "vault").required() + intents: Joi.array().items("devices", "endpoints", "plugins", "rooms", "ssdp", "store", "users", "vault", "mqtt", "mdns", "webhooks").required() }, module); this.hooks.post("add", (data, next) => { diff --git a/components/webhooks/class.webhook.js b/components/webhooks/class.webhook.js new file mode 100644 index 0000000..331c156 --- /dev/null +++ b/components/webhooks/class.webhook.js @@ -0,0 +1,45 @@ +/** + * @description + * Represents a webhook item + * + * @class Webhook + * + * @param {Object} obj Object that matches the item schema. See properties below: + * + * @property {String} _id MongoDB Object is as string + * @property {String} name Webhook name + */ +module.exports = class Webhook { + + constructor(obj) { + + Object.assign(this, obj); + this._id = String(obj._id); + + Object.defineProperty(this, "_handler", { + value: [], + configurable: false, + enumerable: false, + writable: false + }); + + Object.defineProperty(this, "_trigger", { + value: (body, query) => { + + this._handler.forEach((cb) => { + cb(body, query); + }); + + }, + enumerable: false, + configurable: false, + writable: false + }); + + } + + handle(cb) { + this._handler.push(cb); + } + +}; \ No newline at end of file diff --git a/components/webhooks/index.js b/components/webhooks/index.js new file mode 100644 index 0000000..b5f6b10 --- /dev/null +++ b/components/webhooks/index.js @@ -0,0 +1,62 @@ +const mongodb = require("mongodb"); +const Joi = require("joi"); + +//const logger = require("../../system/logger").create("rooms"); +//const COMMON_COMPONENT = require("../../system/component/common.js"); +const COMPONENT = require("../../system/component/class.component.js"); + +const Webhook = require("./class.webhook.js"); + +/** + * @description + * Implement webhook functionality + * + * @class C_WEBHOOKS + * @extends COMPONENT system/component/class.component.js + */ +class C_WEBHOOKS extends COMPONENT { + constructor() { + + // inject logger, collection and schema object + super("webhooks", { + _id: Joi.string().pattern(/^[0-9a-fA-F]{24}$/).default(() => { + return String(new mongodb.ObjectId()); + }), + name: Joi.string().required() + }, module); + + this.hooks.post("add", (data, next) => { + next(null, new Webhook(data)); + }); + + } +} + + +// create component instance +const instance = module.exports = new C_WEBHOOKS(); + + +// init component +// set items/build cache +instance.init((scope, ready) => { + scope.collection.find({}).toArray((err, data) => { + if (err) { + + // shit... + ready(err); + + } else { + + data = data.map((obj) => { + return new Webhook(obj); + }); + + scope.items.push(...data); + + // init done + ready(null); + + } + }); +}); \ No newline at end of file diff --git a/helper/merge.js b/helper/merge.js index f816857..bfe03dd 100644 --- a/helper/merge.js +++ b/helper/merge.js @@ -18,9 +18,16 @@ function merge(dst, src) { let sourceArray = dst[key] || []; res[key] = cur.map((valObj, index) => { + + // fix #295 + if (!(typeof valObj === "object")) { + return valObj; + } + // this is simply merging on index, but if you wanted a "smarter" merge, you could look up // the dst by a specific key with sourceArray.find(...) return merge(sourceArray[index] || {}, valObj); + }); } else if (typeof cur === "object" && cur !== null) { diff --git a/index.js b/index.js index 8109e2e..4a53e58 100644 --- a/index.js +++ b/index.js @@ -56,7 +56,10 @@ process.env = Object.assign({ VAULT_SALT_BYTE_LEN: "16", USERS_BCRYPT_SALT_ROUNDS: "12", USERS_JWT_SECRET: "", - USERS_JWT_ALGORITHM: "HS384" + USERS_JWT_ALGORITHM: "HS384", + MQTT_BROKER_VERSION: "3", + MQTT_CLIENT_ID: "OpenHaus", + MQTT_PING_INTERVAL: "5000" }, env.parsed, process.env); @@ -201,7 +204,10 @@ const init_components = () => { "ssdp", "store", "users", - "vault" + "vault", + "webhooks", + "mqtt", + "mdns" ].sort(() => { // pseudo randomize start/init of components @@ -461,7 +467,7 @@ const starter = new Promise((resolve) => { logger.verbose(`Start plugin "${plugin.name}" (${plugin.uuid})`); - plugin.boot(); + plugin.start(); started += 1; @@ -480,4 +486,4 @@ const starter = new Promise((resolve) => { logger.info("Startup complete"); -}); \ No newline at end of file +}); diff --git a/package-lock.json b/package-lock.json index 5b1e434..7c675f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "backend", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "backend", - "version": "1.0.0", + "version": "2.0.0", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -14,11 +14,13 @@ "body-parser": "^1.20.1", "colors": "^1.4.0", "dateformat": "^4.6.3", + "dns-packet": "^5.4.0", "dotenv": "^16.0.3", "express": "^4.18.2", "joi": "^17.6.4", "jsonwebtoken": "^9.0.0", "mongodb": "^4.11.0", + "mqtt-packet": "^8.1.2", "uuid": "^9.0.0", "ws": "^8.10.0" }, @@ -1160,6 +1162,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -1249,9 +1256,9 @@ } }, "node_modules/@sideway/formula": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz", - "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" }, "node_modules/@sideway/pinpoint": { "version": "2.0.0", @@ -1691,6 +1698,39 @@ "node": ">=8" } }, + "node_modules/bl": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "dependencies": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bluebird": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", @@ -2259,6 +2299,17 @@ "node": ">=8" } }, + "node_modules/dns-packet": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.4.0.tgz", + "integrity": "sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g==", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -4683,6 +4734,37 @@ "node": ">=12" } }, + "node_modules/mqtt-packet": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-8.1.2.tgz", + "integrity": "sha512-vL1YTct+TAy0PqX3Jv8jM3JMzObH6vC/lyA0I5LtD4xvydOdIdmofrSp12PE3jajiIOUaW3XxmQekbyToXpsSw==", + "dependencies": { + "bl": "^5.0.0", + "debug": "^4.1.1", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt-packet/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mqtt-packet/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -5588,8 +5670,7 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -7726,6 +7807,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" + }, "@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -7797,9 +7883,9 @@ } }, "@sideway/formula": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz", - "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" }, "@sideway/pinpoint": { "version": "2.0.0", @@ -8156,6 +8242,27 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, + "bl": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "requires": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + } + } + }, "bluebird": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", @@ -8574,6 +8681,14 @@ "path-type": "^4.0.0" } }, + "dns-packet": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.4.0.tgz", + "integrity": "sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g==", + "requires": { + "@leichtgewicht/ip-codec": "^2.0.1" + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -10447,6 +10562,31 @@ } } }, + "mqtt-packet": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-8.1.2.tgz", + "integrity": "sha512-vL1YTct+TAy0PqX3Jv8jM3JMzObH6vC/lyA0I5LtD4xvydOdIdmofrSp12PE3jajiIOUaW3XxmQekbyToXpsSw==", + "requires": { + "bl": "^5.0.0", + "debug": "^4.1.1", + "process-nextick-args": "^2.0.1" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -11139,8 +11279,7 @@ "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "proxy-addr": { "version": "2.0.7", diff --git a/package.json b/package.json index d769021..8834647 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "backend", "private": true, - "version": "2.0.0", + "version": "2.1.0", "description": "", "main": "index.js", "scripts": { @@ -18,7 +18,8 @@ "release": "grunt release", "db:export": "mongodump --db OpenHaus --archive=./demo-database.gz", "db:import": "mongorestore --db OpenHaus --archive=./demo-database.gz", - "postinstall": "scripts/post-install.sh" + "postinstall": "scripts/post-install.sh", + "publish": "grunt publish" }, "engines": { "node": ">=0.16.0" @@ -30,11 +31,13 @@ "body-parser": "^1.20.1", "colors": "^1.4.0", "dateformat": "^4.6.3", + "dns-packet": "^5.4.0", "dotenv": "^16.0.3", "express": "^4.18.2", "joi": "^17.6.4", "jsonwebtoken": "^9.0.0", "mongodb": "^4.11.0", + "mqtt-packet": "^8.1.2", "uuid": "^9.0.0", "ws": "^8.10.0" }, diff --git a/postman.json b/postman.json index 327ead9..76e6e11 100644 --- a/postman.json +++ b/postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "7c990139-b42a-4603-9c76-841a6cda01aa", + "_postman_id": "87196fff-8d06-44e2-a0ce-626fa3a54a1b", "name": "OpenHaus", "description": "SmartHome/IoT application", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" @@ -328,6 +328,978 @@ } ] }, + { + "name": "Webhooks", + "item": [ + { + "name": "Create new webhook", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"status code: 200\", () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Check room name: input = output\", () => {", + "", + " let res = pm.response.json();", + " let req = JSON.parse(pm.request.body);", + "", + " pm.expect(res.name).to.eql(req.name);", + "", + "});", + "", + "pm.test(\"Check properties\", () => {", + "", + " let res = pm.response.json();", + "", + " let props = [", + " \"name\", \"timestamps\", \"_id\",", + " \"number\", \"floor\", \"icon\"", + " ];", + "", + " Object.keys(res).forEach((key) => {", + " pm.expect(props.includes(key)).to.be.true;", + " });", + "", + " props.forEach((item) => {", + " pm.expect(Object.prototype.hasOwnProperty.call(res, item)).to.be.true;", + " });", + "", + "})" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Triggerd from Shelly i3\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/webhooks", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "webhooks" + ] + } + }, + "response": [] + }, + { + "name": "Get all webhooks", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"The response has all properties\", () => {", + " let json = pm.response.json();", + " pm.expect(json).to.have.lengthOf(json.length);", + "});", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.response.to.have.status(200);", + "});", + "", + "console.log(\"Fooo\")" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "x-auth-token", + "value": "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImhhbnMuaHViZXJ0QGV4YW1wbGUuY29tIiwidXVpZCI6ImM3N2E3NjJkLWM4ODYtNGQ2My1iNGM1LWU0MDJhZGNmYTdiZSIsImlhdCI6MTY1NDI2ODI4NX0.w4mkvTuJ-OXzTcmvWhwIT84oOmo2399hSEfWGbA-9SUWndMWUiHvly1A7-kSV93e", + "type": "text", + "disabled": true + } + ], + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/webhooks", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "webhooks" + ] + } + }, + "response": [] + }, + { + "name": "Get sinlge webhook", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/webhooks/63e9fe420d172b3b39313175", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "webhooks", + "63e9fe420d172b3b39313175" + ] + } + }, + "response": [] + }, + { + "name": "Update existing webhook", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const HOST = pm.collectionVariables.get(\"HOST\");", + "const PORT = pm.collectionVariables.get(\"PORT\");", + "", + "//console.log(pm.request.url.toString())", + "", + "pm.sendRequest({", + " url: `http://${HOST}:${PORT}/api/rooms/`,", + " method: 'GET',", + "}, function (err, res) {", + " if(err){", + "", + " consle.error(err);", + "", + " }else {", + "", + " let data = res.json();", + " let key = Math.floor(Math.random()*data.length);", + " let item = data[key];", + "", + " pm.variables.set(\"_id\", item._id);", + "", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Garage\",\n \"icon\": \"fa-solid fa-warehouse\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/webhooks/63e9fe420d172b3b39313175", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "webhooks", + "63e9fe420d172b3b39313175" + ] + } + }, + "response": [] + }, + { + "name": "Delete exisiting room", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "console.log(\"_id varaible\", pm.variables.get(\"_id\"));" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "console.log(\"_id varaible\", pm.variables.get(\"_id\"));", + "", + "pm.test(\"status code: 200\", () => {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/webhooks/63e9fe420d172b3b39313175", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "webhooks", + "63e9fe420d172b3b39313175" + ] + } + }, + "response": [] + }, + { + "name": "Trigger webhook", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"and\": \"also\",\n \"json\": \"data\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/webhooks/63e9fe420d172b3b39313175/trigger?optional=query¶meter=can&be=given", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "webhooks", + "63e9fe420d172b3b39313175", + "trigger" + ], + "query": [ + { + "key": "optional", + "value": "query" + }, + { + "key": "parameter", + "value": "can" + }, + { + "key": "be", + "value": "given" + } + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ] + }, + { + "name": "MDNS", + "item": [ + { + "name": "Create mdns entry", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"status code: 200\", () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Check room name: input = output\", () => {", + "", + " let res = pm.response.json();", + " let req = JSON.parse(pm.request.body);", + "", + " pm.expect(res.name).to.eql(req.name);", + "", + "});", + "", + "pm.test(\"Check properties\", () => {", + "", + " let res = pm.response.json();", + "", + " let props = [", + " \"name\", \"timestamps\", \"_id\",", + " \"number\", \"floor\", \"icon\"", + " ];", + "", + " Object.keys(res).forEach((key) => {", + " pm.expect(props.includes(key)).to.be.true;", + " });", + "", + " props.forEach((item) => {", + " pm.expect(Object.prototype.hasOwnProperty.call(res, item)).to.be.true;", + " });", + "", + "})" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"shelly*.local\",\n \"type\": \"A\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/mdns", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "mdns" + ] + } + }, + "response": [] + }, + { + "name": "Get all mdns targets", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"The response has all properties\", () => {", + " let json = pm.response.json();", + " pm.expect(json).to.have.lengthOf(json.length);", + "});", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.response.to.have.status(200);", + "});", + "", + "console.log(\"Fooo\")" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "x-auth-token", + "value": "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImhhbnMuaHViZXJ0QGV4YW1wbGUuY29tIiwidXVpZCI6ImM3N2E3NjJkLWM4ODYtNGQ2My1iNGM1LWU0MDJhZGNmYTdiZSIsImlhdCI6MTY1NDI2ODI4NX0.w4mkvTuJ-OXzTcmvWhwIT84oOmo2399hSEfWGbA-9SUWndMWUiHvly1A7-kSV93e", + "type": "text", + "disabled": true + } + ], + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/mdns", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "mdns" + ] + } + }, + "response": [] + }, + { + "name": "Get sinlge mdns target", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/mdns/63e7f7ba26b161df7f3af1d6", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "mdns", + "63e7f7ba26b161df7f3af1d6" + ] + } + }, + "response": [] + }, + { + "name": "Update mdns target", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const HOST = pm.collectionVariables.get(\"HOST\");", + "const PORT = pm.collectionVariables.get(\"PORT\");", + "", + "//console.log(pm.request.url.toString())", + "", + "pm.sendRequest({", + " url: `http://${HOST}:${PORT}/api/rooms/`,", + " method: 'GET',", + "}, function (err, res) {", + " if(err){", + "", + " consle.error(err);", + "", + " }else {", + "", + " let data = res.json();", + " let key = Math.floor(Math.random()*data.length);", + " let item = data[key];", + "", + " pm.variables.set(\"_id\", item._id);", + "", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"labels\": [\n \"manufacturer=shelly\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/mdns/63e7f7ba26b161df7f3af1d6", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "mdns", + "63e7f7ba26b161df7f3af1d6" + ] + } + }, + "response": [] + }, + { + "name": "Delete mdns target", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "console.log(\"_id varaible\", pm.variables.get(\"_id\"));" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "console.log(\"_id varaible\", pm.variables.get(\"_id\"));", + "", + "pm.test(\"status code: 200\", () => {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/mdns/63e7f7ba26b161df7f3af1d6", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "mdns", + "63e7f7ba26b161df7f3af1d6" + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ] + }, + { + "name": "MQTT", + "item": [ + { + "name": "Create mqtt topic", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"status code: 200\", () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Check room name: input = output\", () => {", + "", + " let res = pm.response.json();", + " let req = JSON.parse(pm.request.body);", + "", + " pm.expect(res.name).to.eql(req.name);", + "", + "});", + "", + "pm.test(\"Check properties\", () => {", + "", + " let res = pm.response.json();", + "", + " let props = [", + " \"name\", \"timestamps\", \"_id\",", + " \"number\", \"floor\", \"icon\"", + " ];", + "", + " Object.keys(res).forEach((key) => {", + " pm.expect(props.includes(key)).to.be.true;", + " });", + "", + " props.forEach((item) => {", + " pm.expect(Object.prototype.hasOwnProperty.call(res, item)).to.be.true;", + " });", + "", + "})" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"topic\": \"air-sensor/sensor/particulate_matter_25m_concentration\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/mqtt", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "mqtt" + ] + } + }, + "response": [] + }, + { + "name": "Get all mqtt topics", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"The response has all properties\", () => {", + " let json = pm.response.json();", + " pm.expect(json).to.have.lengthOf(json.length);", + "});", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.response.to.have.status(200);", + "});", + "", + "console.log(\"Fooo\")" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "x-auth-token", + "value": "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImhhbnMuaHViZXJ0QGV4YW1wbGUuY29tIiwidXVpZCI6ImM3N2E3NjJkLWM4ODYtNGQ2My1iNGM1LWU0MDJhZGNmYTdiZSIsImlhdCI6MTY1NDI2ODI4NX0.w4mkvTuJ-OXzTcmvWhwIT84oOmo2399hSEfWGbA-9SUWndMWUiHvly1A7-kSV93e", + "type": "text", + "disabled": true + } + ], + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/mqtt", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "mqtt" + ] + } + }, + "response": [] + }, + { + "name": "Get sinlge mqtt topic", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/mqtt/63e8f7d2ab413a9760e9b08c", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "mqtt", + "63e8f7d2ab413a9760e9b08c" + ] + } + }, + "response": [] + }, + { + "name": "Update mqtt topic", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const HOST = pm.collectionVariables.get(\"HOST\");", + "const PORT = pm.collectionVariables.get(\"PORT\");", + "", + "//console.log(pm.request.url.toString())", + "", + "pm.sendRequest({", + " url: `http://${HOST}:${PORT}/api/rooms/`,", + " method: 'GET',", + "}, function (err, res) {", + " if(err){", + "", + " consle.error(err);", + "", + " }else {", + "", + " let data = res.json();", + " let key = Math.floor(Math.random()*data.length);", + " let item = data[key];", + "", + " pm.variables.set(\"_id\", item._id);", + "", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"description\": \"Ikea VINDRIKTNING MQTT modd\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/mqtt/63e8f7d2ab413a9760e9b08c", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "mqtt", + "63e8f7d2ab413a9760e9b08c" + ] + } + }, + "response": [] + }, + { + "name": "Delete mqtt topic", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "console.log(\"_id varaible\", pm.variables.get(\"_id\"));" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "console.log(\"_id varaible\", pm.variables.get(\"_id\"));", + "", + "pm.test(\"status code: 200\", () => {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "http://{{HOST}}:{{PORT}}/api/mqtt/63e8f7d2ab413a9760e9b08c", + "protocol": "http", + "host": [ + "{{HOST}}" + ], + "port": "{{PORT}}", + "path": [ + "api", + "mqtt", + "63e8f7d2ab413a9760e9b08c" + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ] + }, { "name": "SSDP", "item": [ @@ -1309,7 +2281,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"name\": \"AV - Receiver\"\n}", + "raw": " {\n \"_id\": \"63a5a4c2bd5fe7cb165960d0\",\n \"name\": \"Fernseher\",\n \"device\": \"63a5a4c2bd5fe7cb165960cd\",\n \"commands\": [\n {\n \"payload\": \"KEY_0\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_0\",\n \"name\": \"KEY_0\",\n \"_id\": \"63a5a4c2bd5fe7cb165960d1\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_1\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_1\",\n \"name\": \"KEY_1\",\n \"_id\": \"63a5a4c2bd5fe7cb165960d2\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_2\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_2\",\n \"name\": \"KEY_2\",\n \"_id\": \"63a5a4c2bd5fe7cb165960d3\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_3\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_3\",\n \"name\": \"KEY_3\",\n \"_id\": \"63a5a4c2bd5fe7cb165960d4\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_4\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_4\",\n \"name\": \"KEY_4\",\n \"_id\": \"63a5a4c2bd5fe7cb165960d5\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_5\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_5\",\n \"name\": \"KEY_5\",\n \"_id\": \"63a5a4c2bd5fe7cb165960d6\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_6\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_6\",\n \"name\": \"KEY_6\",\n \"_id\": \"63a5a4c2bd5fe7cb165960d7\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_7\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_7\",\n \"name\": \"KEY_7\",\n \"_id\": \"63a5a4c2bd5fe7cb165960d8\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_8\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_8\",\n \"name\": \"KEY_8\",\n \"_id\": \"63a5a4c2bd5fe7cb165960d9\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_9\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_9\",\n \"name\": \"KEY_9\",\n \"_id\": \"63a5a4c2bd5fe7cb165960da\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_10\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_10\",\n \"name\": \"KEY_10\",\n \"_id\": \"63a5a4c2bd5fe7cb165960db\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_11\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_11\",\n \"name\": \"KEY_11\",\n \"_id\": \"63a5a4c2bd5fe7cb165960dc\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_12\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_12\",\n \"name\": \"KEY_12\",\n \"_id\": \"63a5a4c2bd5fe7cb165960dd\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_CHDOWN\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_CHDOWN\",\n \"name\": \"KEY_CHDOWN\",\n \"_id\": \"63a5a4c2bd5fe7cb165960de\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_CHUP\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_CHUP\",\n \"name\": \"KEY_CHUP\",\n \"_id\": \"63a5a4c2bd5fe7cb165960df\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_VOLDOWN\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_VOLDOWN\",\n \"name\": \"KEY_VOLDOWN\",\n \"_id\": \"63a5a4c2bd5fe7cb165960e0\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_VOLUP\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_VOLUP\",\n \"name\": \"KEY_VOLUP\",\n \"_id\": \"63a5a4c2bd5fe7cb165960e1\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_MUTE\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_MUTE\",\n \"name\": \"KEY_MUTE\",\n \"_id\": \"63a5a4c2bd5fe7cb165960e2\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_SOURCE\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_SOURCE\",\n \"name\": \"KEY_SOURCE\",\n \"_id\": \"63a5a4c2bd5fe7cb165960e3\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_LEFT\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_LEFT\",\n \"name\": \"KEY_LEFT\",\n \"_id\": \"63a5a4c2bd5fe7cb165960e4\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_RIGHT\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_RIGHT\",\n \"name\": \"KEY_RIGHT\",\n \"_id\": \"63a5a4c2bd5fe7cb165960e5\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_UP\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_UP\",\n \"name\": \"KEY_UP\",\n \"_id\": \"63a5a4c2bd5fe7cb165960e6\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_DOWN\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_DOWN\",\n \"name\": \"KEY_DOWN\",\n \"_id\": \"63a5a4c2bd5fe7cb165960e7\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_ENTER\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_ENTER\",\n \"name\": \"KEY_ENTER\",\n \"_id\": \"63a5a4c2bd5fe7cb165960e8\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_MENU\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_MENU\",\n \"name\": \"KEY_MENU\",\n \"_id\": \"63a5a4c2bd5fe7cb165960e9\",\n \"identifier\": null,\n \"description\": null\n },\n {\n \"payload\": \"KEY_EXIT\",\n \"interface\": \"63a5a4c2bd5fe7cb165960ce\",\n \"alias\": \"KEY_EXIT\",\n \"name\": \"KEY_EXIT\",\n \"_id\": \"63a5a4c2bd5fe7cb165960ea\",\n \"identifier\": null,\n \"description\": null\n }\n ],\n \"timestamps\": {\n \"created\": 1671800002321,\n \"updated\": null\n },\n \"enabled\": true,\n \"room\": \"62a4bbf0d9256b5e8d69889c\",\n \"states\": [],\n \"identifier\": null,\n \"icon\": \"fa-solid fa-tv\"\n }", "options": { "raw": { "language": "json" @@ -1317,7 +2289,7 @@ } }, "url": { - "raw": "http://{{HOST}}:{{PORT}}/api/endpoints/6266c441e207ba2a3e3c9222", + "raw": "http://{{HOST}}:{{PORT}}/api/endpoints/63a5a4c2bd5fe7cb165960d0", "protocol": "http", "host": [ "{{HOST}}" @@ -1326,7 +2298,7 @@ "path": [ "api", "endpoints", - "6266c441e207ba2a3e3c9222" + "63a5a4c2bd5fe7cb165960d0" ] } }, @@ -2001,7 +2973,9 @@ "request": { "method": "GET", "header": [], - "url": null + "url": { + "raw": "" + } }, "response": [] } diff --git a/routes/index.js b/routes/index.js index 33129a5..cde8e32 100644 --- a/routes/index.js +++ b/routes/index.js @@ -10,6 +10,9 @@ const C_VAULT = require("../components/vault"); const C_SSDP = require("../components/ssdp"); const C_STORE = require("../components/store"); const C_USERS = require("../components/users"); +const C_WEBHOOKS = require("../components/webhooks"); +const C_MQTT = require("../components/mqtt"); +const C_MDNS = require("../components/mdns"); // Remove due to issue #273 //const { encode } = require("../helper/sanitize"); @@ -127,6 +130,9 @@ module.exports = (server) => { const ssdpRouter = express.Router(); const storeRouter = express.Router(); const usersRouter = express.Router(); + const webhooksRouter = express.Router(); + const mqttRouter = express.Router(); + const mdnsRouter = express.Router(); // http://127.0.0.1/api/plugins api.use("/plugins", pluginsRouter); @@ -176,6 +182,21 @@ module.exports = (server) => { require("./rest-handler.js")(C_USERS, usersRouter); //require("./router.api.users.js")(app, vaultRouter); + // http://127.0.0.1/api/webhooks + api.use("/webhooks", webhooksRouter); + require("./router.api.webhooks.js")(app, webhooksRouter); + require("./rest-handler.js")(C_WEBHOOKS, webhooksRouter); + + // http://127.0.0.1/api/mqtt + api.use("/mqtt", mqttRouter); + require("./router.api.mqtt.js")(app, mqttRouter); + require("./rest-handler.js")(C_MQTT, mqttRouter); + + // http://127.0.0.1/api/mdns + api.use("/mdns", mdnsRouter); + require("./router.api.mdns.js")(app, mdnsRouter); + require("./rest-handler.js")(C_MDNS, mdnsRouter); + // NOTE: Drop this?! api.use((req, res) => { res.status(404).end(); diff --git a/routes/router.api.mdns.js b/routes/router.api.mdns.js new file mode 100644 index 0000000..a5895d3 --- /dev/null +++ b/routes/router.api.mdns.js @@ -0,0 +1,107 @@ +const { decode } = require("dns-packet"); + +const C_MDNS = require("../components/mdns"); + +// external modules +const WebSocket = require("ws"); + +module.exports = (app, router) => { + + // websocket server + let wss = new WebSocket.Server({ + noServer: true + }); + + // detect broken connections + let interval = setInterval(() => { + wss.clients.forEach((ws) => { + + if (!ws.isAlive) { + ws.terminate(); + return; + } + + ws.isAlive = false; + ws.ping(); + + }); + }, Number(process.env.API_WEBSOCKET_TIMEOUT)); + + + // if the server closes + // clear the interval + wss.on("close", () => { + clearInterval(interval); + }); + + + // http route handler + router.get("/", (req, res, next) => { + + console.log("Request to /api/mdns"); + + // check if connection is a simple get request or ws client + if ((!req.headers["upgrade"] || !req.headers["connection"])) { + //return res.status(403).end(); + next(); // let the rest-handler.js do its job + return; + } + + // listen for websockt clients + // keep sending new log entrys to client + wss.once("connection", (ws) => { + + console.log("Clien connected to mdns"); + + C_MDNS.events.emit("connected", ws); + + ws.on("message", (msg) => { + C_MDNS.events.emit("message", decode(msg), msg); + }); + + ws.on("close", () => { + console.log("Client disconnected disolaskjdflaskjfdasdf"); + }); + + + // QUERY LOCAL DNS + /* + setInterval(() => { + + console.log("Query for HTTP Server"); + + let msg = encode({ + type: "query", + id: 1, + flags: RECURSION_DESIRED, + questions: [{ + type: "A", + //name: '_http._tcp.local' + name: "*" + }] + }); + + ws.send(msg); + + }, 30_000); + */ + + }); + + // handle request as websocket + // perform websocket handshake + wss.handleUpgrade(req, req.socket, req.headers, (ws) => { + + ws.isAlive = true; + + ws.on("pong", () => { + ws.isAlive = true; + }); + + wss.emit("connection", ws, req); + + }); + + }); + +}; \ No newline at end of file diff --git a/routes/router.api.mqtt.js b/routes/router.api.mqtt.js new file mode 100644 index 0000000..20a46a6 --- /dev/null +++ b/routes/router.api.mqtt.js @@ -0,0 +1,81 @@ +const C_MQTT = require("../components/mqtt"); + +// external modules +const WebSocket = require("ws"); + +module.exports = (app, router) => { + + // websocket server + let wss = new WebSocket.Server({ + noServer: true + }); + + // detect broken connections + let interval = setInterval(() => { + wss.clients.forEach((ws) => { + + if (!ws.isAlive) { + ws.terminate(); + return; + } + + ws.isAlive = false; + ws.ping(); + + }); + }, Number(process.env.API_WEBSOCKET_TIMEOUT)); + + + // if the server closes + // clear the interval + wss.on("close", () => { + clearInterval(interval); + }); + + + // http route handler + router.get("/", (req, res, next) => { + + console.log("Request to /ai/mqtt"); + + // check if connection is a simple get request or ws client + if ((!req.headers["upgrade"] || !req.headers["connection"])) { + //return res.status(403).end(); + next(); // let the rest-handler.js do its job + return; + } + + // listen for websockt clients + // keep sending new log entrys to client + wss.once("connection", (ws) => { + + C_MQTT.events.emit("connected", ws); + + ws.on("message", (msg) => { + C_MQTT.events.emit("message", msg); + }); + + ws.on("close", () => { + console.log("MQTT Client disconnected disolaskjdflaskjfdasdf"); + C_MQTT.events.emit("disconnected", ws); + }); + + }); + + // handle request as websocket + // perform websocket handshake + wss.handleUpgrade(req, req.socket, req.headers, (ws) => { + + ws.isAlive = true; + + ws.on("pong", () => { + ws.isAlive = true; + }); + + wss.emit("connection", ws, req); + + }); + + }); + +}; \ No newline at end of file diff --git a/routes/router.api.plugins.js b/routes/router.api.plugins.js index 4609b1d..2b5c653 100644 --- a/routes/router.api.plugins.js +++ b/routes/router.api.plugins.js @@ -59,4 +59,32 @@ module.exports = (app, router) => { } }); + router.post("/:_id/start", (req, res) => { + try { + + req.item.start(); + res.json(req.item); + + } catch (err) { + + res.status(500).end(err); + + } + }); + + /* + router.post("/:_id/stop", (req, res) => { + try { + + req.item.stop(); + res.json(req.item); + + } catch (err) { + + res.status(500).end(err); + + } + }); + */ + }; \ No newline at end of file diff --git a/routes/router.api.webhooks.js b/routes/router.api.webhooks.js new file mode 100644 index 0000000..a60171a --- /dev/null +++ b/routes/router.api.webhooks.js @@ -0,0 +1,38 @@ +const C_WEBHOOKS = require("../components/webhooks"); + + +module.exports = (app, router) => { + + router.param("_id", (req, res, next, _id) => { + C_WEBHOOKS.get(_id, (err, obj) => { + if (err) { + + res.status(400).json({ + error: err + }); + + } else { + + if (!obj) { + return res.status(404).end(); + } + + req.item = obj; + + next(); + + } + }); + }); + + router.all("/:_id/trigger", (req, res) => { + + //res.end(`Hello from webhook: ${req.method}, ${JSON.stringify(req.item)}`); + + req.item._trigger(req.body, req.query); + + res.status(202).end(); + + }); + +}; \ No newline at end of file diff --git a/system/component/class.component.js b/system/component/class.component.js index 95f9f20..5abc169 100644 --- a/system/component/class.component.js +++ b/system/component/class.component.js @@ -17,6 +17,10 @@ const PENDING_CHANGE_EVENTS = new Set(); * - remove * - find * + * Methods that are not hookable: + * - found + * - labels + * * @class COMPONENT * * @extends COMMON system/component/class.common.js @@ -24,6 +28,7 @@ const PENDING_CHANGE_EVENTS = new Set(); * @property {Array} items Store where instance of items are keept * @property {Object} collection MongoDB collection instance * @property {Object} schema Joi Object schema which is extend by a timestamp object: + * @property {Array} schema.labels Array that allow custom labels for identification * @property {Object} schema.timestamps Timestamps * @property {Number} schema.timestamps.created Set to `Date.now()` when a item is created/added * @property {Number} schema.timestamps.updated Set to `Date.now()` when a item is updated @@ -92,6 +97,9 @@ module.exports = class COMPONENT extends COMMON { this.schema = Joi.object({ ...schema, + //labels: Joi.array().items(Joi.string().regex(/^[a-zA-Z0-9]+=[a-zA-Z0-9]+$/)).default([]) + //labels: Joi.array().items(Joi.string().regex(/^[a-z0-9\.]+=[a-z0-9]+$/)).default([]), + labels: Joi.array().items(Joi.string().regex(/^[a-z0-9]+=[a-z0-9]+$/)).default([]), timestamps: Joi.object({ ...schema?.timestamps, created: Joi.number().allow(null).default(null), @@ -262,10 +270,17 @@ module.exports = class COMPONENT extends COMMON { */ this._defineMethod("add", (final) => { + let duplicate = false; + final((item) => { - //this.items.push(item); - items.push(item); + + // Fix #294 + if (!duplicate) { + items.push(item); + } + return Promise.resolve(); + }); return (data) => { @@ -316,6 +331,8 @@ module.exports = class COMPONENT extends COMMON { */ }); + duplicate = !!item; + // remove id when error occurs PENDING_CHANGE_EVENTS.delete(result.value._id); @@ -555,12 +572,13 @@ module.exports = class COMPONENT extends COMMON { */ this._defineMethod("find", () => { return (query) => { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { // https://javascript.plainenglish.io/4-ways-to-compare-objects-in-javascript-97fe9b2a949c // https://stackoverflow.com/a/1068883/5781499 // https://dmitripavlutin.com/how-to-compare-objects-in-javascript/ + /* let item = this.items.find((item) => { // for (let key of query) { ?! for (let key in Object.keys(query)) { @@ -579,6 +597,28 @@ module.exports = class COMPONENT extends COMMON { } resolve([item]); + */ + + // fix #306 + let item = this.items.find((item) => { + + let loop = (target, filter) => { + return Object.keys(filter).every((key) => { + + if (target[key] instanceof Object) { + return loop(target[key], filter[key]); + } else { + return target[key] === filter[key]; + } + + }); + }; + + return loop(item, query); + + }); + + resolve([item || null]); }); }; @@ -600,6 +640,8 @@ module.exports = class COMPONENT extends COMMON { * @param {Object} filter Object that matches the component schema * @param {Function} cb Callback function * @param {Function} [nothing] Function that is called when not matching item in array is found. Usefull to add then something, when its not found. + * + * @returns {Function} Cleanup. If you dont to get pdates/called again, call the "cleanup" function */ found(filter, cb, nothing) { @@ -643,6 +685,32 @@ module.exports = class COMPONENT extends COMMON { }; + + /* + // potential fix for #307 + // the problem is not the method, but the wildcard in labels + let handler = (filter, item) => { + + let loop = (target, filter) => { + return Object.keys(filter).every((key) => { + + if (target[key] instanceof Object) { + return loop(target[key], filter[key]); + } else { + return target[key] === filter[key]; + } + + }); + }; + + if (loop(item, filter)) { + matched = true; + cb(item); + } + + }; + */ + this.items.forEach((item) => { handler(filter, item); }); @@ -653,7 +721,6 @@ module.exports = class COMPONENT extends COMMON { }); } - // NOTE: Why is a array from the add eventlistener returned?! let ev = (item) => { handler(filter, item); }; @@ -676,6 +743,60 @@ module.exports = class COMPONENT extends COMMON { } + /** + * @function _labels + * Checks if filter array contains matching labels + * + * @param {Array} arr Array with item labels to check with filter + * @param {Array} filter Filter array + * + * @returns {Boolean} true/false if filter matches label array + */ + _labels(arr, filter) { + return filter.every((filter) => { + if (arr.includes(filter)) { + + return true; + + } else { + + let [key, value] = filter.split("="); + + return arr.some((label) => { + + let [k, v] = label.split("="); + + if (value === "*") { + return key === k; + } + + if (key === "*") { + return value === v; + } + + return false; + + }); + + } + }); + } + + + /** + * @function labels + * Retun all items that matches the givel label filter + * + * @param {Array} filter Filter for labels array + * + * @returns {Array} With all items that have matching labels + */ + labels(filter) { + return this.items.filter(({ labels }) => { + return this._labels(labels || [], filter); + }); + } + // /* _exportItemMethod(name, prop) { diff --git a/system/logger/index.js b/system/logger/index.js index bdb17cb..fedf82b 100644 --- a/system/logger/index.js +++ b/system/logger/index.js @@ -18,7 +18,9 @@ Object.defineProperty(logger, "create", { value: function create(name) { let file = path.resolve(process.env.LOG_PATH, `${name}.log`); - let stream = createWriteStream(file); + let stream = createWriteStream(file, { + flags: "a" + }); stream.on("error", (err) => { console.error(err); @@ -51,8 +53,12 @@ Object.defineProperty(logger, "create", { * ``` */ -const system = createWriteStream(path.resolve(process.env.LOG_PATH, "system.log")); -const combined = createWriteStream(path.resolve(process.env.LOG_PATH, "combined.log")); +const system = createWriteStream(path.resolve(process.env.LOG_PATH, "system.log"), { + flags: "a" +}); +const combined = createWriteStream(path.resolve(process.env.LOG_PATH, "combined.log"), { + flags: "a" +}); [system, combined].forEach((stream) => { stream.on("error", (err) => { @@ -106,7 +112,9 @@ Object.defineProperty(logger, "create", { }); } - let stream = createWriteStream(file); + let stream = createWriteStream(file, { + flags: "a" + }); stream.on("error", (err) => { console.error(err); diff --git a/tests/components/index.js b/tests/components/index.js index 1578c94..492d3ae 100644 --- a/tests/components/index.js +++ b/tests/components/index.js @@ -7,7 +7,7 @@ describe("Components", () => { [ "devices", "endpoints", "plugins", "rooms", "ssdp", "store", "users", - "vault" + "vault", "webhooks", "mdns", "mqtt" ].forEach((name) => { describe(name, () => { diff --git a/tests/components/mdns.js b/tests/components/mdns.js new file mode 100644 index 0000000..54dcf9f --- /dev/null +++ b/tests/components/mdns.js @@ -0,0 +1,132 @@ +const assert = require("assert"); +const mongodb = require("mongodb"); + +try { + + const C_COMPONENT = require(`../../components/mdns/index.js`); + const MDNS = require("../../components/mdns/class.mdns.js"); + + const workflow = require("./test.workflow.js"); + + let _id = String(new mongodb.ObjectId()); + + + workflow(C_COMPONENT, "add", (done, { event }) => { + C_COMPONENT.add({ + _id, + type: "A", + name: "shelly*.local" + }, (err, item) => { + try { + + // check event arguments + event.args.forEach((args) => { + assert.equal(args[0] instanceof MDNS, true); + }); + + assert.ok(err === null); + assert.equal(item instanceof MDNS, true); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + + workflow(C_COMPONENT, "get", (done) => { + C_COMPONENT.get(_id, (err, item) => { + try { + + assert.ok(err === null); + assert.equal(item instanceof MDNS, true); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + + workflow(C_COMPONENT, "update", (done) => { + C_COMPONENT.update(_id, { + type: "SRV" + }, (err, item) => { + try { + + assert.ok(err === null); + assert.equal(item instanceof MDNS, true); + assert.equal(item.name, "shelly*.local"); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + + workflow(C_COMPONENT, "update", "Double update result / event arguments check", (done, { event }) => { + Promise.all([ + + // update call 1 + C_COMPONENT.update(_id, { + name: `shelly*_tcp.local` + }), + + // update call 2 + C_COMPONENT.update(_id, { + name: `shelly*_tcp.local`, + type: "A" + }), + + ]).then(() => { + + event.args.forEach((args) => { + assert.equal(args[0] instanceof MDNS, true); + }); + + done(); + + }).catch(done); + }); + + + workflow(C_COMPONENT, "remove", (done, { post }) => { + C_COMPONENT.remove(_id, (err, item) => { + try { + + // check post arguments item instance + post.args.forEach((args) => { + assert.equal(args[0] instanceof MDNS, true); + }); + + assert.ok(err === null); + assert.equal(item instanceof MDNS, true); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + +} catch (err) { + console.error(err); + process.exit(100); +} \ No newline at end of file diff --git a/tests/components/mqtt.js b/tests/components/mqtt.js new file mode 100644 index 0000000..84a6ef2 --- /dev/null +++ b/tests/components/mqtt.js @@ -0,0 +1,139 @@ +const assert = require("assert"); +const mongodb = require("mongodb"); + +try { + + const C_COMPONENT = require(`../../components/mqtt/index.js`); + const MQTT = require("../../components/mqtt/class.mqtt.js"); + + const workflow = require("./test.workflow.js"); + + let _id = String(new mongodb.ObjectId()); + + + workflow(C_COMPONENT, "add", (done, { event }) => { + C_COMPONENT.add({ + _id, + topic: "air-sensor/", + }, (err, item) => { + try { + + // check event arguments + event.args.forEach((args) => { + assert.equal(args[0] instanceof MQTT, true); + }); + + assert.ok(err === null); + assert.equal(item instanceof MQTT, true); + assert.ok(item._publisher); + assert.ok(item._subscriber); + assert.equal(item.subscribe instanceof Function, true); + assert.equal(item.publish instanceof Function, true); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + + workflow(C_COMPONENT, "get", (done) => { + C_COMPONENT.get(_id, (err, item) => { + try { + + assert.ok(err === null); + assert.equal(item instanceof MQTT, true); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + + workflow(C_COMPONENT, "update", (done) => { + C_COMPONENT.update(_id, { + topic: "air-sensor/sensor/particulate_matter_25m_concentration/state" + }, (err, item) => { + try { + + assert.ok(err === null); + assert.equal(item instanceof MQTT, true); + assert.equal(item.topic, "air-sensor/sensor/particulate_matter_25m_concentration/state"); + assert.ok(item._publisher); + assert.ok(item._subscriber); + assert.equal(item.subscribe instanceof Function, true); + assert.equal(item.publish instanceof Function, true); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + + workflow(C_COMPONENT, "update", "Double update result / event arguments check", (done, { event }) => { + Promise.all([ + + // update call 1 + C_COMPONENT.update(_id, { + topic: `air-sensor/status` + }), + + // update call 2 + C_COMPONENT.update(_id, { + topic: `air-sensor/`, + description: "Ikea VINDRIKTNING Air sensor MQTT topic" + }), + + ]).then(() => { + + event.args.forEach((args) => { + assert.equal(args[0] instanceof MQTT, true); + }); + + done(); + + }).catch(done); + }); + + + workflow(C_COMPONENT, "remove", (done, { post }) => { + C_COMPONENT.remove(_id, (err, item) => { + try { + + // check post arguments item instance + post.args.forEach((args) => { + assert.equal(args[0] instanceof MQTT, true); + }); + + assert.ok(err === null); + assert.equal(item instanceof MQTT, true); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + +} catch (err) { + console.error(err); + process.exit(100); +} \ No newline at end of file diff --git a/tests/components/webhooks.js b/tests/components/webhooks.js new file mode 100644 index 0000000..d9173b1 --- /dev/null +++ b/tests/components/webhooks.js @@ -0,0 +1,134 @@ +const assert = require("assert"); +const mongodb = require("mongodb"); + +try { + + const C_COMPONENT = require(`../../components/webhooks/index.js`); + const Webhook = require("../../components/webhooks/class.webhook.js"); + + const workflow = require("./test.workflow.js"); + + let _id = String(new mongodb.ObjectId()); + + + workflow(C_COMPONENT, "add", (done, { event }) => { + C_COMPONENT.add({ + _id, + name: "Webhook #1", + }, (err, item) => { + try { + + // check event arguments + event.args.forEach((args) => { + assert.equal(args[0] instanceof Webhook, true); + }); + + assert.ok(err === null); + assert.equal(item instanceof Webhook, true); + assert.equal(item._handler instanceof Array, true); + assert.equal(item._trigger instanceof Function, true); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + + workflow(C_COMPONENT, "get", (done) => { + C_COMPONENT.get(_id, (err, item) => { + try { + + assert.ok(err === null); + assert.equal(item instanceof Webhook, true); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + + workflow(C_COMPONENT, "update", (done) => { + C_COMPONENT.update(_id, { + name: "Webhook #1 - updated", + }, (err, item) => { + try { + + assert.ok(err === null); + assert.equal(item instanceof Webhook, true); + assert.equal(item._handler instanceof Array, true); + assert.equal(item._trigger instanceof Function, true); + assert.equal(item.name, "Webhook #1 - updated"); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + + workflow(C_COMPONENT, "update", "Double update result / event arguments check", (done, { event }) => { + Promise.all([ + + // update call 1 + C_COMPONENT.update(_id, { + name: "New name", + }), + + // update call 2 + C_COMPONENT.update(_id, { + name: "New name - updated", + }) + + ]).then(() => { + + event.args.forEach((args) => { + assert.equal(args[0] instanceof Webhook, true); + }); + + done(); + + }).catch(done); + }); + + + workflow(C_COMPONENT, "remove", (done, { post }) => { + C_COMPONENT.remove(_id, (err, item) => { + try { + + // check post arguments item instance + post.args.forEach((args) => { + assert.equal(args[0] instanceof Webhook, true); + }); + + assert.ok(err === null); + assert.equal(item instanceof Webhook, true); + + done(err); + + } catch (err) { + + done(err); + + } + }); + }); + + +} catch (err) { + console.error(err); + process.exit(100); +} \ No newline at end of file diff --git a/tests/index.js b/tests/index.js index aee3a46..616cee4 100644 --- a/tests/index.js +++ b/tests/index.js @@ -35,7 +35,8 @@ process.env = Object.assign({ VAULT_SALT_BYTE_LEN: "16", USERS_BCRYPT_SALT_ROUNDS: "12", USERS_JWT_SECRET: "Pa$$w0rd", - USERS_JWT_ALGORITHM: "HS384" + USERS_JWT_ALGORITHM: "HS384", + MQTT_BROKER_VERSION: "3" }, env.parsed, process.env); diff --git a/tests/system/component/test.component.js b/tests/system/component/test.component.js index 7dd3fc5..9193426 100644 --- a/tests/system/component/test.component.js +++ b/tests/system/component/test.component.js @@ -67,7 +67,7 @@ describe("component", function () { "_defineMethod", "_mapMethod", "_ready", // common methods "items", "collection", "schema", // common properties "get", "add", "update", "remove", "find", // component methods - "found" + "found", "_labels", "labels" ]; for (let item of getItmes(instance)) {