diff --git a/.gitignore b/.gitignore index 945c6c244b..f84ae204a0 100644 --- a/.gitignore +++ b/.gitignore @@ -63,14 +63,23 @@ Temporary Items !/modules/default/** !/modules/README.md** +# Do not ignore greetings module. +!/modules/greetings +!/modules/greetings/** + +# Do not ignore MQTT module. +!/modules/MMM-MQTTbridge +!/modules/MMM-MQTTbridge/** + # Ignore changes to the custom css files but keep the sample and main. /css/* !/css/custom.css.sample !/css/main.css +# We will have a basic config file, then each branch its own? # Ignore users config file but keep the sample. -/config/* -!/config/config.js.sample +# /config/* +# !/config/config.js.sample # Vim ## swap @@ -81,3 +90,6 @@ Temporary Items *.orig *.rej *.bak + +# Ignore the docker-compose file to avoid unwanted IP address pushing +# docker-compose.yaml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..bff0169115 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# Dockerfile for MagicMirror + +# FROM node:21 + +FROM ubuntu:22.04 + +# Install Node.js and npm, keep the FROM to just one per dockerfile +RUN apt-get update && apt-get install -y curl software-properties-common +RUN curl -sL https://deb.nodesource.com/setup_21.x | bash - +RUN apt-get update && apt-get install -y nodejs + +# Make sure to have x11-apps to perform screen forwarding +RUN apt-get update && apt-get install -qqy x11-apps + +# Install dependencies +RUN apt-get update && apt-get install -y \ + python3 \ + python3-pip \ + cmake \ + libopenblas-dev \ + liblapack-dev \ + libnss3 \ + libgtk-3-0 \ + libx11-xcb1 \ + libxcomposite1 \ + libxcursor1 \ + libxdamage1 \ + libxi6 \ + libxtst6 \ + libxrandr2 \ + libasound2 \ + libpangocairo-1.0-0 \ + libatk1.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libxss1 \ + libgbm1 \ + libxshmfence1 \ + libglu1-mesa \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /opt/magicmirror + +COPY package.json ./package.json +COPY package-lock.json ./package-lock.json +COPY vendor/ ./vendor/ +COPY fonts/ ./fonts/ +RUN npm install --verbose + +# Copy MagicMirror files +COPY . . + +# Expose port +EXPOSE 8080 + +# Start MagicMirror in server mode +# CMD ["node", "serveronly"] + +# Start MagicMirror in client mode +CMD ["npm", "start", "--", "--no-sandbox"] diff --git a/Old_Dockerfile.txt b/Old_Dockerfile.txt new file mode 100644 index 0000000000..93ccf4eb5a --- /dev/null +++ b/Old_Dockerfile.txt @@ -0,0 +1,34 @@ +# Set the default OS to Linux +ARG OS=linux + +# Use the official Node.js image as base +FROM node:21 + +# Set the working directory in the container to /app +WORKDIR /app + +# Copy package.json and package-lock.json into the container at /app +COPY package*.json ./ + +# Bundle the app source inside the Docker image +# (i.e., copy the rest of the application into the Docker image) +COPY . . + +# Install any needed packages specified in package.json +RUN npm install + +# Install dependencies in the vendor directory (only for Windows) +RUN if [ "$OS" = "windows" ]; then \ + cd /app/vendor && npm install; \ + fi + +# Install dependencies in the font directory (only for Windows) +RUN if [ "$OS" = "windows" ]; then \ + cd /app/fonts && npm install; \ + fi + +# Make port 8080 available to the world outside this container +EXPOSE 8080 + +# Run MagicMirror² when the container launches, using the server only mode +CMD ["npm", "run", "server"] \ No newline at end of file diff --git a/Old_docker-compose.txt b/Old_docker-compose.txt new file mode 100644 index 0000000000..9dc3060547 --- /dev/null +++ b/Old_docker-compose.txt @@ -0,0 +1,9 @@ +version: '3' +services: + magic_mirror: + build: . + image: magic_mirror + container_name: magic_mirror + ports: + - "8080:8080" + restart: unless-stopped \ No newline at end of file diff --git a/README.md b/README.md index 0221b79f40..ea7926c102 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,151 @@ +# Docker Setup with X Server Forwarding for Windows + +## Prerequisites +- Docker Desktop +- VcXsrv Windows X Server + +## Installation & Configuration + +1. **Install Docker Desktop for Windows** + - Download and install from the Docker [website](https://docs.docker.com/desktop/install/windows-install/). + +2. **Install VcXsrv Windows X Server** + - Download [VcXsrv from SourceForge](https://sourceforge.net/projects/vcxsrv/). + - Run XLaunch, choose your display settings, and ensure "Disable access control" is checked. + +3. **Configure Docker - Not always needed** + - Do this only if you have any issue on the first run + - In Docker settings, ensure "Expose daemon on tcp://localhost:2375 without TLS" is enabled. + +4. **Retrieve IP Address** + - Open Command Prompt and execute `ipconfig` to find your "IPv4 Address". + +5. **Firewall** + - Set the correct **Firewall Settings** to allow X server forwarding + + +# Docker with X Server Forwarding on macOS + +This guide will walk you through the process of setting up Docker to run containers that can display GUI applications on macOS using X server forwarding. + +## Step 1: Install Docker Desktop for Mac + +- Download **Docker Desktop for Mac** from the Docker [website](https://docs.docker.com/desktop/install/mac-install/). +- Follow the installation instructions provided by the installer. + +## Step 2: Install XQuartz + +- Download **XQuartz** from the [XQuartz website](https://www.xquartz.org/). +- Install XQuartz and then restart your computer to ensure the changes take effect. + +## Step 3: Configure XQuartz + +- Open **XQuartz**. +- In the top menu, go to `XQuartz` > `Preferences`. +- Click on the **Security** tab. +- Check the option **"Allow connections from network clients"**. + +## Step 4: Retrieve Your IP Address + +- Open the **Terminal** application. +- Type `ifconfig` and press **Enter**. +- Look for the **"inet"** address associated with your active network connection (not the loopback `127.0.0.1`). + +## Step 5: Firewall + + - Open the **System Preferences** from the Apple menu + - Click on **Security & Privacy** + - Select the **Firewall** tab + - If the firewall is turned on, click on the lock icon and enter your admin pwd + - Click on **Firewall Options** + - Set the correct **Firewall Settings** to allow X server forwarding + - Re-lock the settings to ensure + +## Step 6: Addittional - in case of extra issues + +1. Add your IP address to the xhost list +- xhost +your_host_ip (replace your_host_ip with the actual IP address of your Mac machine) + +# After - common steps to build, test, and run the container + +## Clone the repository +1. Create a new folder +2. In the new folder git clone this repo (use the link) + +**If you already downloaded the repo and built locally, you can't use the same folder** + +## Building Docker Container + +1. Go in the project folder + +2. **Run the command** + - docker-compose build + + +**As of now, keep your X Server Emulator (Xserver or Qwartz) launched - as explained above** + +## Test + +1. **Run the container in interactive mode** + - Run the docker container as follow + docker run --rm -it + - Use the following command + xeyes + +## Play + +1. **Modify the docker-compose file** +- Go to the docker-compose file +- In the line below environment +- DISPLAY=:0, put your IP address in place of YOUR_IP_ADDRESS_HERE +2. **Run the container as expected** +- Run the container as intended +- docker-compose up + +# On target - the RASP + +## Prepare the Raspberry Pi + +1. Update the list of available packages and their version + - sudo apt-get update +2. Installs Docker and Docker-Compose + - sudo apt-get install -y docker.io docker-compose +3. Start the docker service, and ensure it will run on boot + - sudo systemctl start docker + - sudo systemctl enable docker +4. The X server manages the graphical display. To allow the Docker container to interact with it, use: + - xhost +si:localuser:root + +## Optional: Use a Non-Root User for Docker +By default, Docker commands require sudo. To run Docker commands without sudo, add your user to the docker group, and later reboot: + - sudo usermod -aG docker $USER + +## Start the mirror +1. Clone the repository in a local folder +2. Manually change the docker-compose file in order to have the following env variable + - DISPLAY=:0 +3. Navigate to the folder +4. Run the container + - (sudo) docker-compose up + +## Optional: debug and test +1. Build the container instead of running it + - (sudo) docker-compose build +2. Run the container using the following command + - docker run --rm -e DISPLAY=:0 -v /tmp/.X11-unix:/tmp/.X11-unix x11-apps xeyes +3. Check the logs + - docker-compose logs + + + + + + + + + + + ![MagicMirror²: The open source modular smart mirror platform. ](.github/header.png)

diff --git a/config/config.js b/config/config.js new file mode 100644 index 0000000000..39e5884b3b --- /dev/null +++ b/config/config.js @@ -0,0 +1,127 @@ +/* Config Sample + * + * For more information on how you can configure this file + * see https://docs.magicmirror.builders/configuration/introduction.html + * and https://docs.magicmirror.builders/modules/configuration.html + * + * You can use environment variables using a `config.js.template` file instead of `config.js` + * which will be converted to `config.js` while starting. For more information + * see https://docs.magicmirror.builders/configuration/introduction.html#enviromnent-variables + */ +let config = { + address: "localhost", // Address to listen on, can be: + // - "localhost", "127.0.0.1", "::1" to listen on loopback interface + // - another specific IPv4/6 to listen on a specific interface + // - "0.0.0.0", "::" to listen on any interface + // Default, when address config is left out or empty, is "localhost" + port: 8080, + basePath: "/", // The URL path where MagicMirror² is hosted. If you are using a Reverse proxy + // you must set the sub path here. basePath must end with a / + ipWhitelist: ["127.0.0.1", "::ffff:127.0.0.1", "::1"], // Set [] to allow all IP addresses + // or add a specific IPv4 of 192.168.1.5 : + // ["127.0.0.1", "::ffff:127.0.0.1", "::1", "::ffff:192.168.1.5"], + // or IPv4 range of 192.168.3.0 --> 192.168.3.15 use CIDR format : + // ["127.0.0.1", "::ffff:127.0.0.1", "::1", "::ffff:192.168.3.0/28"], + + useHttps: false, // Support HTTPS or not, default "false" will use HTTP + httpsPrivateKey: "", // HTTPS private key path, only require when useHttps is true + httpsCertificate: "", // HTTPS Certificate path, only require when useHttps is true + + language: "en", + locale: "en-US", + logLevel: ["INFO", "LOG", "WARN", "ERROR", "DEBUG"], // Add "DEBUG" for even more logging + timeFormat: 24, + units: "metric", + + modules: [ + { + module: "alert", + }, + { + module: "updatenotification", + position: "top_bar" + }, + { + module: "helloworld", + position: "top_left", + config: { + // text: "Say Yes to AYES!", + imagePath: "modules/default/helloworld/AYES_Icon.png", + imageWidth: "60%", + imageHeight: "60%", + + } + }, + { + module: "clock", + position: "top_left", + config: { + timezone: "Europe/Brussels", + } + }, + { + module: 'MMM-MQTTbridge', + disabled: false, + config: { + mqttServer: "mqtt://localhost:1883", + mqttConfig: + { + listenMqtt: true, + interval: 300000, + }, + } + }, + { + module: "weather", + position: "top_right", + config: { + weatherProvider: "openweathermap", + type: "current", + location: "Brussels", + // locationID: "2800866", + apiKey: "f8b3c5d1e4b3a80422d92bdf820148e9", + timezone: "Europe/Brussels" + } + }, + { + module: "weather", + position: "top_right", + header: "Weather Forecast", + config: { + weatherProvider: "openweathermap", + type: "forecast", + location: "Brussels", + // locationID: "2800866", + apiKey: "f8b3c5d1e4b3a80422d92bdf820148e9", + colored: true + } + }, + // { + // module: "streetmap", + // position: "middle_center" + // }, + { + module: "greetings", + position: "lower_third" + }, + { + module: "newsfeed", + position: "bottom_bar", + config: { + feeds: [ + { + title: "BBC News", + url: "https://feeds.bbci.co.uk/news/world/rss.xml" + } + ], + showSourceTitle: true, + showPublishDate: true, + broadcastNewsFeeds: true, + broadcastNewsUpdates: true + } + }, + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { module.exports = config; } diff --git a/config/config.js.sample b/config/config.js.sample index 688f627286..c9fb8194f0 100644 --- a/config/config.js.sample +++ b/config/config.js.sample @@ -46,19 +46,20 @@ let config = { position: "top_left" }, { - module: "calendar", - header: "US Holidays", - position: "top_left", - config: { - calendars: [ - { - fetchInterval: 7 * 24 * 60 * 60 * 1000, - symbol: "calendar-check", - url: "https://ics.calendarlabs.com/76/mm3137/US_Holidays.ics" - } - ] - } + module: "calendar", + header: "Belgian Holidays", + position: "top_left", + config: { + calendars: [ + { + fetchInterval: 7 * 24 * 60 * 60 * 1000, + symbol: "calendar-check", + url: "https://www.officeholidays.com/ics/belgium" + } + ] + } }, + { module: "compliments", position: "lower_third" @@ -69,9 +70,9 @@ let config = { config: { weatherProvider: "openweathermap", type: "current", - location: "New York", - locationID: "5128581", //ID from http://bulk.openweathermap.org/sample/city.list.json.gz; unzip the gz file and find your city - apiKey: "YOUR_OPENWEATHER_API_KEY" + location: "Brussels", + locationID: "2800866", + apiKey: "f8b3c5d1e4b3a80422d92bdf820148e9" } }, { @@ -81,9 +82,9 @@ let config = { config: { weatherProvider: "openweathermap", type: "forecast", - location: "New York", - locationID: "5128581", //ID from http://bulk.openweathermap.org/sample/city.list.json.gz; unzip the gz file and find your city - apiKey: "YOUR_OPENWEATHER_API_KEY" + location: "Brussels", + locationID: "2800866", + apiKey: "f8b3c5d1e4b3a80422d92bdf820148e9" } }, { diff --git a/css/main.css b/css/main.css index 0aa5c3418e..3ce9d5d799 100644 --- a/css/main.css +++ b/css/main.css @@ -1,8 +1,8 @@ :root { - --color-text: #999; - --color-text-dimmed: #666; - --color-text-bright: #fff; - --color-background: #000; + --color-text: #1f69a1; + --color-text-dimmed: #001e4b; + --color-text-bright: #288bf4; + --color-background: #ffffff; --font-primary: "Roboto Condensed"; --font-secondary: "Roboto"; --font-size: 20px; @@ -11,10 +11,10 @@ --font-size-medium: 1.5rem; --font-size-large: 3.25rem; --font-size-xlarge: 3.75rem; - --gap-body-top: 60px; - --gap-body-right: 60px; + --gap-body-top: 0px; + --gap-body-right: 30px; --gap-body-bottom: 60px; - --gap-body-left: 60px; + --gap-body-left: 30px; --gap-modules: 30px; } diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000000..4ccb2c845e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,24 @@ +version: '3' +services: + magic_mirror: + image: magic_mirror + build: + context: . + dockerfile: Dockerfile + container_name: magic_mirror + environment: + - DISPLAY=:0 + volumes: + - ./config:/opt/magicmirror/config + - /tmp/.X11-unix:/tmp/.X11-unix + devices: + - /dev/dri:/dev/dri + # networks: + # - mqtt_network + network_mode: host + + privileged: true + + restart: unless-stopped + + diff --git a/dockerfile_x11 b/dockerfile_x11 new file mode 100644 index 0000000000..ea11a8594c --- /dev/null +++ b/dockerfile_x11 @@ -0,0 +1,11 @@ +FROM ubuntu:22.04 + +RUN apt-get update && apt-get install -qqy x11-apps + +# RUN apt-get install -y iputils-ping + +# RUN apt-get install -y telnet + +# EXPOSE 6000 8080 8000 + +# CMD ["-e", "DISPLAY=192.168.178.21:0"] \ No newline at end of file diff --git a/index.html b/index.html index b97124be10..be5df92eaf 100644 --- a/index.html +++ b/index.html @@ -56,5 +56,12 @@ + + + diff --git a/modules/MMM-MQTTbridge/.github/mqttbridge_logo.png b/modules/MMM-MQTTbridge/.github/mqttbridge_logo.png new file mode 100644 index 0000000000..bdce8b3539 Binary files /dev/null and b/modules/MMM-MQTTbridge/.github/mqttbridge_logo.png differ diff --git a/modules/MMM-MQTTbridge/.github/mqttbridge_logo.psd b/modules/MMM-MQTTbridge/.github/mqttbridge_logo.psd new file mode 100644 index 0000000000..06ffbd7262 Binary files /dev/null and b/modules/MMM-MQTTbridge/.github/mqttbridge_logo.psd differ diff --git a/modules/MMM-MQTTbridge/LICENSE b/modules/MMM-MQTTbridge/LICENSE new file mode 100644 index 0000000000..87f03d3f9c --- /dev/null +++ b/modules/MMM-MQTTbridge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Tom-Hirschberger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modules/MMM-MQTTbridge/MMM-MQTTbridge.js b/modules/MMM-MQTTbridge/MMM-MQTTbridge.js new file mode 100644 index 0000000000..d7754919ab --- /dev/null +++ b/modules/MMM-MQTTbridge/MMM-MQTTbridge.js @@ -0,0 +1,458 @@ +/* eslint-disable indent */ +/* global Module */ + +/* MagicMirror² + * Module: MMM-MQTTbridge + * MIT Licensed. + */ + +Module.register("MMM-MQTTbridge", { + defaults: { + mqttDictConf: "./dict/mqttDictionary.js", + notiDictConf: "./dict/notiDictionary.js", + mqttServer: "mqtt://localhost:1883", + stringifyPayload: true, + newlineReplacement: null, + notiConfig: {}, //default values will be set in start function + mqttConfig: {}, //default values will be set in start function + }, + + getScripts: function () { + return [this.file('node_modules/jsonpath-plus/dist/index-browser-umd.js')]; + }, + + start: function () { + const self = this + Log.info("Starting module: " + self.name); + self.config.mqttConfig = Object.assign({ + qos: 0, + retain: false, + clean: true, + rejectUnauthorized: true, + listenMqtt: false, + interval: 300000, + onConnectMessages: [] + },self.config.mqttConfig) + + self.config.notiConfig = Object.assign({ + qos: 0, + listenNoti: false, + ignoreNotiId: [], + ignoreNotiSender: [], + onConnectNotifications: [] + },self.config.notiConfig) + + self.sendSocketNotification("CONFIG", self.config); + self.loaded = false; + self.mqttVal = ""; + setTimeout(() => { + self.updateMqtt(); + }, 500); + self.cnotiHook = {} + self.cnotiMqttCommands = {} + self.cmqttHook = {} + self.cmqttNotiCommands = {} + self.ctopicsWithJsonpath = {} + self.lastNotiValues = {} + self.lastMqttValues = {} + }, + + isAString: function(x) { + return Object.prototype.toString.call(x) === "[object String]" + }, + + validateCondition: function(source, value, type, lastValue){ + if (type == "eq"){ + if ((typeof source === "number") || (this.isAString(source))){ + return source === value + } else { + return JSON.stringify(source) === value + } + } else if (type == "incl"){ + if (this.isAString(source)){ + return source === value + } else { + return JSON.stringify(source).includes(value) + } + } else if (type == "mt") { + if (this.isAString(source)){ + return new RegExp(value).test(source) + } else { + return new RegExp(value).test(JSON.stringify(source)) + } + } else if (type == "lt"){ + return source < value + } else if (type == "le"){ + return source <= value + } else if (type == "gt"){ + return source > value + } else if (type == "ge"){ + return source >= value + } else if (type == "time") { + if (lastValue != null) { + if ((Date.now() - lastValue[1]) > value){ + return true + } else { + return false + } + } else { + return true + } + } else if (type == "tdiff") { + if (lastValue != null) { + if (JSON.stringify(source) != lastValue[0]){ + return true + } else { + if (value > 0){ + if ((Date.now() - lastValue[1]) > value){ + return true + } else { + return false + } + } else { + return false + } + } + } else { + return true + } + } + + return false + }, + + //https://stackoverflow.com/questions/3710204/how-to-check-if-a-string-is-a-valid-json-string + tryParseJSONObject: function (jsonString) { + try { + var o = JSON.parse(jsonString); + + // Handle non-exception-throwing cases: + // Neither JSON.parse(false) or JSON.parse(1234) throw errors, hence the type-checking, + // but... JSON.parse(null) returns null, and typeof null === "object", + // so we must check for that, too. Thankfully, null is falsey, so this suffices: + if (o && typeof o === "object") { + return o; + } + } + catch (e) { } + + return false; + }, + + updateMqtt: function () { + const self = this + self.sendSocketNotification("MQTT_BRIDGE_CONNECT"); //request to connect to the MQTT broker + + setTimeout(() => { + self.updateMqtt(); + }, self.config.mqttConfig.interval); + }, + + publishNotiToMqtt: function(topic, payload, options = {}) { + const self = this + + if (self.isAString(payload)){ + self.sendSocketNotification("MQTT_MESSAGE_SEND", { + mqttServer: self.config.mqttServer, + topic: topic, + payload: payload, + options: options + }); + } else { + self.sendSocketNotification("MQTT_MESSAGE_SEND", { + mqttServer: self.config.mqttServer, + topic: topic, + payload: JSON.stringify(payload), + options: options + }); + } + }, + + mqttToNoti: function (payload) { + const self = this + let msg = payload.data + let curMqttHook = self.cmqttHook[payload.topic] + + // Parse the data of the payload as a JSON and send all of it in an object + // We suppose we only have one mqtt hook for each topic and one notification command for each hook + let curCmdConf = self.cmqttNotiCommands[curMqttHook[0].mqttNotiCmd[0]][0] + let value = self.tryParseJSONObject(msg) + if (value != false) { + self.sendNotification(curCmdConf.notiID, value) + this.sendSocketNotification("LOG","[MQTT bridge] MQTT -> NOTI issued: " + curCmdConf.notiID + ", payload: "+ value); + } + // Skip the rest of the default implementation + return; + + //if there are configured jsonpath settings for this topic we create the json object and the jsonpath values + //this way they only get calculated once even if they are used by more than one element + if (typeof self.ctopicsWithJsonpath[payload.topic] !== "undefined"){ + let jsonObj = self.tryParseJSONObject(msg) + if (jsonObj != false){ + for(let curPath in self.ctopicsWithJsonpath[payload.topic]){ + let value = JSONPath.JSONPath({ path: curPath, json: jsonObj }); + if(Array.isArray(value) && (value.length == 1)){ + value = value[0] + } + + self.ctopicsWithJsonpath[payload.topic][curPath] = value + } + } + } + + for(let curHookIdx=0; curHookIdx < curMqttHook.length; curHookIdx++){ + let curHookConfig = curMqttHook[curHookIdx] + // { + // payloadValue: '{"state": "ON"}', + // mqttNotiCmd: ["Command 1"] + // }, + let value = msg + //if a jsonpath is configured in commad configuration we use the pre-calculated value now + //if the message was not a valid JSON we still use the raw value and write a message to the log instead + if(typeof curHookConfig.jsonpath !== "undefined") { + value = self.ctopicsWithJsonpath[payload.topic][curHookConfig.jsonpath] + if(value == null){ + this.sendSocketNotification("LOG","[MQTT bridge] Invalid JSON: There is configured a jsonpath setting for topic "+payload.topic + " but the message: "+msg+" is not a valid JSON. Using original value instead!"); + value = msg + } + } + + //now we need to replace all new lines in the message if "newlineReplacement" is configured + //either in the global option or special in this configuration + if (typeof curHookConfig.valueFormat !== "undefined") { + let newlineReplacement = curHookConfig.newlineReplacement || self.config.newlineReplacement + if (newlineReplacement != null) { + value = String(value).replace(/(?:\r\n|\r|\n)/g, newlineReplacement) + } + value = eval(eval("`" + curHookConfig.valueFormat + "`")) + } + + //now that we have the parsed and replaced value we can check if the payloadValue is matched (if payloadValue is present) + if ( + (typeof curHookConfig.payloadValue === "undefined") || + (curHookConfig.payloadValue == value) + ){ + //if additional conditions are configured we will now check if all of them match + //only if all of them match further processing is done + let conditionsValid = true + if (typeof curHookConfig.conditions !== "undefined"){ + let curLastValues = self.lastMqttValues[payload.topic][curHookIdx] || null + for(let curCondIdx = 0; curCondIdx < curHookConfig.conditions.length; curCondIdx++){ + let curCondition = curHookConfig.conditions[curCondIdx] + if((typeof curCondition["type"] !== "undefined") && (typeof curCondition["value"] !== "undefined")){ + if(!self.validateCondition(value,curCondition["value"],curCondition["type"],curLastValues)){ + conditionsValid = false + break + } + } + } + } + + //if all preconditions met we process the command configurations now + if (conditionsValid){ + self.lastMqttValues[payload.topic][curHookIdx] = [JSON.stringify(value), Date.now()] + let mqttCmds = curHookConfig.mqttNotiCmd || [] + for(let curCmdIdx = 0; curCmdIdx < mqttCmds.length; curCmdIdx++){ + let curCmdConfigs = self.cmqttNotiCommands[mqttCmds[curCmdIdx]] + for(let curCmdConfIdx = 0; curCmdConfIdx < curCmdConfigs.length; curCmdConfIdx++){ + let curCmdConf = curCmdConfigs[curCmdConfIdx] + // { + // commandId: "Command 1", + // notiID: "REMOTE_ACTION", + // notiPayload: {action: 'MONITORON'} + // }, + if (typeof curCmdConf.notiID !== "undefined"){ + if (typeof curCmdConf.notiPayload === "undefined") { + self.sendNotification(curCmdConf.notiID, value) + this.sendSocketNotification("LOG","[MQTT bridge] MQTT -> NOTI issued: " + curCmdConf.notiID + ", payload: "+ value); + } else { + self.sendNotification(curCmdConf.notiID, curCmdConf.notiPayload) + this.sendSocketNotification("LOG","[MQTT bridge] MQTT -> NOTI issued: " + curCmdConf.notiID + ", payload: "+ JSON.stringify(curCmdConf.notiPayload)); + } + } else { + this.sendSocketNotification("LOG","[MQTT bridge] MQTT -> NOTI error: Skipping notification cause \"notiID\" is missing. "+JSON.stringify(curCmdConf)); + } + } + } + } + } + } + }, + + notiToMqtt: function(notification, payload) { + const self = this + let curNotiHooks = self.cnotiHook[notification] + for(let curHookIdx = 0; curHookIdx < curNotiHooks.length; curHookIdx++){ + let curHookConfig = curNotiHooks[curHookIdx] + // { + // payloadValue: true, + // notiMqttCmd: ["SCREENON"] + // }, + + //now we need to replace all new lines in the message if "newlineReplacement" is configured + //either in the global option or special in this configuration + let value = payload + if (typeof curHookConfig.valueFormat !== "undefined") { + let newlineReplacement = curHookConfig.newlineReplacement || self.config.newlineReplacement + if (newlineReplacement != null) { + value = String(value).replace(/(?:\r\n|\r|\n)/g, newlineReplacement) + } + value = eval(eval("`" + curHookConfig.valueFormat + "`")) + } + + if ( + (typeof curHookConfig.payloadValue === "undefined") || + (JSON.stringify(curHookConfig.payloadValue) == JSON.stringify(value)) + ){ + //if additional conditions are configured we will now check if all of them match + //only if all of them match further processing is done + let conditionsValid = true + if (typeof curHookConfig.conditions !== "undefined"){ + let curLastValues = self.lastNotiValues[notification][curHookIdx] || null + for(let curCondIdx = 0; curCondIdx < curHookConfig.conditions.length; curCondIdx++){ + let curCondition = curHookConfig.conditions[curCondIdx] + if(typeof curCondition["type"] !== "undefined"){ + if (typeof curCondition["value"] !== "undefined") { + if(!self.validateCondition(value,curCondition["value"],curCondition["type"],curLastValues)){ + conditionsValid = false + break + } + } + } + } + } + + //if all preconditions met we process the command configurations now + if(conditionsValid){ + self.lastNotiValues[notification][curHookIdx] = [JSON.stringify(value),Date.now()] + let notiCmds = curHookConfig.notiMqttCmd || [] + for(let curCmdIdx = 0; curCmdIdx < notiCmds.length; curCmdIdx++){ + let curCmdConfigs = self.cnotiMqttCommands[notiCmds[curCmdIdx]] + for(let curCmdConfIdx = 0; curCmdConfIdx < curCmdConfigs.length; curCmdConfIdx++){ + let curCmdConf = curCmdConfigs[curCmdConfIdx] + // { + // commandId: "SCREENON", + // mqttTopic: "magicmirror/state", + // mqttMsgPayload: '{"state":"ON"}', + // options: {"qos": 1, "retain": false}, + // retain: true, + // qos: 0 + // }, + if (typeof curCmdConf.mqttTopic !== "undefined"){ + let curStringifyPayload + if(typeof curCmdConf.stringifyPayload !== "undefined"){ + curStringifyPayload = curCmdConf.stringifyPayload + } else { + curStringifyPayload = self.config.stringifyPayload + } + let msg + if (typeof curCmdConf.mqttMsgPayload === "undefined") { + if(curStringifyPayload){ + msg = JSON.stringify(value) + } else { + msg = value + } + } else { + if(curStringifyPayload){ + msg = JSON.stringify(curCmdConf.mqttMsgPayload) + } else { + msg = curCmdConf.mqttMsgPayload + } + } + + let curOptions = curCmdConf.options || {} + + if (typeof curCmdConf.retain !== "undefined"){ + curOptions["retain"] = curCmdConf.retain + } else { + curOptions["retain"] = self.config.mqttConfig.retain + } + if (typeof curCmdConf.qos !== "undefined"){ + curOptions["qos"] = curCmdConf.qos + } else { + curOptions["qos"] = self.config.mqttConfig.qos + } + + self.publishNotiToMqtt(curCmdConf.mqttTopic, msg, curOptions); + } else { + this.sendSocketNotification("LOG","[MQTT bridge] NOTI -> MQTT error: Skipping mqtt publish cause \"mqttTopic\" is missing. " + JSON.stringify(curCmdConf)); + } + } + } + } + } + } + }, + + socketNotificationReceived: function (notification, payload) { + const self = this + switch (notification) { + // START MQTT to NOTI logic + case "MQTT_MESSAGE_RECEIVED": + self.mqttToNoti(payload); + break; + // END of MQTT to NOTI logic + case "ERROR": + self.sendNotification("SHOW_ALERT", payload); + break; + case "DICTIONARIES": //use dictionaries from external files at module sturt-up + self.cnotiHook = payload.cnotiHook; + self.cnotiMqttCommands = payload.cnotiMqttCommands; + self.cmqttHook = payload.cmqttHook; + self.cmqttNotiCommands = payload.cmqttNotiCommands; + self.ctopicsWithJsonpath = payload.ctopicsWithJsonpath; + + for (let curNotification in self.cnotiHook) { + self.lastNotiValues[curNotification] = {}; + } + + for (let curTopic in self.cmqttHook) { + self.lastMqttValues[curTopic] = {}; + } + break; + case "CONNECTED_AND_SUBSCRIBED": + for (let curMsg of self.config.mqttConfig.onConnectMessages){ + self.publishNotiToMqtt(curMsg.topic, curMsg.msg, curMsg.options || {}) + } + + for (let curNoti of self.config.notiConfig.onConnectNotifications){ + if (typeof curNoti.payload !== "undefined"){ + self.sendNotification(curNoti.notification, curNoti.payload) + } else { + self.sendNotification(curNoti.notification) + } + } + } + }, + + notificationReceived: function (notification, payload, sender) { + const self = this + // START of NOTIFICATIONS to MQTT logic + + // Filtering... + if (!self.config.notiConfig.listenNoti) { return; } // check whether we need to listen for the NOTIFICATIONS. Return if "false" + var sndname = "system"; //sender name default is "system" + + if (!sender === false) { sndname = sender.name; }; //if no SENDER specified in NOTIFICATION, the SENDER is left as "system" (according to MM documentation), otherwise - use sender name + + // exclude NOTIFICATIONS where SENDER in ignored list + for (var x in self.config.notiConfig.ignoreNotiSender) + { + if (sndname == self.config.notiConfig.ignoreNotiSender[x]) { return; } + } + // exclude NOTIFICATIONS where NOTIFICATION ID in ignored list + for (var x in self.config.notiConfig.ignoreNotiId) + { + if (notification == self.config.notiConfig.ignoreNotiId[x]) { return; } + } + + if (typeof self.cnotiHook[notification] !== "undefined"){ + if (typeof payload !== "undefined"){ + self.notiToMqtt(notification, payload); + } else { + self.notiToMqtt(notification, ""); + } + } + } + // END of NOTIFICATIONS to MQTT logic +}); diff --git a/modules/MMM-MQTTbridge/conditions.md b/modules/MMM-MQTTbridge/conditions.md new file mode 100644 index 0000000000..c2375f81eb --- /dev/null +++ b/modules/MMM-MQTTbridge/conditions.md @@ -0,0 +1,168 @@ +# Conditions + +As of version 2.1 of the module it is possible to configure complex conditions instead of a simple compare to deceide which commands shuld be called depending of the content of notification payloads or MQTT messages. + +As the compare is done after selecting elements with jsonpath (look to [jsonpath.md](jsonpath.md) for further details ) and after the value formatting (look to [valueFormat.md](valueFormat.md) for further details) you only need to care about the current pre-processed value at this point. + +You can define the `conditions` option for `mqttPayload` elements in the `mqttDictionary.js` or in the elments of `notiPayload` in `notiDictionary.js` like in the following examples: + +```js + mqttPayload: [ + { + jsonpath: "output.myValue", + valueFormat: "{value}", + conditions: [ + { + type: "gt", + value: 10.1 + }, + { + type: "lt", + value: 12.1 + } + ], + mqttNotiCmd: ["Command 0"] + }, + ], +``` + +```js + notiPayload: [ + { + newlineReplacement: "#", + valueFormat: "\"${value}\".replace(\"test\",\"\").replace(\"abc\",\"\")", + conditions: [ + { + type: "tdiff", + value: 30000 + }, + ], + notiMqttCmd: ["Command 0"], + } + ] +``` + +:information_source: All conditions defined need to match for the commands to be processed (AND condition)! + +The following types are possible: + +* `lt` - The current value needs to be lower than the configured one +* `le` - The current value needs to be lower or equal than the configured one +* `gt` - The current value needs to be greater than the configured one +* `ge` - The current value needs to be greater or equal than the configured one +* `eq` - The current value needs to be equal to the configured one +* `incl` - The current value needs to include the configured one +* `mt` - The current value needs to match the configured [Regex pattern](https://www.w3schools.com/jsref/jsref_obj_regexp.asp) +* `time` - Only send the message / notification if the time between the last send one and the current one is greater than the amount of milliseconds configured +* `tdiff`- Only send the message / notification if the current value is different to the last send one or if the configured amount of milliseconds is reached. If the `value` is set to `0` or lower the time does not matter and the values only message / notification will only be send if the content changed + +Let's look at some examples now. + +Let us assume we do have the following MQTT message configuration: + +```js + mqttPayload: [ + { + conditions: [ + { + type: "gt", + value: 10.1 + }, + { + type: "lt", + value: 12.1 + } + ], + mqttNotiCmd: ["Command 0"] + }, + ], +``` + +Now we receive the message with the payload: + +```js +10.2 +``` + +As the value is greater than 10.1 and lower than 12.1 the command `Command 0` will be initiated. + +Now we receive the message with the payload: + +```js +12.2 +``` + +As the value is greater than 12.2 nothing will happen. + +Let us assume we do have the following MQTT message configuration: + +```js + mqttPayload: [ + { + conditions: [ + { + type: "mt", + value: ".*test[2-4].*" + }, + ], + mqttNotiCmd: ["Command 0"] + }, + ], +``` + +Now we receive the message with the payload: + +```text +mystringstart test123abc +``` + +Although the string has a sub string that starts with `test` nothing will happen cause only if `test` is followed by the digits `2`, `3` or `4` the string matches. + +Now we receive the message with the payload: + +```text +myotherstringtest test24 and some more text +``` + +As the string `test` is followed by `2` the command `Command 0` is issued. + +Let us assume we do have the following Notification configuration: + +```js + notiPayload: [ + { + conditions: [ + { + type: "tdiff", + value: 30000 + }, + ], + notiMqttCmd: ["Command 0"], + } + ] +``` + +If we receive a notification `test` the notiMqttCmd `Command 0` will be initiated. +If we receive a notification `test` with a different payload the `Command 0` will be initiated again. +If we receive a notification `test` with the same payload WITHIN 30 seconds the command `Command 0` will NOT be initated. +If we receive a notification `test` with the same payload AFTER 30 seconds the command `Command 0` will be initiated. + +Let us assume we do have the following Notification configuration: + +```js + notiPayload: [ + { + conditions: [ + { + type: "time", + value: 20000 + }, + ], + notiMqttCmd: ["Command 0"], + } + ] +``` + +If we receive a notification `test` the notiMqttCmd `Command 0` will be initiated. +If we receive a notification `test` again WITHIN 20 seconds the command `Command 0` will NOT be initated. +If we receive a notification `test` again AFTER 20 seconds the command `Command 0` will be initated. diff --git a/modules/MMM-MQTTbridge/dict/mqttDictionary.example.js b/modules/MMM-MQTTbridge/dict/mqttDictionary.example.js new file mode 100644 index 0000000000..c0377b3ce5 --- /dev/null +++ b/modules/MMM-MQTTbridge/dict/mqttDictionary.example.js @@ -0,0 +1,38 @@ +var mqttHook = [ + { + mqttTopic: "myhome/smartmirror/led/set", + mqttPayload: [ + { + payloadValue: '{"state": "ON"}', + mqttNotiCmd: ["Command 1"] + }, + { + payloadValue: '{"state": "OFF"}', + mqttNotiCmd: ["Command 2"] + }, + ], + }, + { + mqttTopic: "magicmirror/state", + mqttPayload: [ + { + payloadValue: "1", + mqttNotiCmd: ["Command 1", "Command 2"] + }, + ], + }, + ]; +var mqttNotiCommands = [ + { + commandId: "Command 1", + notiID: "REMOTE_ACTION", + notiPayload: {action: 'MONITORON'} + }, + { + commandId: "Command 2", + notiID: "REMOTE_ACTION", + notiPayload: {action: 'MONITOROFF'} + }, + ]; + + module.exports = { mqttHook, mqttNotiCommands}; diff --git a/modules/MMM-MQTTbridge/dict/mqttDictionary.js b/modules/MMM-MQTTbridge/dict/mqttDictionary.js new file mode 100644 index 0000000000..afe1b254c5 --- /dev/null +++ b/modules/MMM-MQTTbridge/dict/mqttDictionary.js @@ -0,0 +1,56 @@ +var mqttHook = [ + { + mqttTopic: "greetings/face_added", + mqttPayload: [ + { + mqttNotiCmd: ["Face added"] + }, + ], + }, + { + mqttTopic: "greetings/face_removed", + mqttPayload: [ + { + mqttNotiCmd: ["Face removed"] + }, + ], + }, + { + mqttTopic: "temperature/indoor", + mqttPayload: [ + { + mqttNotiCmd: ["Indoor Temperature"] + }, + ], + }, + { + mqttTopic: "humidity/indoor", + mqttPayload: [ + { + mqttNotiCmd: ["Indoor Humidity"] + }, + ], + }, + ]; +// The payload of the MQTT message must contain an array of strings called 'names' +// that contains the name of the persons that have been recognized +var mqttNotiCommands = [ + { + commandId: "Face added", + notiID: "FACE_ADDED" + }, + { + commandId: "Face removed", + notiID: "FACE_REMOVED" + }, + { + commandId: "Indoor Temperature", + notiID: "INDOOR_TEMPERATURE" + }, + { + commandId: "Indoor Humidity", + notiID: "INDOOR_HUMIDITY" + }, + ]; + + module.exports = { mqttHook, mqttNotiCommands}; diff --git a/modules/MMM-MQTTbridge/dict/notiDictionary.example.js b/modules/MMM-MQTTbridge/dict/notiDictionary.example.js new file mode 100644 index 0000000000..8de4230a2b --- /dev/null +++ b/modules/MMM-MQTTbridge/dict/notiDictionary.example.js @@ -0,0 +1,49 @@ + +var notiHook = [ + { + notiId: "USER_PRESENCE", + notiPayload: [ + { + payloadValue: true, + notiMqttCmd: ["SCREENON"] + }, + { + payloadValue: false, + notiMqttCmd: ["SCREENOFF"] + }, + ], + }, + { + notiId: "INDOOR_TEMPERATURE", + notiPayload: [ + { + payloadValue: '', + notiMqttCmd: ["Command 2"] + }, + ], + }, +]; +var notiMqttCommands = [ + { + commandId: "SCREENON", + mqttTopic: "magicmirror/state", + mqttMsgPayload: '{"state":"ON"}' + }, + { + commandId: "SCREENOFF", + mqttTopic: "magicmirror/state", + mqttMsgPayload: '{"state":"OFF"}' + }, + { + commandId: "Command 1", + mqttTopic: "magicmirror/state", + mqttMsgPayload: '{"state":"OFF"}' + }, + { + commandId: "Command 2", + mqttTopic: "magicmirror/state", + mqttMsgPayload: '' + }, +]; + +module.exports = { notiHook, notiMqttCommands }; diff --git a/modules/MMM-MQTTbridge/jsonpath.md b/modules/MMM-MQTTbridge/jsonpath.md new file mode 100644 index 0000000000..e19524cd9a --- /dev/null +++ b/modules/MMM-MQTTbridge/jsonpath.md @@ -0,0 +1,84 @@ +# JSONPath-plus + +As of version 2.1 of the module it is possible to parse the MQTT messages as JSON and select single values of the JSON with [JSONPath-Plus](https://github.com/JSONPath-Plus/JSONPath). + +:warning: +If the MQTT message is not a valid JSON the parsing will fail and so does the [JSONPath-Plus](https://github.com/JSONPath-Plus/JSONPath) selection. As a result the raw message will be further processed! + +You can define the `jsonpath` option for `mqttPayload` elements in the `mqttDictionary.js` like in the following example: + +```js + { + mqttTopic: "test/test1", + mqttPayload: [ + { + jsonpath: "output", + mqttNotiCmd: ["Command 0"] + }, + { + jsonpath: "output2", + mqttNotiCmd: ["Command 1"] + payloadValue: '{"state": "ON"}', + }, + { + jsonpath: "output2", + mqttNotiCmd: ["Command 2"] + payloadValue: '{"state": "OFF"}', + }, + ], + }, +``` + +Lets assume the message looks like: + +```json +{ + "output": 10.1, + "output2": { + "state": "OFF" + } +} +``` + +With the above configuration this will be the result: + +* The messages of the topic `test/test1` will be processed +* `Command 0` will be called with `10.1` cause no `payloadValue` is specified to compare the value to +* `Command 1` will NOT be called as the value of `output2` is `{"state": "OFF"}` +* `Command 2` will be called as the value of `output2` is `{"state": "OFF"}` + +Lets look at a second example... + +The configuration is: + +```js + { + mqttTopic: "test/test1", + mqttPayload: [ + { + jsonpath: "output.myValue", + mqttNotiCmd: ["Command 0"] + }, + ], + }, +``` + +Lets assume the message looks like: + +```json +{ + "output": { + "myValue": 10.1, + }, + "output2": { + "state": "OFF" + } +} +``` + +The result will be: + +* The messages of the topic `test/test1` will be processed +* `Command 0` will be called with `10.1` cause no `payloadValue` is specified to compare the value to + +For the full power of the selection mechanism please look at the [JSONPath-Plus](https://github.com/JSONPath-Plus/JSONPath) page. \ No newline at end of file diff --git a/modules/MMM-MQTTbridge/node_helper.js b/modules/MMM-MQTTbridge/node_helper.js new file mode 100644 index 0000000000..858b4433bf --- /dev/null +++ b/modules/MMM-MQTTbridge/node_helper.js @@ -0,0 +1,214 @@ +/* eslint-disable indent */ +'use strict'; + +/* MagicMirror² + * Module: MMM-MQTTbridge + * MIT Licensed. + */ + +const NodeHelper = require('node_helper'); +const mqtt = require('mqtt'); + +const fs = require('fs') +const path = require('path') +const moduleDir = __dirname + +module.exports = NodeHelper.create({ + start: function () { + const self = this; + console.log('[MQTT bridge] Module started'); + self.clients = []; + self.started = false; + self.config = {}; + + self.notiHook = {} + self.notiMqttCommands = {} + self.mqttHook = {} + self.mqttNotiCommands = {} + self.converted = {} + self.converted.notiHook = {} + self.converted.notiMqttCommands = {} + self.converted.mqttHook = {} + self.converted.topicsWithJsonpath = {} + self.converted.notisWithJsonpath = {} + self.converted.mqttNotiCommands = {} + }, + + connectMqtt: function () { + const self = this; + if (self.started){ + var client; + + if (typeof self.clients[self.config.mqttServer] === "undefined" || self.clients[self.config.mqttServer].connected == false) { + let options = { "clean": self.config.mqttConfig.clean } + if (typeof self.config.mqttConfig.will !== "undefined"){ + options["will"] = self.config.mqttConfig.will + } + + if (typeof self.config.mqttConfig.clientId !== "undefined"){ + options["clientId"] = self.config.mqttConfig.clientId + } + + options = Object.assign(options, self.config.mqttConfig.options) + + if (typeof self.config.mqttConfig.mqttClientKey === "undefined"){ + console.log("[MQTT bridge] MQTT broker uses unencrypted connection with options: "+JSON.stringify(options)); + client = mqtt.connect(self.config.mqttServer, options); + } else { + if (typeof self.config.mqttConfig.mqttClientKey !== "undefined") { + options["key"] = fs.readFileSync(self.config.mqttConfig.mqttClientKey); + } + + if (typeof self.config.mqttConfig.mqttClientCert !== "undefined") { + options["cert"] = fs.readFileSync(self.config.mqttConfig.mqttClientCert); + } + + if (typeof self.config.mqttConfig.caCert !== "undefined") { + options["ca"] = fs.readFileSync(self.config.mqttConfig.caCert); + } + + options["rejectUnauthorized"] = self.config.mqttConfig.rejectUnauthorized; + + console.log("[MQTT bridge] MQTT broker uses encrypted connection with options: "+JSON.stringify(options)); + client = mqtt.connect(self.config.mqttServer, options); + } + + self.clients[self.config.mqttServer] = client; + + client.on('connect', function () { + if (self.config.mqttConfig.listenMqtt){ + for (var i = 0; i < self.mqttHook.length; i++) { + let curQos = self.mqttHook[i].qos || self.config.mqttConfig.qos + let curOptions = self.mqttHook[i].options || {} + curOptions["qos"] = curQos + client.subscribe(self.mqttHook[i].mqttTopic, curOptions); + console.log("[MQTT bridge] Subscribed to the topic: " + self.mqttHook[i].mqttTopic +" with options: "+JSON.stringify(curOptions)); + } + } + + self.sendSocketNotification('CONNECTED_AND_SUBSCRIBED') + }) + + client.on('error', function (error) { //MQTT library function. Returns ERROR when connection to the broker could not be established. + console.log("[MQTT bridge] MQTT broker error: " + error); + self.sendSocketNotification('ERROR', { type: 'notification', title: '[MMM-MQTTbridge]', message: 'MQTT broker rised the following error: ' + error }); + }); + + client.on('offline', function () { //MQTT library function. Returns OFFLINE when the client (our code) is not connected. + console.log("[MQTT bridge] Could not establish connection to MQTT broker"); + self.sendSocketNotification('ERROR', { type: 'notification', title: '[MMM-MQTTbridge]', message: "MQTT broker can't be reached" }); + client.end(); + }); + + client.on('message', function (topic, message) { //MQTT library function. Returns message topic/payload when it arrives to subscribed topics. + console.log('[MQTT bridge] MQTT message received. Topic: ' + topic + ', message: ' + message); + self.sendSocketNotification('MQTT_MESSAGE_RECEIVED', { 'topic': topic, 'data': message.toString() }); // send mqtt mesage payload for further converting to NOTI to MMM-MQTTbridge.js file + }); + + } else { + client = self.clients[self.config.mqttServer]; + } + } else { + setTimeout(() => { + self.connectMqtt(); + }, 500); + } + }, + + // check all messages arrived from MMM-MQTTbridge.js + socketNotificationReceived: function (notification, payload) { + const self = this + switch (notification) { + case 'MQTT_BRIDGE_CONNECT': //case which appear at sturt-up.It sends pre-read arrays of custom dictionaries + self.connectMqtt(); + self.sendSocketNotification("DICTIONARIES", { "cnotiHook": self.converted.notiHook, + "cmqttHook": self.converted.mqttHook, + "cnotiMqttCommands": self.converted.notiMqttCommands, + "cmqttNotiCommands": self.converted.mqttNotiCommands, + "ctopicsWithJsonpath": self.converted.topicsWithJsonpath + }); + break; + case 'MQTT_MESSAGE_SEND': // if this message arrived, commands below send MQTT message using payload information + var client = self.clients[payload.mqttServer]; + if (typeof client !== "undefined") { + client.publish(payload.topic, payload.payload, payload.options || {}); + }; + console.log("[MQTT bridge] NOTI->MQTT. Topic: " + payload.topic + ", payload: " + payload.payload); + break; + case 'LOG': + console.log(payload); //just to display LOG in Terminal, not console. + break; + case 'CONFIG': + if ( self.started === false) { + self.config = payload + self.started = true + + let notiDictPath = path.join(moduleDir,self.config.notiDictConf) + try{ + console.log("[MQTT bridge] Info: Reading notification configuration: "+notiDictPath) + const { notiHook, notiMqttCommands } = require(notiDictPath); //read the custom NOTI->MQTT rules from external files (they are in a config.notiDictionary ) + self.notiHook = notiHook + self.notiMqttCommands = notiMqttCommands + } catch { + console.log("[MQTT bridge] ERROR: Could not read configuration "+notiDictPath+". Starting without configuration!") + } + + let mqttDictPath = path.join(moduleDir,self.config.mqttDictConf) + try{ + console.log("[MQTT bridge] Info: Reading mqtt configuration: "+mqttDictPath) + const { mqttHook, mqttNotiCommands } = require(mqttDictPath); //read the custom NOTI->MQTT rules from external files (they are in a config.notiDictionary ) + self.mqttHook = mqttHook + self.mqttNotiCommands = mqttNotiCommands + } catch { + console.log("[MQTT bridge] ERROR: Could not read configuration "+mqttDictPath+". Starting without configuration!") + } + + for (let idx = 0; idx < self.notiHook.length; idx ++){ + let curId = self.notiHook[idx].notiId || null + if ((curId != null) && (typeof self.notiHook[idx].notiPayload !== "undefined")){ + let curArray = self.converted.notiHook[curId] || [] + curArray = curArray.concat(self.notiHook[idx].notiPayload) + self.converted.notiHook[curId] = curArray + } + } + + for (let idx = 0; idx < self.notiMqttCommands.length; idx ++){ + let curId = self.notiMqttCommands[idx].commandId || null + if (curId != null){ + let curArray = self.converted.notiMqttCommands[curId] || [] + curArray.push(self.notiMqttCommands[idx]) + self.converted.notiMqttCommands[curId] = curArray + } + } + + for (let idx = 0; idx < self.mqttHook.length; idx ++){ + let curId = self.mqttHook[idx].mqttTopic || null + if ((curId != null) && (typeof self.mqttHook[idx].mqttPayload !== "undefined")){ + let curArray = self.converted.mqttHook[curId] || [] + curArray = curArray.concat(self.mqttHook[idx].mqttPayload) + self.converted.mqttHook[curId] = curArray + + for (let curPayloadIdx = 0; curPayloadIdx < self.mqttHook[idx].mqttPayload.length; curPayloadIdx++){ + let curPayloadObj = self.mqttHook[idx].mqttPayload[curPayloadIdx] + if(typeof curPayloadObj.jsonpath !== "undefined"){ + + let curResultObj = self.converted.topicsWithJsonpath[curId] || {} + curResultObj[curPayloadObj.jsonpath] = null + self.converted.topicsWithJsonpath[curId] = curResultObj + } + } + } + } + + for (let idx = 0; idx < self.mqttNotiCommands.length; idx ++){ + let curId = self.mqttNotiCommands[idx].commandId || null + if (curId != null){ + let curArray = self.converted.mqttNotiCommands[curId] || [] + curArray.push(self.mqttNotiCommands[idx]) + self.converted.mqttNotiCommands[curId] = curArray + } + } + } + } + } +}); diff --git a/modules/MMM-MQTTbridge/package.json b/modules/MMM-MQTTbridge/package.json new file mode 100644 index 0000000000..9a57f3848b --- /dev/null +++ b/modules/MMM-MQTTbridge/package.json @@ -0,0 +1,27 @@ +{ + "name": "MMM-MQTTbridge", + "version": "2.2.0", + "description": "NOTIFICATION <-> MQTT module for MagicMirror", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sergge1/MMM-MQTTbridge.git" + }, + "keywords": [ + "MagicMirror", + "MQTT" + ], + "author": "sergge1", + "license": "MIT", + "bugs": { + "url": "https://github.com/sergge1/MMM-MQTTbridge/issues" + }, + "homepage": "https://github.com/sergge1/MMM-MQTTbridge#readme", + "dependencies": { + "mqtt": "4.3.7", + "jsonpath-plus": "5.0.1" + } +} diff --git a/modules/MMM-MQTTbridge/readme.md b/modules/MMM-MQTTbridge/readme.md new file mode 100644 index 0000000000..24243f7671 --- /dev/null +++ b/modules/MMM-MQTTbridge/readme.md @@ -0,0 +1,402 @@ +## MMM-MQTTbridge + +

+ License +

+ +[**MMM-MQTTbridge**](.) allows you to integrate your MagicMirror into your smart home system via [MQTT protocol](https://github.com/mqtt/mqtt.github.io/wiki/software?id=software) and manage MagicMirror via MQTT messages by converting them into MM Notifications and vise verse - listen to your MM's Notifications and convert them into MQTT mesages. + +So, this module for MagicMirror does the following: + +1. **Listens to MQTT messages** from your MQTT broker and, if mqtt-message arrives, module **sends MM Notifications** based on the pre-configured mqtt-to-notification Dictionary rules. +2. **Listens to the MM Notifications** within your MagicMirror environment. If Notification arrives, module **sends MQTT message** based on the preconfigured notification-to-mqtt Dictionary rules. + +![MQTTbridge_logo](.github/mqttbridge_logo.png) + +## INSTALLATION + +**1. Clone and install module. Do the following commands**: + +```bash +cd ~/MagicMirror/modules +git clone --depth=1 https://github.com/sergge1/MMM-MQTTbridge.git +cd MMM-MQTTbridge +npm install +``` + +**2. Copy to MagicMirror config.js file MQTTbridge's config section**: + +- go to `cd ~/MagicMirror/config` +- open file `config.js` +- add the following config within `modules` section: + +```json5 +{ + module: 'MMM-MQTTbridge', + disabled: false, + config: { + mqttServer: "mqtt://:@localhost:1883", + mqttConfig: + { + listenMqtt: true, + interval: 300000, + }, + notiConfig: + { + listenNoti: true, + ignoreNotiId: ["CLOCK_MINUTE", "NEWS_FEED"], + ignoreNotiSender: ["system", "NEWS_FEED"], + }, + // set "NOTIFICATIONS -> MQTT" dictionary at /dict/notiDictionary.js + // set "MQTT -> NOTIFICATIONS" dictionary at /dict/mqttDictionary.js + }, +}, +``` + +If you like to use a tls encrypted connection to your server you can use this example configuration: + +```json5 +{ + module: 'MMM-MQTTbridge', + disabled: false, + config: { + mqttServer: "mqtts://:@localhost:8883", + mqttConfig: + { + listenMqtt: true, + interval: 300000, + mqttClientKey: "/home/pi/client-key.pem", + mqttClientCert: "/home/pi/client-cert.pem", + caCert: "/home/pi/ca-cert.pem", + rejectUnauthorized: true, + }, + notiConfig: + { + listenNoti: true, + ignoreNotiId: ["CLOCK_MINUTE", "NEWS_FEED"], + ignoreNotiSender: ["system", "NEWS_FEED"], + }, + // set "NOTIFICATIONS -> MQTT" dictionary at /dict/notiDictionary.js + // set "MQTT -> NOTIFICATIONS" dictionary at /dict/mqttDictionary.js + }, +}, +``` + +**3. Set dictionary files with your MQTT->NOTI and NOTI->MQTT rules**: + +- go to `cd ~/MagicMirror/modules/MMM-MQTTbridge/dict` +- copy the example files `cp notiDictionary.example.js notiDictionary.js; cp mqttDictionary.example.js mqttDictionary.js` +- edit `notiDictionary.js` and `mqttDictionary.js` for respective rules according to the explanation below. + +## CONFIG STRUCTURE + +**For better understanding, we have divided config into 3 sections**: + +1. General configurations in `config.js`; +2. "NOTIFICATION to MQTT" dictionary rules; +3. "MQTT to NOTIFICATION" dictionary rules; + +### GENERAL SECTION + +- `stringifyPayload`- specify if the payload of notifications should be converted with "JSON.stringify" before it will be send as MQTT message (The setting can be overriden for each message seperatly in "notiDictionary.js"), default is true. +- `mqttDictConf`- specify the path to the "mqttDictionary.js" file starting at the module folder, default: "./dict/mqttDictionary.js" +- `notiDictConf`- specify the path to the "notiDictionary.js" file starting at the module folder, default: "./dict/notiDictionary.js" +- `newlineReplacement`- if you use `valueFormat` for MQTT messages or for notification payloads you need to configure how new line characters should be replaced. See [valueFormat.md](valueFormat.md) for further details. +- `notiConfig`- the notification configuration. See the "notiConfig part" section for details +- `mqttConfig`- the mqtt configuration. See the "mqttConfig part" section for details + +#### mqttConfig part + +- `mqttServer` - set you server address using the following format: "mqtt://"+USERNAME+":"+PASSWORD+"@"+IPADDRESS+":"+PORT or "mqtts://"+USERNAME+":"+PASSWORD+"@"+IPADDRESS+":"+PORT. E.g. if you are using your broker with plaintext connnection *without username/password* on *localhost* with port *1883*, you config should looks "*mqtt://:@localhost:1883*", +- `clientId` - specify the id this client will register at the mqtt broker with, default: a random string starting with "mqttjs_" +- `mqttClientKey` - specify the path of the client tls key file (mandatory if using tls connetion). i.e. "/home/pi/client-key.pem"; +- `mqttClientCert` - specify the path of the client tls certificate file (mandatory if using tls connection). i.e. "/home/pi/client-cert.pem"; +- `caCert` - specify the path of the CA tls certificate file (mandatory if using tls connection). i.e. "/home/pi/ca-cert.pem"; +- `rejectUnauthorized` - specify if a self-signed server certificate should be rejected, default is true; +- `listenMqtt` - turn on/off the listening of MQTT messages. Set to `false` if you are going to use only NOTI->MQTT dictionary to save CPU usage; +- `qos` - specify the default QoS level for subscribed MQTT messages (can be overriden for each message in "mqttDictionary.js"), default: 0 +- `retain` - specify the default retain flag for published MQTT messages (can be overriden for each message in "mqttDictionary.js"), default: false +- `clean` - should the connection be a clean one (look [mqtt-clean-sessions-example](http://www.steves-internet-guide.com/mqtt-clean-sessions-example/) for details), default: true +- `will` - specify a object containing the "topic", "payload", etc. (see [MQTT.js](https://github.com/mqttjs/MQTT.js#readme) for details) you want the broker send to subscribing clients if the connection to this client is interrupted unexpected, default: unset +- `options` - if you want to specify additional options for the connect you can add all of the MQTT-Lib supported ones this as this options object (see [MQTT.js](https://github.com/mqttjs/MQTT.js#readme) for details). If `clientId`, `clean` and `will` are specified both as single options and in this options object the single ones override the ones in the options object, default: unset +- `interval` - interval for MQTT status update (messages will be received all time independent of this setting), default is 300000ms. +- `onConnectMessages` - a array of MQTT messages that should be send after the MQTT connected and subscribed to all topics successfully. While `topic` and `msg` setting are mandatory `options` are optional. + +onConnectMessages example: + +```json5 +{ + module: 'MMM-MQTTbridge', + disabled: false, + config: { + mqttServer: "mqtts://:@localhost:8883", + mqttConfig: + { + listenMqtt: true, + interval: 300000, + mqttClientKey: "/home/pi/client-key.pem", + mqttClientCert: "/home/pi/client-cert.pem", + caCert: "/home/pi/ca-cert.pem", + rejectUnauthorized: true, + onConnectMessages: [ + { + topic: "test1/connected", + msg: "true", + options: { + retain: false, + qos: 1 + } + }, + { + topic: "test2/get_status", + msg: {value: "test"} + } + ] + }, + }, +}, +``` + +#### notiConfig part + +- `listenNoti` - turn on/off the listening of NOTIFICATIONS. Set to `false` if you are going to use only MQTT->NOTI dictionary to save CPU usage; +- `ignoreNotiId` - list your NOTIFICATION ID that should be ignored from processing, this saves CPU usage. E.g. ["CLOCK_MINUTE", "NEWS_FEED"], +- `ignoreNotiSender` - list your NOTIFICATION SENDERS that should be ignored from processing, this saves CPU usage. E.g. ["system", "NEWS_FEED"] +- `qos` - specify the default QoS level for published MQTT messages (can be overriden for each message in "notiDictionary.js"), default: 0 +- `onConnectNotifications` - a array of notifications that are send after the MQTT client connected and subscribed to all topics successfully. While `notification` is mandatory `payload` is optional. + +onConnectNotifications example: + +```json5 +{ + module: 'MMM-MQTTbridge', + disabled: false, + config: { + mqttServer: "mqtts://:@localhost:8883", + notiConfig: + { + listenNoti: true, + ignoreNotiId: ["CLOCK_MINUTE", "NEWS_FEED"], + ignoreNotiSender: ["system", "NEWS_FEED"], + onConnectNotifications: [ + { + notification: "TEST1", + payload: true + }, + { + notification: "TEST2", + payload: {value: "myvalue"} + } + ] + }, + }, +}, +``` + +### NOTIFICATIONS to MQTT DICTIONARY SECTION + +Should be set within `~/MagicMirror/modules/MMM-MQTTbridge/dict/notiDictionary.js` or the path you configured with the general configuration option `notiDictConf`. + +:warning: +The configuration of how to ignore the payload value of notifications or send the payload of notifications as mqtt messages changed slightly with version 2.0 of the module! + +If the `payloadValue` is missing for a `notiPayload` element all commands configured in `notiMqttCmd` will be initiated independent of the value of the notification payload (new since version 2.0 of the module)! +If the `payloadValue` is set the payload of the notification needs to match with this value for the commands in `notiMqttCmd` to be initiated! +A `payloadValue` set to `''` will match for notifications with no payload or with the payload set to a empty string (new with version 2.0 of the module, **DIFFERENT TO VERSIONS 1.X AND LOWER**)! + +If no `mqttMsgPayload` is specified the payload of the notification will be send as MQTT message (new since version 2.0 of the module, **DIFFERENT TO VERSIONS 1.X and LOWER**)! +If `mqttMsgPayload` is present the value of `mqttMsgPayload` will be send as MQTT message! + +You can configure if the payload of a notification should be stringified before it is send with the optional option "stringifyPayload". The default value is the one configured in the general section. + +:warning: +The way boolean values as payload of notifications are treated changed with version 2.0 of the module. NO NEED TO CONVERT THEM TO 0 OR 1 ANYMORE!!! + +As of version 2.0 of the module it is possible to specify `qos`, `retain` settings for each `notiMqttCommands` element. If present these settings override the default values of the general configuration. +Additionally `options` can be specified which support all options for published messages of the MQTT.js library (see [MQTT.js](https://github.com/mqttjs/MQTT.js#readme) for details). If `qos` and `retain` are specified as single values and in the `options` the single values override the `options`. + +As of version 2.1 it is possible to format the payload of the notification with a `valueFormat` string before it will be compared to the `payloadValue` and will be further processed. Look at [valueFormat.md](valueFormat.md) for further details. + +As of version 2.1 it is possible to define complexer `conditions` than only compare the payload content to the `payloadValue`. Look at [conditions.md](conditions.md) for further details. + +```js +var notiHook = [ + { + notiId: "CLOCK_SECOND", + notiPayload: [ + { + payloadValue: '10', + notiMqttCmd: ["Command 1"] + }, + { + notiMqttCmd: ["payload ignored"] + }, + { + payloadValue: '' + notiMqttCmd: ["payload is empty string"] + }, + ], + }, + { + notiId: "INDOOR_TEMPERATURE", + notiPayload: [ + { + payloadValue: '', + notiMqttCmd: ["Command 2"] + }, + ], + }, + { + notiId: "TOGGLE_SHELLY_PLUG", + notiPayload: [ + { + payloadValue: '', + notiMqttCmd: ["Command 3"] + }, + ], + }, +]; +var notiMqttCommands = [ + { + commandId: "Command 1", + mqttTopic: "myhome/kitchen/light/set", + retain: true, + qos: 2, + mqttMsgPayload: '{"state":"OFF"}' + }, + { + commandId: "Command 2", + mqttTopic: "myhome/kitchen/temperature", + mqttMsgPayload: '', + options: { + retain: true, + qos: 1 + } + }, + { + commandId: "Command 3", + mqttTopic: "shellies/plug/relay/0/command", + stringifyPayload: false, + mqttMsgPayload: 'toogle' + }, + { + commandId: "payload ignored", + mqttTopic: "myhome/plug", + mqttMsgPayload: '{"state":"OFF"}' + }, + { + commandId: "payload is empty string", + mqttTopic: "myhome/test", + mqttMsgPayload: '{"state":"OFF"}' + }, + { + commandId: "payload of notification is send", + mqttTopic: "myhome/test", + }, +]; +``` + +### MQTT to NOTIFICATIONS DICTIONARY SECTION + +Should be set within `~/MagicMirror/modules/MMM-MQTTbridge/dict/mqttDictionary.js` or the path you configured with the general configuration option `mqttDictConf`. + +:warning: +The configuration of how to ignore the payload value of mqtt messages or send the payload of mqtt message as notification payload changed slightly with version 2.0 of the module! + +If the `payloadValue` is missing for a `mqttPayload` element all commands configured in `mqttNotiCmd` will be initiated independent of the value of mqtt message (new since version 2.0 of the module)! +If the `payloadValue` is set, the payload of the message needs to match with this value for the commands in `mqttNotiCmd` to be initiated! +A `payloadValue` set to `''` will match for empty messsage (new with version 2.0 of the module, **DIFFERENT TO VERSIONS 1.X AND LOWER**)! + +If no `notiPayload` is specified the message content will be send as payload of the notification (new since version 2.0 of the module, **DIFFERENT TO VERSIONS 1.X and LOWER**)! +If `notiPayload` is present the value of `notiPayload` will be send as MQTT message! + +As of version 2.0 of the module it is possible to specify `qos` setting for each `mqttHook` element. If present these settings override the default values of the general configuration. +Additionally `options` can be specified which support all options for subscribing to messages of the MQTT.js library (see [MQTT.js](https://github.com/mqttjs/MQTT.js#readme) for details). If `qos` is specified as single value and in the `options` the single value override the `options`. + +As of Version 2.1 it is possible to let the message value be parsed as JSON and select single or multiple values with [JSONPath-Plus](https://github.com/JSONPath-Plus/JSONPath) before the message gets further processed. Look at [jsonpath.md](jsonpath.md) for further details. + +As of version 2.1 it is possible to format the message or [JSONPath-Plus](https://github.com/JSONPath-Plus/JSONPath) result with a `valueFormat` string before it will be compared to the `payloadValue` and will be further processed. Look at [valueFormat.md](valueFormat.md) for further details. + +As of version 2.1 it is possible to define complexer `conditions` than only compare the messaage content to the `payloadValue`. Look at [conditions.md](conditions.md) for further details. + +```js +var mqttHook = [ + { + mqttTopic: "myhome/test", + qos: 1, + mqttPayload: [ + { + payloadValue: "ASSISTANT_LISTEN", + mqttNotiCmd: ["Command 1"] + }, + { + payloadValue: "", + mqttNotiCmd: ["Command 2"] + }, + ], + }, + { + mqttTopic: "myhome/test2", + mqttPayload: [ + { + mqttNotiCmd: ["Command 2"] + }, + ], + }, + { + mqttTopic: "myhome/test3", + options: { + qos: 2 + } + mqttPayload: [ + { + payloadValue: "", + mqttNotiCmd: ["Command 2"] + }, + ], + }, + { + mqttTopic: "myhome/testjson", + mqttPayload: [ + { + jsonpath: "output" + valueFormat: "Number(${value}).toFixed(2)", + mqttNotiCmd: ["Command 3"], + conditions: [ + { + type: "gt", + value: "10" + }, + { + type: "lt", + value: "15" + } + ] + }, + ], + }, + ]; +var mqttNotiCommands = [ + { + commandId: "Command 1", + notiID: "ASSISTANT_LISTEN", + notiPayload: 'BLABLABLA-1' + }, + { + commandId: "Command 2", + notiID: "ASSISTANT_LISTEN", + }, + { + commandId: "Command 3", + notiID: "PARSED_JSON_NOTIFICATION", + }, + ]; + ``` + +## CREDITS + +[@bugsounet](https://github.com/bugsounet) + +[@sergge1](https://github.com/sergge1) + +[@DanielHfnr](https://github.com/DanielHfnr) diff --git a/modules/MMM-MQTTbridge/recipes/with-MMM-MQTTbridge.js b/modules/MMM-MQTTbridge/recipes/with-MMM-MQTTbridge.js new file mode 100644 index 0000000000..602fa6c26b --- /dev/null +++ b/modules/MMM-MQTTbridge/recipes/with-MMM-MQTTbridge.js @@ -0,0 +1,94 @@ +/* eslint-disable indent */ +/** MMM-MQTTbridge commands addon **/ +/** modify pattern to your language **/ +/** for MMM-MQTTbridge **/ +/** sergge1 **/ + +var recipe = { + transcriptionHooks: { + "LED_TURN_ON": { + pattern: "включить подсветку", + command: "LED_TURN_ON" + }, + "LED_TURN_OFF": { + pattern: "выключить подсветку", + command: "LED_TURN_OFF" + }, + "LED_COLOR_RED": { + pattern: "красная подсветка", + command: "LED_COLOR_RED" + }, + "LED_COLOR_BLUE": { + pattern: "голубая подсветка", + command: "LED_COLOR_BLUE" + }, + "LED_COLOR_YELLOW": { + pattern: "желтая подсветка", + command: "LED_COLOR_YELLOW" + }, + "LED_COLOR_NEON": { + pattern: "неоновая подсветка", + command: "LED_COLOR_NEON" + }, + "LED_COLOR_CYAN": { + pattern: "синяя подсветка", + command: "LED_COLOR_CYAN" + }, + "LED_COLOR_GREEN": { + pattern: "зелёная подсветка", + command: "LED_COLOR_GREEN" + } + }, + commands: { + "LED_TURN_ON": { + notificationExec: { + notification: "NOTI_TO_MQTT", + payload: "led_turn_on" + }, + }, + "LED_TURN_OFF": { + notificationExec: { + notification: "NOTI_TO_MQTT", + payload: "led_turn_off" + }, + }, + "LED_COLOR_RED": { + notificationExec: { + notification: "NOTI_TO_MQTT", + payload: "led_color_red" + } + }, + "LED_COLOR_BLUE": { + notificationExec: { + notification: "NOTI_TO_MQTT", + payload: "led_color_blue" + } + }, + "LED_COLOR_YELLOW": { + notificationExec: { + notification: "NOTI_TO_MQTT", + payload: "led_color_yellow" + } + }, + "LED_COLOR_NEON": { + notificationExec: { + notification: "NOTI_TO_MQTT", + payload: "led_color_neon" + } + }, + "LED_COLOR_CYAN": { + notificationExec: { + notification: "NOTI_TO_MQTT", + payload: "led_color_cyan" + } + }, + "LED_COLOR_GREEN": { + notificationExec: { + notification: "NOTI_TO_MQTT", + payload: "led_color_green" + } + } + } +} + +exports.recipe = recipe // Don't remove this line. \ No newline at end of file diff --git a/modules/MMM-MQTTbridge/valueFormat.md b/modules/MMM-MQTTbridge/valueFormat.md new file mode 100644 index 0000000000..a431080355 --- /dev/null +++ b/modules/MMM-MQTTbridge/valueFormat.md @@ -0,0 +1,128 @@ +# valueFormat + +As of version 2.1 of the module it is possible to format the MQTT messages or notification payloads before they get further processed. + +:information_source: The MQTT message or notification payload will be in the variable `value` which you can access in the `valueFormat` with `${value}`. + +:warning: As formatting of newlines causes problems you need to configure a `newlineReplacement` either in the global module configuration or in the configuration of the messages/payloads. + +You can define the `valueFormat` option for `mqttPayload` elements in the `mqttDictionary.js` or in the elments of `notiPayload` in `notiDictionary.js` like in the following examples: + +```js + notiPayload: [ + { + newlineReplacement: "#", + valueFormat: "\"${value}\".replace(\"test\",\"\").replace(\"abc\",\"\")", + notiMqttCmd: ["Command 0"], + } + ] +``` + +```js + mqttPayload: [ + { + valueFormat: "Number(${value}).toFixed(2)", + mqttNotiCmd: ["Command 0"] + }, + ], +``` + +Let's look at some examples now. + +Let us assume we do have the following notification configuration: + +```js + notiPayload: [ + { + valueFormat: "Number(${value}).toFixed(2)", + notiMqttCmd: ["Command 0"], + } + ] +``` + +Now we receive the corresponding notification with the payload: + +```js +10.123456 +``` + +The result will be: + +* The command `Command 0` will be called with the value `10.12`. + +Now lets assume we do have the configuration: + +```js + notiPayload: [ + { + valueFormat: "Number(${value.output}).toFixed(2)", + notiMqttCmd: ["Command 0"], + } + ] +``` + +And the notification payload is: + +```js +{ + output: 10.12345 +} +``` + +The result will be the same as before as we selected `${value.output}` in the configuration. + +Now lets assume the following configuration: + +```js + notiPayload: [ + { + newlineReplacement: "#", + valueFormat: "\"${value}\".replace(\"test\",\"\").replace(\"abc\",\"\").replace(\"#\",\"\")", + notiMqttCmd: ["Command 0"], + } + ] +``` + +And the notification payload is the following string with new line characters: + +```text +test +123 +abc +``` + +As newline charcters will be replaced with `#` and then `test`, `abc` and `#` will be replaced by nothing the result will be `123`. + +The way to format MQTT messages is the same as for the notification payload but if you want to select sub elements like `output` in the example before you need to use the `jsonpath` option first. See [jsonpath.md](jsonpath.md) for more details but let us look at a simple example now. + +The MQTT configuration contains: + +```js + mqttPayload: [ + { + jsonpath: "output", + valueFormat: "Number(${value}).toFixed(2)", + mqttNotiCmd: ["Command 0"] + }, + ], +``` + +The received MQTT message is: + +```json +{ + "output": 10.12345 +} +``` + +And again the result will be the same as in the notification example. The `Command 0` will be called with value `10.12`. + +## Further examples + +### valueFormat: "\"${value}\".replace(\"test\",\"\").replace(\"abc\",\"\")" + +The value will be interpreted as strint, `test` and `abc` in the string will be replaced with nothing. So if the input will be something like `test123abc` the result will be `123`. + +### valueFormat: "\"${value.myInput}\".replace(\"test\",\"\").replace(\"abc\",\"\")" + +The `myInput` of the value will be selected, `test` and `abc` will be replaced with nothing. So fi the input is something like `{myInput: test123abc}` the result will be `123`. diff --git a/modules/default/clock/clock.js b/modules/default/clock/clock.js index 42b066ab58..673f3686ee 100644 --- a/modules/default/clock/clock.js +++ b/modules/default/clock/clock.js @@ -27,8 +27,8 @@ Module.register("clock", { showSunTimes: false, showMoonTimes: false, // options: false, 'times' (rise/set), 'percent' (lit percent), 'phase' (current phase), or 'both' (percent & phase) - lat: 47.630539, - lon: -122.344147 + lat: 50.5039, + lon: 4.4699 }, // Define required scripts. getScripts () { diff --git a/modules/default/compliments/compliments.js b/modules/default/compliments/compliments.js index 39e6c34d49..5ae9505e81 100644 --- a/modules/default/compliments/compliments.js +++ b/modules/default/compliments/compliments.js @@ -2,7 +2,7 @@ Module.register("compliments", { // Module config defaults. defaults: { compliments: { - anytime: ["Hey there sexy!"], + anytime: ["C'è solo il Toro","Hey there sexy!"], morning: ["Good morning, handsome!", "Enjoy your day!", "How was your sleep?"], afternoon: ["Hello, beauty!", "You look sexy!", "Looking good today!"], evening: ["Wow, you look hot!", "You look nice!", "Hi, sexy!"], diff --git a/modules/default/helloworld/AYES_Icon.png b/modules/default/helloworld/AYES_Icon.png new file mode 100644 index 0000000000..5aa4b71c58 Binary files /dev/null and b/modules/default/helloworld/AYES_Icon.png differ diff --git a/modules/default/helloworld/helloworld.css b/modules/default/helloworld/helloworld.css new file mode 100644 index 0000000000..5da4985735 --- /dev/null +++ b/modules/default/helloworld/helloworld.css @@ -0,0 +1,24 @@ +/* modules/default/helloworld/helloworld.css */ + +.helloworld-container { +display: flex; +flex-direction: column; /* Ensure the text is below the image */ +align-items: flex-start; /* Align items to the start (left) */ +} + +.helloworld-text { +font-size: 24px; +font-family: 'Franklin Gothic Medium'; +text-align: left; +margin-bottom: 10px; +} + +.helloworld-image { +max-width: 80%; /* Set the image size */ +max-height: 80%; +margin: 0; /* Remove any default margin */ +} + +.highlight-a { + color: red; /* Highlight the letter 'A' in red */ +} \ No newline at end of file diff --git a/modules/default/helloworld/helloworld.js b/modules/default/helloworld/helloworld.js index 53fbd80c03..baeb041613 100644 --- a/modules/default/helloworld/helloworld.js +++ b/modules/default/helloworld/helloworld.js @@ -1,14 +1,41 @@ Module.register("helloworld", { - // Default module config. defaults: { - text: "Hello World!" + text: "Say YES, Say AYES!", + imagePath: "modules/default/helloworld/AYES_Icon.png", + imageWidth: "60%", + imageHeight: "60%" }, - - getTemplate () { - return "helloworld.njk"; + + start: function() { + this.sendNotification("SHOW_ALERT", { + type: "notification", + title: "Hello World!", + message: "Module is loaded!" + }); }, - - getTemplateData () { - return this.config; + + getStyles: function() { + return ["helloworld.css"]; + }, + + getDom: function() { + var wrapper = document.createElement("div"); + wrapper.className = "helloworld-container"; + + if (this.config.imagePath) { + var img = document.createElement("img"); + img.src = this.config.imagePath; + img.style.width = this.config.imageWidth; + img.style.height = this.config.imageHeight; + img.className = "helloworld-image"; + wrapper.appendChild(img); + } + + var text = document.createElement("div"); + text.innerHTML = this.config.text; + text.className = "helloworld-text"; + wrapper.appendChild(text); + + return wrapper; } -}); + }); \ No newline at end of file diff --git a/modules/default/newsfeed/newsfeed.js b/modules/default/newsfeed/newsfeed.js index c66f8990d8..cd72f3fb3a 100644 --- a/modules/default/newsfeed/newsfeed.js +++ b/modules/default/newsfeed/newsfeed.js @@ -13,7 +13,7 @@ Module.register("newsfeed", { showPublishDate: true, broadcastNewsFeeds: true, broadcastNewsUpdates: true, - showDescription: false, + showDescription: true, showTitleAsUrl: false, wrapTitle: true, wrapDescription: true, diff --git a/modules/default/weather/current.njk b/modules/default/weather/current.njk index d19848954e..a9fd22cff2 100644 --- a/modules/default/weather/current.njk +++ b/modules/default/weather/current.njk @@ -46,7 +46,7 @@ {% if config.showIndoorTemperature and indoor.temperature %} - + {{ indoor.temperature | roundValue | unit("temperature") | decimalSymbol }} diff --git a/modules/default/weather/weather.css b/modules/default/weather/weather.css index 816f0a9b74..ee9beae984 100644 --- a/modules/default/weather/weather.css +++ b/modules/default/weather/weather.css @@ -41,9 +41,9 @@ } .weather tr.colored .min-temp { - color: #bcddff; + color: #409cf8; } .weather tr.colored .max-temp { - color: #ff8e99; + color: #f90606; } diff --git a/modules/default/weather/weather.js b/modules/default/weather/weather.js index 224aba0647..558d99cff8 100644 --- a/modules/default/weather/weather.js +++ b/modules/default/weather/weather.js @@ -15,8 +15,8 @@ Module.register("weather", { animationSpeed: 1000, showFeelsLike: true, showHumidity: "none", // this is now a string; see current.njk - showIndoorHumidity: false, - showIndoorTemperature: false, + showIndoorHumidity: true, + showIndoorTemperature: true, allowOverrideNotification: false, showPeriod: true, showPeriodUpper: false, @@ -113,10 +113,10 @@ Module.register("weather", { } } } else if (notification === "INDOOR_TEMPERATURE") { - this.indoorTemperature = this.roundValue(payload); + this.indoorTemperature = this.roundValue(payload.temperature); this.updateDom(300); } else if (notification === "INDOOR_HUMIDITY") { - this.indoorHumidity = this.roundValue(payload); + this.indoorHumidity = this.roundValue(payload.humidity); this.updateDom(300); } else if (notification === "CURRENT_WEATHER_OVERRIDE" && this.config.allowOverrideNotification) { this.weatherProvider.notificationReceived(payload); diff --git a/modules/greetings/README.md b/modules/greetings/README.md new file mode 100644 index 0000000000..48fac15af6 --- /dev/null +++ b/modules/greetings/README.md @@ -0,0 +1,6 @@ +# Module: Greetings + +The purpose of the `greetings` module is to display a message to greet a certain person whenever a notification "FACE_ADDED" is received. + +It works alongside the MMM-MQTTbridge module that subscribes to certain topics of a MQTT broker. +For example: if a MQTT message with topic `greetings/face_added` is received, the linked command will be executed. In this case the command `Face added` will be executed which will send a `FACE_ADDED` notification to all modules. This notification will then be intercepted by this module. diff --git a/modules/greetings/greetings.js b/modules/greetings/greetings.js new file mode 100644 index 0000000000..396ee4b6c2 --- /dev/null +++ b/modules/greetings/greetings.js @@ -0,0 +1,150 @@ +Module.register("greetings", { + // Define module defaults + defaults: { + greetings: { + anytime: ["hello"], + morning: ["good morning"], + afternoon: ["good afternoon"], + evening: ["good evening"], + "....-01-01": ["happy new year"] + }, + morningStartTime: 3, + morningEndTime: 12, + afternoonStartTime: 12, + afternoonEndTime: 17, + displayTime: 10000, + animationSpeed: 1000 + }, + + getTranslations () { + return { + en: "translations/en.json", + fr: "translations/fr.json" + }; + }, + + // Override start method. + start () { + Log.info(`Starting module: ${this.name}`); + + this.personsDetected = []; + this.lastgreetingIndex = -1; + }, + + // Override notification handler. + notificationReceived (notification, payload, sender) { + if (notification === "FACE_ADDED") { + // Greet new person + this.personDetected(payload); + } else if (notification === "FACE_REMOVED") { + // Remove greeting + this.personRemoved(payload); + } + }, + + personDetected (payload) { + for (let name of payload.names) { + this.personsDetected.push(name); + } + + setTimeout(() => { + this.personRemoved(payload); + }, this.config.displayTime); + + this.updateDom(this.config.animationSpeed); + }, + + personRemoved (payload) { + for (let name of payload.names) { + const index = this.personsDetected.indexOf(name); + + if (index !== -1) { + this.personsDetected.splice(index, 1); + + this.updateDom(this.config.animationSpeed); + } + } + }, + + /** + * Generate a random index for a list of greetings. + * @param {string[]} greetings Array with greetings. + * @returns {number} a random index of given array + */ + randomIndex (greetings) { + if (greetings.length <= 1) { + return 0; + } + + const generate = function () { + return Math.floor(Math.random() * greetings.length); + }; + + let greetingIndex = generate(); + + while (greetingIndex === this.lastgreetingIndex) { + greetingIndex = generate(); + } + + this.lastgreetingIndex = greetingIndex; + + return greetingIndex; + }, + + /** + * Retrieve an array of greetings for the time of the day. + * @returns {string[]} array with greetings for the time of the day. + */ + getGreetingsArray () { + const hour = moment().hour(); + const date = moment().format("YYYY-MM-DD"); + let greetings = []; + + // Add time of day greetings + if (hour >= this.config.morningStartTime && hour < this.config.morningEndTime && this.config.greetings.hasOwnProperty("morning")) { + greetings = [...this.config.greetings.morning]; + } else if (hour >= this.config.afternoonStartTime && hour < this.config.afternoonEndTime && this.config.greetings.hasOwnProperty("afternoon")) { + greetings = [...this.config.greetings.afternoon]; + } else if (this.config.greetings.hasOwnProperty("evening")) { + greetings = [...this.config.greetings.evening]; + } + + // Add greetings for anytime + Array.prototype.push.apply(greetings, this.config.greetings.anytime); + + // Add greetings for special days + for (let entry in this.config.greetings) { + if (new RegExp(entry).test(date)) { + Array.prototype.push.apply(greetings, this.config.greetings[entry]); + } + } + + return greetings; + }, + + // Override dom generator. + getDom () { + const wrapper = document.createElement("div"); + if (this.personsDetected.length) { + wrapper.className = this.config.classes ? this.config.classes : "thin xlarge bright pre-line"; + // get the greeting text + const greetingsArray = this.getGreetingsArray(); + let greetingsText = this.translate(greetingsArray[this.randomIndex(greetingsArray)]) + // let greetingsText = this.translate("hello"); + // process all the names of persons detected + for (const [index, name] of this.personsDetected.entries()) { + greetingsText = greetingsText.concat(" " + name); + if (index < this.personsDetected.length - 1) { + greetingsText = greetingsText.concat(" " + this.translate("and")); + } + } + greetingsText = greetingsText.concat("!"); + // create a span to hold the greetings + const greetings = document.createElement("span"); + // create a text element + greetings.appendChild(document.createTextNode(greetingsText)); + wrapper.appendChild(greetings); + } + return wrapper; + } +}); diff --git a/modules/greetings/translations/en.json b/modules/greetings/translations/en.json new file mode 100644 index 0000000000..2c7dfdaf6b --- /dev/null +++ b/modules/greetings/translations/en.json @@ -0,0 +1,8 @@ +{ + "hello": "Hello", + "good morning": "Good morning", + "good afternoon": "Good afternoon", + "good evening": "Good evening", + "happy new year": "Happy new year", + "and": "and" +} diff --git a/modules/greetings/translations/fr.json b/modules/greetings/translations/fr.json new file mode 100644 index 0000000000..3958e6f8d1 --- /dev/null +++ b/modules/greetings/translations/fr.json @@ -0,0 +1,8 @@ +{ + "hello": "Bonjour", + "good morning": "Bonne matinée", + "good afternoon": "Bonne après-midi", + "good evening": "Bonne soirée", + "happy new year": "Bonne année", + "and": "et" +} diff --git a/modules/streetmap/streetmap.css b/modules/streetmap/streetmap.css new file mode 100644 index 0000000000..c96c438b0d --- /dev/null +++ b/modules/streetmap/streetmap.css @@ -0,0 +1,7 @@ +#map { + height: 500px; + width: 100%; + margin: 0; + padding: 0; + opacity: 0.7; +} diff --git a/modules/streetmap/streetmap.html b/modules/streetmap/streetmap.html new file mode 100644 index 0000000000..a16cde497b --- /dev/null +++ b/modules/streetmap/streetmap.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/modules/streetmap/streetmap.js b/modules/streetmap/streetmap.js new file mode 100644 index 0000000000..885a1c4496 --- /dev/null +++ b/modules/streetmap/streetmap.js @@ -0,0 +1,180 @@ +function initMap(mapElement, location) { + const map = new google.maps.Map(mapElement, { + zoom: location.zoom, + center: { lat: location.latitude, lng: location.longitude }, + mapTypeId: 'roadmap', + streetViewControl: false, + navigationControl: false, + mapTypeControl: false, + scaleControl: false, + zoomControl: false, + draggable: false, + scrollwheel: false, + styles: location.styles + }); + + if (location.showTraffic) { + const trafficLayer = new google.maps.TrafficLayer(); + trafficLayer.setMap(map); + } +} + +function waitForElement(selector) { + return new Promise(resolve => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + + const observer = new MutationObserver(mutations => { + if (document.querySelector(selector)) { + observer.disconnect(); + resolve(document.querySelector(selector)); + } + }); + + // If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336 + observer.observe(document.body, { + childList: true, + subtree: true + }); + }); +} + +function initMapRoutine() { + const trafficStyles = [ + { + featureType: "landscape.man_made", + elementType: "geometry", + stylers: [{ color: "#f0f0f0" }], + }, + { + featureType: "poi", + elementType: "labels", + stylers: [{ visibility: "off" }], + }, + { + featureType: "transit.station", + elementType: "labels", + stylers: [{ visibility: "off" }], + }, + { + featureType: "water", + elementType: "labels", + stylers: [{ visibility: "off" }], + }, + ] + + const foodStyles = [ + { + featureType: "landscape.man_made", + elementType: "geometry", + stylers: [{ color: "#f0f0f0" }], + }, + { + featureType: "transit.station", + elementType: "labels", + stylers: [{ visibility: "off" }], + }, + { + featureType: "water", + elementType: "labels", + stylers: [{ visibility: "off" }], + }, + ] + + const brussels = { + latitude: 50.8477, + longitude: 4.3572, + zoom: 11, + styles: trafficStyles, + showTraffic: true + }; + + const charleroi = { + latitude: 50.4081, + longitude: 4.4476, + zoom: 11, + styles: trafficStyles, + showTraffic: true + }; + + const liege = { + latitude: 50.6402, + longitude: 5.5689, + zoom: 12, + styles: trafficStyles, + showTraffic: true + }; + + const belgium = { + latitude: 50.5039, + longitude: 4.4699, + zoom: 8, + styles: trafficStyles, + showTraffic: true + + }; + + const ayes = { + latitude: 50.82909981139743, + longitude: 4.36146805513083, + zoom: 15, + styles: foodStyles, + showTraffic: false + }; + + const commonLocations = [ brussels, charleroi, liege, belgium ]; + const midDayLocations = [ brussels, charleroi, liege, belgium, ayes ]; + + var startDate = new Date(); + startDate.setHours(11); + startDate.setMinutes(30); + + var endDate = new Date(); + endDate.setHours(12); + endDate.setMinutes(15); + + waitForElement('#map').then((mapElement) => { + initMap(mapElement, commonLocations[0]); + + var i = 0; + + window.setInterval(function() { + const currentDate = new Date(); + + if (startDate < currentDate && endDate > currentDate) { + i = (i + 1) % midDayLocations.length; + initMap(mapElement, midDayLocations[i]); + } else { + i = (i + 1) % commonLocations.length; + initMap(mapElement, commonLocations[i]); + } + }, 10000); + }); +} + +Module.register("streetmap", { + defaults: { }, + + start: function() { + this.sendNotification("SHOW_ALERT", { + type: "notification", + title: "Street Map!", + message: "Module is loaded!" + }); + + initMapRoutine(); + }, + + getStyles() { + return ["streetmap.css"]; + }, + + getScripts() { + return []; + }, + + getTemplate() { + return "streetmap.html"; + } +}); \ No newline at end of file diff --git a/package.json b/package.json index 949306ca8e..c79531db30 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/MagicMirrorOrg/MagicMirror" + "url": "https://github.com/AndreaCalabro-AYES/MagicMirror" }, "keywords": [ "magic mirror", @@ -49,6 +49,7 @@ }, "homepage": "https://magicmirror.builders", "devDependencies": { + "electron": "^29.1.6", "@stylistic/eslint-plugin": "^1.7.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^27.9.0", @@ -67,12 +68,10 @@ "stylelint-config-standard": "^36.0.0", "stylelint-prettier": "^5.0.0" }, - "optionalDependencies": { - "electron": "^29.1.6" - }, "dependencies": { "ansis": "^2.3.0", "console-stamp": "^3.1.2", + "electron": "^29.1.6", "envsub": "^4.1.0", "eslint": "^8.57.0", "express": "^4.19.2", @@ -87,7 +86,9 @@ "pm2": "^5.3.1", "socket.io": "^4.7.5", "suncalc": "^1.9.0", - "systeminformation": "^5.22.6" + "systeminformation": "^5.22.6", + "mqtt": "4.3.7", + "jsonpath-plus": "5.0.1" }, "lint-staged": { "*": "prettier --write", diff --git a/splashscreen/splash.png b/splashscreen/splash.png index b2acc49539..d26034e020 100644 Binary files a/splashscreen/splash.png and b/splashscreen/splash.png differ diff --git a/splashscreen/splash_halt.png b/splashscreen/splash_halt.png index dcf9d8be0d..386701bec8 100644 Binary files a/splashscreen/splash_halt.png and b/splashscreen/splash_halt.png differ