├── .dockerignore ├── .jscsrc ├── DOCKER.md ├── Dockerfile ├── Dockerfile-rpi ├── Dockerfile.alpine ├── LICENSE ├── README.md ├── Tasmosta.md ├── WindowsInstall.md ├── _config.example.yml ├── _config.yml ├── _devices.example.yml ├── _smartapp.example.md ├── bin └── mqtt-bridge-smartthings ├── devicetypes └── gupta │ ├── combo.src │ ├── ComboContactSensorSwitch.groovy │ └── ComboSwitchContactSensor.groovy │ └── mqtt │ ├── mbs-bridge.src │ └── mbs-bridge.groovy │ ├── tasmota.src │ ├── TasmotaContactSensor.groovy │ ├── TasmotaSensorSwitch.groovy │ ├── TasmotaSwitch.groovy │ └── TasmotaSwitchSensor.groovy │ └── thermostat.src │ └── MQTTThermostat.groovy ├── docker-compose.yml ├── mbs-server.js ├── package.json ├── smartapps └── gupta │ └── mqtt │ └── mbs-smartapp.src │ ├── mbs-smartapp-full.groovy │ └── mbs-smartapp-lite.groovy ├── test ├── mbs-server-json.js ├── mbs-server-retain-test.js ├── mbs-server-retries.js ├── mbs-server-v2.js └── mbs-server.test.js └── windows service ├── Mqtt-Bridge-Smartthings-Service.txt ├── Mqtt-Bridge-Smartthings.ini └── README.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | config 4 | devicetypes 5 | helpers 6 | smartapps 7 | windows service 8 | test 9 | .jscsrc 10 | .git 11 | .gitignore 12 | *.log 13 | Dockerfile* 14 | docker-compose* 15 | *.md 16 | LICENSE 17 | .vscode -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "node-style-guide", 3 | "disallowMultipleVarDecl": false, 4 | "validateIndentation": 4, 5 | "disallowTrailingComma": true, 6 | "requireTrailingComma": false, 7 | "disallowSpacesInFunction": false, 8 | "requireSpacesInFunction": { 9 | "beforeOpeningCurlyBrace": true, 10 | "beforeOpeningRoundBrace": true 11 | }, 12 | "maximumLineLength": 120, 13 | "excludeFiles": [ 14 | "artifacts/**" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /DOCKER.md: -------------------------------------------------------------------------------- 1 | # Docker Setup 2 | ## On Windows - using Docker Desktop 3 | 4 | Windows Defender Firewall with Advanced Security, the following rule needs to be created: 5 | 6 | Type: Inbound 7 | Program: C:\Program Files\Docker\Docker\resources\com.docker.backend.exe 8 | Allow all connections 9 | 10 | Volume mapping 11 | ```- 12 | make sure the directory that you are going to map to /mbs/config in docker has the appropriate config.yml and devices.yml 13 | For e.g. for "d:/data/docker/volumes/mbs:/mbs/config" the files need to be in d:/data/docker/volumes/mbs 14 | ``` 15 | for alpine distro 16 | ``` 17 | docker pull mbs-alpine sgupta99/mqtt-bridge-smartthings:1.0.3-alpine 18 | docker run -p:8080:8080 -v d:/data/docker/volumes/mbs:/mbs/config -e TZ=America/Chicago --name mbs-alpine sgupta99/mqtt-bridge-smartthings:1.0.3-alpine 19 | ``` 20 | for raspberry pi distro 21 | ``` 22 | docker pull mbs-alpine sgupta99/mqtt-bridge-smartthings:1.0.3-rpi 23 | docker run -p:8080:8080 -v d:/data/docker/volumes/mbs:/mbs/config -e TZ=America/Chicago --name mbs-rpi sgupta99/mqtt-bridge-smartthings:1.0.3-rpi 24 | ``` 25 | The RPI distro is about 768MB and Alpine is about 136MB - so if running on windows I would go for the alpine distro 26 | ## Some helpful commands for widows users not familiar with docker 27 | ``` 28 | docker image ls 29 | docker image rm -f 30 | docker container ls -a 31 | docker container stop 32 | docker container start -i (starts in interactive mode 33 | docker container rm 34 | Examples 35 | docker stop mbs-alpine 36 | docker start -i mbs-alpine 37 | ``` 38 | Container names in the above examples are mbs-alpine and mbs-rpi for example 39 | 40 | 41 | ## On linux platforms - (I have not tested then but should work) 42 | 43 | make sure linux firewall rules are set appropriately 44 | 45 | Volume mapping - 46 | ``` 47 | make sure the directory that you are going to map to /mbs/config in docker has the appropriate config.yml and devices.yml 48 | For e.g. for "/home/pi/docker/volumes/mbs:/mbs/config" the files need to be in /home/pi/docker/volumes/mbs 49 | ``` 50 | for alpine distro 51 | ``` 52 | docker pull mbs-alpine sgupta99/mqtt-bridge-smartthings:1.0.3-alpine 53 | docker run -p:8080:8080 \ 54 | -v /home/pi/docker/volumes/mbs:/mbs/config \ 55 | -e TZ=America/Chicago \ 56 | --name mbs-alpine \ 57 | sgupta99/mqtt-bridge-smartthings:1.0.3-alpine 58 | ``` 59 | for raspberry pi distro 60 | ``` 61 | docker pull mbs-alpine sgupta99/mqtt-bridge-smartthings:1.0.3-rpi 62 | docker run -p:8080:8080 \ 63 | -v /home/pi/docker/volumes/mbs:/mbs/config \ 64 | -e TZ=America/Chicago \ 65 | --name mbs-rpi 66 | sgupta99/mqtt-bridge-smartthings:1.0.3-rpi 67 | ``` 68 | The RPI distro is about 768MB and Alpine is about 136MB - so I would still go for the alpine distro 69 | 70 | # Docker Compose 71 | 72 | If you want to bundle everything together, you can use [Docker Compose](https://docs.docker.com/compose/). This will install and run both mosquitto and MBS - you still need to make sure mosquitto.conf and mbs config files are in the right directories. 73 | 74 | Just create a file called docker-compose.yml with these contents: 75 | ``` 76 | mqtt: 77 | image: eclipse-mosquitto 78 | container_name: mqtt 79 | environment: 80 | - TZ=America/Chicago 81 | volumes: 82 | - D:/data/docker/volumes/mosquitto/config:/mosquitto/config 83 | - D:/data/docker/volumes/mosquitto/data:/mosquitto/data 84 | - D:/data/docker/volumes/mosquitto/log:/mosquitto/log 85 | ports: 86 | - 1883:1883 87 | - 9001:9001 88 | 89 | mqttbridge: 90 | image: sgupta99/mqtt-bridge-smartthings:1.0.3-alpine 91 | container_name: mbs 92 | environment: 93 | - TZ=America/Chicago 94 | volumes: 95 | - D:/data/docker/volumes/mbs:/mbs/config 96 | ports: 97 | - 8080:8080 98 | ``` 99 | 100 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | MAINTAINER Sandeep Gupta 3 | 4 | # Create our application direcory 5 | RUN mkdir -p /usr/src/app 6 | WORKDIR /usr/src/app 7 | 8 | # Expose Configuration Volume 9 | VOLUME /mbs/config 10 | 11 | # Copy and install dependencies 12 | COPY package.json /usr/src/app/ 13 | COPY *.yml /mbs/config/ 14 | RUN npm install --production 15 | 16 | # Copy everything else 17 | COPY . /usr/src/app 18 | 19 | # Set config directory 20 | ENV CONFIG_DIR=/mbs/config 21 | 22 | # Expose the web service port 23 | EXPOSE 8080 24 | 25 | # Run the service 26 | CMD [ "npm", "start" ] 27 | -------------------------------------------------------------------------------- /Dockerfile-rpi: -------------------------------------------------------------------------------- 1 | FROM arm32v7/node:10 2 | MAINTAINER Sandeep Gupta 3 | 4 | # Create our application direcory 5 | RUN mkdir -p /usr/src/app 6 | WORKDIR /usr/src/app 7 | 8 | # Expose Configuration Volume 9 | VOLUME /mbs/config 10 | 11 | # Copy and install dependencies 12 | COPY package.json /usr/src/app/ 13 | COPY *.yml /mbs/config/ 14 | RUN npm install --production 15 | 16 | # Copy everything else 17 | COPY . /usr/src/app 18 | 19 | # Set config directory 20 | ENV CONFIG_DIR=/mbs/config 21 | 22 | # Expose the web service port 23 | EXPOSE 8080 24 | 25 | # Run the service 26 | CMD [ "npm", "start" ] 27 | 28 | -------------------------------------------------------------------------------- /Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | MAINTAINER Sandeep Gupta 3 | 4 | # Create our application direcory 5 | RUN mkdir -p /usr/src/app 6 | WORKDIR /usr/src/app 7 | 8 | # Expose Configuration Volume 9 | VOLUME /mbs/config 10 | 11 | # Copy and install dependencies 12 | COPY package.json /usr/src/app/ 13 | COPY *.yml /mbs/config/ 14 | RUN npm install --production 15 | 16 | # Copy everything else 17 | COPY . /usr/src/app 18 | 19 | # Set config directory 20 | ENV CONFIG_DIR=/mbs/config 21 | 22 | # Expose the web service port 23 | EXPOSE 8080 24 | 25 | # Run the service 26 | CMD [ "npm", "start" ] 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 St. John Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MQTT Bridge to SmartThings [MBS] 2 | ***Broker between Smartthings and MQTT Broker.*** 3 | 4 | [![GitHub Tag](https://img.shields.io/github/tag/sgupta999/mqtt-bridge-smartthings.svg)](https://github.com/sgupta999/mqtt-bridge-smartthings/releases) 5 | [![Docker Pulls](https://img.shields.io/docker/pulls/sgupta99/mqtt-bridge-smartthings.svg)](https://hub.docker.com/r/sgupta99/mqtt-bridge-smartthings/) 6 | [![Docker Stars](https://img.shields.io/docker/stars/sgupta99/mqtt-bridge-smartthings.svg)](https://hub.docker.com/r/sgupta99/mqtt-bridge-smartthings/) 7 | 8 | This is an upate from the [smartthings-mqtt-broker](https://github.com/stjohnjohnson/smartthings-mqtt-bridge) orginally developed by St. John Johnson. (https://github.com/stjohnjohnson/smartthings-mqtt-bridge). I have borrowed with abandon from his work, both in compiling this readme; and the server/client codeset, so a big thank you to him. 9 | 10 | The primary motivation was having a pure MQTT solution for Tasmota devices in Smartthings. Unfortunately, the old solution was more targeted towards HASS-IO integration. Most of the architecture and basic concepts remain the same but significant refactoring and changes have been made. 11 | 12 | # Architecture 13 | 14 | ![Architecture](https://www.websequencediagrams.com/cgi-bin/cdraw?lz=dGl0bGUgU21hcnRUaGluZ3MgPC0-IE1RVFQgCgpwYXJ0aWNpcGFudCBaV2F2ZSBMaWdodAoKAAcGTW90aW9uIERldGVjdG9yLT5TVCBIdWI6ABEIRXZlbnQgKFotV2F2ZSkKABgGACEFTVFUVEJyaWRnZSBBcHA6IERldmljZSBDaGFuZ2UAMAhHcm9vdnkAMwUAIg4AMxAAOAY6IE1lc3NhADYKSlNPTgAuEABjBi0-AHYLU2VyADkGAHAVUkVTVCkKAB0SAD0GIEJyb2tlcgCBaQk9IHRydWUgKE1RVFQpCgAyBQAcBwBdFgCCSgUgPSAib24iAC4IAFgUAIFaFgCBFhsAgWAWAIJnEwCCESMAgmoIAINWBVR1cm4AgTAHT24AgxcNAINXBQCEGwsAgVYIT24Ag3oJ&s=default) 15 | 16 | # Compatibility 17 | The new server should be fully backward compatible. If you have been using the old library you should still be able to use and avail of all the new functionality. 18 | 19 | # Updates 20 | 1. An external devices YAML config file has been introduced. It allows to define any custom mapping between and smartthings [device][attribute][command] and MQTT [topic][payload] and vice-versa. 21 | 2. A device can subscribe and publish to any number of topics 22 | 3. MQTT wildcards can be used in subscribe topics 23 | 4. Within smartthings. smartapp and device handlers have a generic processMQTT method to process subscription messages. Smartthings attribute 'events' are use to publish messages to MQTT broker 24 | 5. The logging and configurations have beem significantly streamlined. 'Log' and 'Data' directories store logs and state information 25 | 6. All dependencies have been updated to the latest versions. 26 | 7. The use case tested was for primarily Tasmota devices. I have a lite and full version of the SmartApp and sample tasmota Device Type Handlers. Use the 'lite' version if you are primary interested in configuring devices using the external device config file and primarily using Tasmota device handlers provided with this package. 27 | 8. The Tasmota Device Type Handlers periodically update other information from device like SSID, RSSI, LWT etc. If you use the SmartThings virtual switches or contact sensors they will just process the commands 28 | 29 | 30 | # MQTT Events Flow 31 | While the original flow is preserved as is - a new flow has been introduced to make the bridge more flexible. Please read the original readme on the previous project for the original flow. The new flow is as follows: 32 | 33 | 1. Smartthings events are generated from attributes - both standard and custom. You may choose to define custom attriutes for handling functionality beyond standard attributes - e.g combo devices or more functional tiles 34 | 2. Within the 'MBS - SmartApp' you describe a capability map and specify which attribute(s) (and corresponding event(s)), the SmartApp will subscribe to. 35 | 3. For every event Smartthings generates a [device][attribute][command] event package that the SmartApp is subscribed to and sends it to the mbs-server. 36 | 4. The devices.yml config file maintains a mapping of [device][attribute][command] to the MQTT [topic][payload] to be published. 37 | 5. Once the server receives the event from Smartthings, if the device is in the config file it uses the mapping to publish to MQTT broker. If the device is not in the device config file it assumes the standard old flow and publishes a message {PREFACE}/{DEVICE_NAME}/${ATTRIBUTE}/SUFFIX corresponding to the old flow. 38 | 6. On the flip side, when the server receives an MQTT message from the broker, it checks the device config file for mapping of [topic][payload] to [device][attribute][command] and sends the command back to Smartthings. If device is not found it follows the old flow. 39 | 7. Smartthings MBS-SmartApp maintains mappings for each capability of what attribute[event] to subscribe (for publishing to MQTT Broker) and what action method to call for an event received form the MQTT broker via the MBS-Server. See [_smartapp.example.md](https://github.com/sgupta999/mqtt-bridge-smartthings/blob/master/_smartapp.example.md) 40 | 41 | # [SmartApp example](https://github.com/sgupta999/mqtt-bridge-smartthings/blob/master/_smartapp.example.md). 42 | Please read this to ensure appropriate configuration. 43 | The MBS-SmartApp controls the mappings between the Devices and the Server config. A lot of flexibility for advanced configuration has been built in, but it can also be used without any configuration for the basic switches and sensors. 44 | 45 | # Configuration 46 | 47 | The bridge has two yaml files for configuration. The config files need to be stored in the config directory. You can specify a CONFIG_DIR environment variable to specify where the config directory or it will default to locating the config directory in the same folder where mbs-server. This is a change form vesions 1.02 and earlier where confif files were not in a separate sub-directory 48 | 49 | [config.yml](https://github.com/sgupta999/mqtt-bridge-smartthings/blob/master/config.yml) 50 | ========== 51 | ``` 52 | --- 53 | # Port number to listen on 54 | port: 8080 55 | 56 | #Default (info) - error, warn, info, verbose, debug, silly 57 | loglevel: "info" 58 | 59 | #is there an external device config file: true, false 60 | deviceconfig: true 61 | 62 | mqtt: 63 | # Specify your MQTT Broker URL here 64 | host: mqtt://localhost 65 | # Example from CloudMQTT 66 | # host: mqtt:///m10.cloudmqtt.com:19427 67 | 68 | # Preface for the topics $PREFACE/$DEVICE_NAME/$PROPERTY 69 | preface: smartthings 70 | 71 | # The write and read suffixes need to be different to be able to differentiate when state comes from SmartThings or when its coming from the physical device/application 72 | 73 | # Suffix for the topics that receive state from SmartThings $PREFACE/$DEVICE_NAME/$PROPERTY/$STATE_READ_SUFFIX 74 | # Your physical device or application should subscribe to this topic to get updated status from SmartThings 75 | # state_read_suffix: state 76 | 77 | # Suffix for the topics to send state back to SmartThings $PREFACE/$DEVICE_NAME/$PROPERTY/$STATE_WRITE_SUFFIX 78 | # your physical device or application should write to this topic to update the state of SmartThings devices that support setStatus 79 | # state_write_suffix: set_state 80 | 81 | # Suffix for the command topics $PREFACE/$DEVICE_NAME/$PROPERTY/$COMMAND_SUFFIX 82 | # command_suffix: cmd 83 | 84 | # Other optional settings from https://www.npmjs.com/package/mqtt#mqttclientstreambuilder-options 85 | # username: AzureDiamond 86 | # password: hunter2 87 | 88 | # MQTT retains state changes be default, retain mode can be disabled: 89 | # retain: false 90 | 91 | 92 | ``` 93 | [devices.yml](https://github.com/sgupta999/mqtt-bridge-smartthings/blob/master/_devices.example.yml) 94 | =========== 95 | ``` 96 | --- 97 | # Look for actual scenarios at the end without comments 98 | # Complete example of complex device setup with multiple subscriptions and commands 99 | Living Room Light: 100 | # device name - make sure it is exactly the same as in smartthings 101 | attribute: switch 102 | # REQUIRED: mapped to an actual attribute of device [e.g. switch, contact or any custom attribute 103 | # this attribute is specified in the capability map section of the mbs-smartapp 104 | # an attribute is required for each topic subbscription 105 | subscribe: 106 | # topic details to which smartthings will be subscribed 107 | # (topic, payload) from MQTT will be transformed to (device, attribute, payload*) to smartthings 108 | smartthings/stat/sonoff-1/POWER: 109 | # OPTIONAL: subscribe to this topic, for tasmota you really need it to get status updates for third party on/off 110 | command: 111 | # OPTIONAL: Translate payload coming from MQTT to this new payload* send to smartthings 112 | # For e.g. here OFF command published from MQTT will be sent as off (lowercase) to smartthings 113 | # if not set payload from MQTT is sent as is 114 | 'OFF': 'off' 115 | 'ON': 'on' 116 | smartthings/stat/sonoff-1/STATUS: 117 | # You can subscribe to as many topics 118 | smartthings/stat/sonoff-1/STATUS2: 119 | smartthings/stat/sonoff-1/STATUS5: 120 | smartthings/stat/sonoff-1/STATUS11: 121 | publish: 122 | # OPTIONAL: commands (device, attribute, payload) from smartthings is send to MQTT as (topic, payload*) 123 | switch: 124 | #REQUIRED: attribute specified in the capability map section of the mbs-smartapp 125 | topic: smartthings/cmnd/sonoff-1/POWER 126 | # REQUIRED: topic to be published to MQTT 127 | command: 128 | # REQUIRED: transforming payload from smartthings to the one sent to MQTT and physical device 129 | 'off': 'OFF' 130 | 'on': 'ON' 131 | update: 132 | topic: smartthings/cmnd/sonoff-1/Backlog 133 | command: 134 | # tasmota specific example of using Backlog to send multiple simultaneous commands to physical device 135 | refresh: Status; Status 2; Status 5; Status 11 136 | retain: 'false' 137 | # false set as default and here 138 | ``` 139 | 140 | # Installation 141 | 142 | ## Docker Setup 143 | 144 | since version 1.0.3 I have uploaded docker images for alpine linux distro and raspberry pi distro to docker hub. Please see detailed instructions for Docker installation in [DOCKER.md](https://github.com/sgupta999/mqtt-bridge-smartthings/blob/master/DOCKER.md) 145 | 146 | ## NPM 147 | 148 | To install the module, just use `npm`: 149 | ``` 150 | $ npm install -g mqtt-bridge-smartthings 151 | ``` 152 | 153 | If you want to run it, you can simply call the binary: 154 | ``` 155 | $ mqtt-bridge-smartthings 156 | Starting SmartThings MQTT Bridge - v1.0.1 157 | Loading configuration 158 | No previous configuration found, creating one 159 | ``` 160 | 161 | If you are interested in running it on windows as a server the windows service directory has instructions and sample .ini file and .bat file with commands. 162 | 163 | ## Usage 164 | 1. Customize the MQTT host and devices 165 | ``` 166 | $ vi config.yml 167 | $ vi devices.yml 168 | # Restart the service to get the latest changes 169 | ``` 170 | 171 | 2. Install the [MBS-Bridge Device Handler](https://github.com/sgupta999/mqtt-bridge-smartthings/blob/master/devicetypes/gupta/mqtt/mbs-bridge.src/mbs-bridge.groovy) in the [Device Handler IDE][ide-dt] using "Create via code" 172 | 3. Add the "MQTT Bridge" device in the [My Devices IDE][ide-mydev]. Enter MQTT Bridge (or whatever) for the name. Select "MBS Bridge" for the type. 173 | 4. Configure the "MQTT Bridge" in the [My Devices IDE][ide-mydev] with the IP Address, Port, and MAC Address of the machine running the mbs-server processm service or docker container 174 | 5. If ST is receiving messages from the bridge but the bridge is not receiving any messages from ST then most liley IP Address, Port, and MAC Address configuration is not correct 175 | 6. Install the [MBS SmartApp Lite](https://github.com/sgupta999/mqtt-bridge-smartthings/blob/master/smartapps/gupta/mbs-smartapp.src/mbs-smartapp-lite.groovy) or [MBS SmartApp Full](https://github.com/sgupta999/mqtt-bridge-smartthings/blob/master/smartapps/gupta/mbs-smartapp.src/mbs-smartapp-full.groovy)on the [Smart App IDE][ide-app] using "Create via code" 176 | 7. If using a Tasmota device install the [Tasmota SwitchSensor] or any other Tamota device from the [Tasmota Device Type] folder. 177 | 8. Configure the Smart App (via the Native App) with the devices you want to subscribe to and the bridge that you just installed 178 | 9. Via the Native App, select your MQTT device and watch as device is populated with events from your devices 179 | 180 | 181 | 182 | -------------------------------------------------------------------------------- /Tasmosta.md: -------------------------------------------------------------------------------- 1 | # If you are using sonoff’s 2 | 1. Enable MQTT 3 | 2. Configure MQTT to you broker. 4 | 3. Set the unique topic like “SNF-M1” etc, for each sonoff (This topic should also match with the topics in the devices.yml) 5 | 4. For full topic I just prepend smartthings as "smartthings/%prefix%/%topic%" 6 | 5. Also make sure to set the timezones etc otherwise your timestamps will be wrong. From the web console I run the following backlog commands 7 | ``` 8 | Backlog ntpServer1 0.us.pool.ntp.org; ntpServer2 1.us.pool.ntp.org; ntpServer3 2.us.pool.ntp.org; Sleep 250; TIMEDST 0,2,3,1,2,-300; TIMESTD 0,1,11,1,2,-360; Timezone 99; WifiConfig 2; Latitude xx.xxx; Longitude -xx.xxx; SetOption55 0 9 | Backlog setoption53 1; powerretain on;SwitchRetain off; ButtonRetain on; ButtonRetain off 10 | ``` 11 | Timezone 99 is CST for me. Enter your specific longitude / latitude (that gets local sunrise /sunset) 12 | 13 | # If you are using RF Bridge 14 | run this rule on the RFBridge to broadcast 'rfsensor' payload for any RFReceived event 15 | ``` 16 | rule1 on rfreceived#data do publish rfbridge/%value% rfsensor endon 17 | ``` -------------------------------------------------------------------------------- /WindowsInstall.md: -------------------------------------------------------------------------------- 1 | # Windows Installation Instructions 2 | 3 | 1. On windows command propmt (I usually run in administrative mode) cd into the nodejs directory (C:\Program Files\nodejs) 4 | 2. from that directory run npm install -g mqtt-bridge-smartthings 5 | This will automatically download all the dependencies and setup - other installed packages should not make a difference. 6 | 3. In windows explorer go to C:\Program Files\nodejs\node_modules\mqtt-bridge-smartthings and there create ‘config.yml’ and ‘devices.yml’ for your situation using the examples. 7 | 4. Go back to the command prompt and from the same directory C:\Program Files\nodejs run the command mqtt-bridge-smartthings 8 | 9 | this should start the server and you should see the logging on the console. 10 | Then follow the directions for smartthings IDE to set up the device handlers, the bridge and the smartapp. 11 | Once you are comfortable everything is working fine you an set the process to run as a service (See the windows service folder for the readme) so it automatically stars and everything should be handled automatically. 12 | 13 | -------------------------------------------------------------------------------- /_config.example.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Port number to listen on 3 | port: 8080 4 | 5 | #Default (info) - error, warn, info, verbose, debug, silly 6 | loglevel: "info" 7 | 8 | #is there an external device config file: true, false 9 | deviceconfig: true 10 | 11 | mqtt: 12 | # Specify your MQTT Broker's hostname or IP address here 13 | host: "mqtt://192.168.1.2" 14 | # Preface for the topics $PREFACE/$DEVICE_NAME/$PROPERTY 15 | preface: smartthings 16 | 17 | # These are legacy setting for integration for ha or hassio. Never used them but retained for anybody who has been using the bridge 18 | 19 | # The write and read suffixes need to be different to be able to differentiate when state comes from SmartThings or when its coming from the physical device/application 20 | 21 | # Suffix for the topics that receive state from SmartThings $PREFACE/$DEVICE_NAME/$PROPERTY/$STATE_READ_SUFFIX 22 | # Your physical device or application should subscribe to this topic to get updated status from SmartThings 23 | #state_read_suffix: state 24 | 25 | # Suffix for the topics to send state back to SmartThings $PREFACE/$DEVICE_NAME/$PROPERTY/$STATE_WRITE_SUFFIX 26 | # your physical device or application should write to this topic to update the state of SmartThings devices that support setStatus 27 | # state_write_suffix: set_state 28 | 29 | # Suffix for the command topics $PREFACE/$DEVICE_NAME/$PROPERTY/$COMMAND_SUFFIX 30 | #command_suffix: cmd 31 | 32 | # Other optional settings from https://www.npmjs.com/package/mqtt#mqttclientstreambuilder-options 33 | # username: AzureDiamond 34 | # password: hunter2 35 | retain: false 36 | 37 | 38 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | mqtt: 3 | # Specify your MQTT Broker's hostname or IP address here 4 | host: mqtt://localhost 5 | # Preface for the topics $PREFACE/$DEVICE_NAME/$PROPERTY 6 | preface: smartthings 7 | 8 | # The write and read suffixes need to be different to be able to differentiate when state comes from SmartThings or when its coming from the physical device/application 9 | 10 | # Suffix for the topics that receive state from SmartThings $PREFACE/$DEVICE_NAME/$PROPERTY/$STATE_READ_SUFFIX 11 | # Your physical device or application should subscribe to this topic to get updated status from SmartThings 12 | # state_read_suffix: state 13 | 14 | # Suffix for the topics to send state back to SmartThings $PREFACE/$DEVICE_NAME/$PROPERTY/$STATE_WRITE_SUFFIX 15 | # your physical device or application should write to this topic to update the state of SmartThings devices that support setStatus 16 | # state_write_suffix: set_state 17 | 18 | # Suffix for the command topics $PREFACE/$DEVICE_NAME/$PROPERTY/$COMMAND_SUFFIX 19 | # command_suffix: cmd 20 | 21 | # Other optional settings from https://www.npmjs.com/package/mqtt#mqttclientstreambuilder-options 22 | # username: AzureDiamond 23 | # password: hunter2 24 | 25 | # Port number to listen on 26 | port: 8080 27 | 28 | #Default (info) - error, warn, info, verbose, debug, silly 29 | loglevel: "info" 30 | 31 | #is there an external device config file: true, false 32 | deviceconfig: false 33 | -------------------------------------------------------------------------------- /_devices.example.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Look for actual scenarios at the end without comments 3 | # Complete example of complex device setup with multiple subscriptions and commands 4 | Living Room Light: 5 | # device name - make sure it is exactly the same as in smartthings 6 | subscribe: 7 | # topic details to which smartthings will be subscribed 8 | # (topic, payload) from MQTT will be transformed to (device, attribute, payload*) to smartthings 9 | switch: 10 | # REQUIRED: mapped to an actual attribute of device [e.g. switch, contact or any custom attribute 11 | # this attribute is specified in the capability map section of the mbs-smartapp 12 | # an attribute is required for each topic subbscription 13 | smartthings/stat/sonoff-1/POWER: 14 | # OPTIONAL: subscribe to this topic, for tasmota you really need it to get status updates for third party on/off 15 | command: 16 | # OPTIONAL: Translate payload coming from MQTT to this new payload* send to smartthings 17 | # For e.g. here OFF command published from MQTT will be sent as off (lowercase) to smartthings 18 | # if not set payload from MQTT is sent as is 19 | 'OFF': 'off' 20 | 'ON': 'on' 21 | smartthings/stat/sonoff-1/STATUS: 22 | # You can subscribe to as many topics 23 | smartthings/stat/sonoff-1/STATUS2: 24 | smartthings/stat/sonoff-1/STATUS5: 25 | smartthings/stat/sonoff-1/STATUS11: 26 | publish: 27 | # OPTIONAL: commands (device, attribute, payload) from smartthings is send to MQTT as (topic, payload*) 28 | switch: 29 | #REQUIRED: attribute specified in the capability map section of the mbs-smartapp 30 | smartthings/cmnd/sonoff-1/POWER: 31 | # REQUIRED: topic to be published to MQTT 32 | command: 33 | # REQUIRED: transforming payload from smartthings to the one sent to MQTT and physical device 34 | 'off': 'OFF' 35 | 'on': 'ON' 36 | update: 37 | smartthings/cmnd/sonoff-1/Backlog: 38 | command: 39 | # tasmota specific example of using Backlog to send multiple simultaneous commands to physical device 40 | refresh: Status; Status 2; Status 5; Status 11 41 | retain: 'false' 42 | # false set as default and here 43 | 44 | # 45 | #most simple tasmota switch using Smartthings Virtual Switch device Type 46 | Living Room Light: 47 | subscribe: 48 | switch: 49 | smartthings/stat/sonoff1/POWER: 50 | command: 51 | 'OFF': 'off' 52 | 'ON': 'on' 53 | publish: 54 | switch: 55 | smartthings/cmnd/sonoff1/POWER: 56 | retain: 'false' 57 | 58 | # 59 | 60 | # example of RF Switch handled by Tasmota RF Bridge using Smartthings Virtual Switch device Type 61 | # run this rule on the RFBridge to get command 'rfsensor' for any RFReceived event 62 | #rule1 on rfreceived#data do publish rfbridge/%value% rfsensor endon 63 | Living Room Light: 64 | subscribe: 65 | switch: 66 | rfbridge/555ABC: 67 | command: 68 | 'rfsensor': 'on' 69 | rfbridge/555ADC: 70 | command: 71 | 'rfsensor': 'off' 72 | publish: 73 | switch: 74 | cmnd/rfbridge/Backlog: 75 | command: 76 | 'on': 'RfSync 2390; RfLow 410; RfHigh 780; RfCode #555ABC' 77 | 'off': 'RfSync 2390; RfLow 2410; RfHigh 780; RfCode #555ADC' 78 | retain: 'false' 79 | # 80 | # RF Contact Sensor handled by Tasmota RF Bridge using Smartthings Simulated Contact Sensor device Type 81 | Mailbox: 82 | subscribe: 83 | contact: 84 | rfbridge/53620A: 85 | command: 86 | 'rfsensor': 'open' 87 | rfbridge/53620E: 88 | command: 89 | 'rfsensor': 'closed' 90 | 91 | 92 | # 93 | #Complete example of Sonoff module running Tasmota using my custom Tasmota SwitchSensor Device Type 94 | Living Room Light: 95 | subscribe: 96 | switch: 97 | smartthings/stat/sonoff-1/POWER: 98 | command: 99 | 'OFF': 'off' 100 | 'ON': 'on' 101 | update: 102 | smartthings/tele/sonoff-1/LWT: 103 | smartthings/stat/sonoff-1/STATUS: 104 | smartthings/stat/sonoff-1/STATUS2: 105 | smartthings/stat/sonoff-1/STATUS5: 106 | smartthings/stat/sonoff-1/STATUS11: 107 | publish: 108 | switch: 109 | smartthings/cmnd/sonoff-1/POWER: 110 | command: 111 | 'off': 'OFF' 112 | 'on': 'ON' 113 | update: 114 | smartthings/cmnd/sonoff-1/Backlog: 115 | command: 116 | refresh: Status; Status 2; Status 5; Status 11 117 | retain: 'false' 118 | 119 | 120 | # 121 | # Completely custom device using MQTT and custom device type 122 | #Electricity Meter 123 | Electricity Meter: 124 | subscribe: 125 | mqttmsg: 126 | smartthings/emu2/mqttmsg: 127 | demand: 128 | smartthings/emu2/demand: 129 | publish: 130 | mqttmsg: 131 | smartthings/emu2/msghandler: 132 | retain: 'true' -------------------------------------------------------------------------------- /_smartapp.example.md: -------------------------------------------------------------------------------- 1 | # MQTT Bridge to SmartThings [MBS-SmartApp] 2 | # Configuring the capability map 3 | To start you do not have to change anything here. Just use a Virtual SmartThings switch and configure the devices.yml on server. Once you are comfortable you can come to this sections for more advanced customizations 4 | ``` 5 | @Field CAPABILITY_MAP = [ 6 | // My custom device type 7 | "tasmotaSwitches": [ 8 | // filter name used on input screen 9 | name: "Tasmota Switch", 10 | // only one capability per device type to filter devices on input screen 11 | capability: "capability.switch", 12 | attributes: [ 13 | // any number of actual attributes used by devices filtered by capability above. 14 | // if attribute for device does not exist, command/update structure for that attribute for that device 15 | // will not work. 16 | "switch", 17 | "update" 18 | ], 19 | // When an event is received from the server, control will be passed to device if an action is defined here 20 | // If action is just single string only, that single action method will be invoked for all events received 21 | // from the server for all attributes 22 | // If action is defined as a Map like here, specific action method will be called for events received from server 23 | // for the specified attribute. If an attribute is not mapped to an action command in this map no action will be 24 | // taken on event received from server. 25 | action: [ 26 | switch: "actionOnOff", 27 | // in my custom handlers I am using 'update' as a catch-all attribute, and actionProcessMQTT as a catch-all action 28 | // command. All logic about how these specific commands are generated from SmartThings or events are handled from 29 | // server are handle by the Device Handler 30 | update: "actionProcessMQTT" 31 | ] 32 | ], 33 | // These could be standardized Smartthings virtual switches or any other device that has MQTT functionality implemented 34 | "switches": [ 35 | name: "Switch", 36 | capability: "capability.switch", 37 | attributes: [ 38 | "switch" 39 | ], 40 | action: "actionOnOff" 41 | ] 42 | ] 43 | ``` 44 | 45 | On the settings sections of the MBS SmartApp you will select the devices that you want to associate with each capability map. Make sure devices you associate with a specific capability map have thse associate 'attributes' and handlers defined. 46 | 47 | If you associate an 'action event' with all attributes or attribute specific action events - make sure the appropriate action events are already pre-defined in the SmartApp or make sure to define them. for e.g. 48 | ``` 49 | def actionProcessMQTT(device, attribute, value) { 50 | if ((device == null) || (attribute == null) || (value == null)) return; 51 | device.processMQTT(attribute, value); 52 | } 53 | 54 | def actionOnOff(device, attribute, value) { 55 | if (value == "off") { 56 | device.off() 57 | } else if (value == "on") { 58 | device.on() 59 | } 60 | } 61 | ``` 62 | Also make sure devices handlers for the devices have the functions being invoked implemented. For e.g. here my custom device handlers will have an implementation of processMQTT(attribute, value). The off() on() commands are typically implementation by any device handler with capability.switch. -------------------------------------------------------------------------------- /bin/mqtt-bridge-smartthings: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | process.chdir(__dirname + '/../'); 3 | console.log('Current Working Directory is ',process.cwd()); 4 | require('../mbs-server.js'); 5 | -------------------------------------------------------------------------------- /devicetypes/gupta/combo.src/ComboContactSensorSwitch.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Combo Contact Sensor (Primary) - Switch (Secondary) Device Handler 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * 7 | * Copyright 2019 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 10 | * in compliance with the License. You may obtain a copy of the License at: 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 15 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 16 | * for the specific language governing permissions and limitations under the License. 17 | */ 18 | 19 | metadata { 20 | 21 | definition (name: "Combo Contact Sensor Switch", namespace: "gupta", author: "Sandeep Gupta") { 22 | capability "Contact Sensor" 23 | capability "Switch" 24 | capability "Momentary" 25 | 26 | command "open" 27 | command "close" 28 | } 29 | 30 | simulator { 31 | status "open": "contact:open" 32 | status "closed": "contact:close" 33 | status "on": "switch:on" 34 | status "off": "switch:off" 35 | status "toggle": "momentary:push" 36 | } 37 | 38 | tiles { 39 | multiAttributeTile(name:"main", type: "generic"){ 40 | tileAttribute ("device.contact", key: "PRIMARY_CONTROL") { 41 | attributeState "open", label:'${name}', action: "close", icon:"st.contact.contact.open", backgroundColor:"#e86d13" 42 | attributeState "closed", label:'${name}', action: "open", icon:"st.contact.contact.closed", backgroundColor:"#00a0dc" 43 | } 44 | 45 | tileAttribute("device.switch", key: "SECONDARY_CONTROL") { 46 | attributeState("on", label: '${name}', action: "switch.off", icon: "st.switches.switch.on", backgroundColor: "#00A0DC") 47 | attributeState("off", label: '${name}', action: "switch.on", icon: "st.switches.switch.off", backgroundColor: "#ffffff") 48 | } 49 | } 50 | 51 | 52 | standardTile("switch", "device.switch", width: 2, height: 2) { 53 | state("on", label: '${name}', action: "switch.off", icon: "st.switches.switch.on", backgroundColor: "#00A0DC") 54 | state("off", label: '${name}', action: "switch.on", icon: "st.switches.switch.off", backgroundColor: "#ffffff") 55 | } 56 | 57 | 58 | standardTile("toggle", "device.switch", width: 2, height: 2) { 59 | state("on", label: 'Toggle', action: "push", icon: "st.switches.switch.on", backgroundColor: "#00A0DC") 60 | state("off", label: 'Toggle', action: "push", icon: "st.switches.switch.off", backgroundColor: "#ffffff") 61 | } 62 | 63 | valueTile("empty", "device.contact", width: 1, height: 4) { 64 | } 65 | 66 | 67 | main "main" 68 | details(["main", "switch","empty", "toggle"]) 69 | } 70 | 71 | preferences { 72 | section("Main") { 73 | input(name: "linked", type: "bool", title: "Link Switch and Contact Sensor", description: "", required: false) 74 | } 75 | } 76 | } 77 | 78 | 79 | def parse(String description) { 80 | log.debug "Parsing message is ${description}" 81 | def pair = description.split(":") 82 | switch(pair[0].trim()){ 83 | case 'switch': 84 | (pair[1].trim() == "on") ? on() : off(); 85 | break; 86 | case 'contact': 87 | (pair[1].trim() == "open") ? open() : close(); 88 | break; 89 | case 'momentary': 90 | if (pair[1].trim() == "push") push(); 91 | break; 92 | default: 93 | break; 94 | } 95 | } 96 | 97 | def on(){ 98 | if (device.currentValue("switch") == "on") return; 99 | _on(); 100 | } 101 | 102 | def off(){ 103 | if (device.currentValue("switch") == "off") return; 104 | _off(); 105 | } 106 | 107 | def open(){ 108 | if (device.currentValue("contact") == "open") return; 109 | _open(); 110 | } 111 | 112 | def close(){ 113 | if (device.currentValue("contact") == "closed") return; 114 | _close(); 115 | } 116 | 117 | def push() { 118 | (device.currentValue("switch") == "on") ? off() : on() 119 | log.debug "Sent 'TOGGLE' command for device: ${device}" 120 | } 121 | 122 | def _on() { 123 | sendEvent(name: "switch", value: "on") 124 | if (linked) sendEvent(name: "contact", value: "open") 125 | log.debug "Sent 'on' command for device: ${device}" 126 | } 127 | 128 | def _off() { 129 | sendEvent(name: "switch", value: "off") 130 | if (linked) sendEvent(name: "contact", value: "closed") 131 | log.debug "Sent 'off' command for device: ${device}" 132 | } 133 | 134 | def _open() { 135 | sendEvent(name: "contact", value: "open") 136 | if (linked) sendEvent(name: "switch", value: "on") 137 | log.debug "Sent 'open' command for device: ${device}" 138 | } 139 | 140 | def _close() { 141 | sendEvent(name: "contact", value: "closed") 142 | if (linked) sendEvent(name: "switch", value: "off") 143 | log.debug "Sent 'close' command for device: ${device}" 144 | } 145 | 146 | -------------------------------------------------------------------------------- /devicetypes/gupta/combo.src/ComboSwitchContactSensor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Tasmota Combo Switch (Primary) - Contact Sensor (Secondary) [Lite] a Device HandlerDevice Handler 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * 7 | * Copyright 2019 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 10 | * in compliance with the License. You may obtain a copy of the License at: 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 15 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 16 | * for the specific language governing permissions and limitations under the License. 17 | */ 18 | 19 | metadata { 20 | 21 | definition (name: "Combo Switch Contact Sensor", namespace: "gupta", author: "Sandeep Gupta") { 22 | capability "Switch" 23 | capability "Momentary" 24 | capability "Contact Sensor" 25 | 26 | command "open" 27 | command "close" 28 | } 29 | 30 | simulator { 31 | status "open": "contact:open" 32 | status "closed": "contact:close" 33 | status "on": "switch:on" 34 | status "off": "switch:off" 35 | status "toggle": "momentary:push" 36 | } 37 | 38 | tiles { 39 | multiAttributeTile(name:"main", type: "device.switch", width: 6, height: 4){ 40 | tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { 41 | attributeState "on", label: '${name}', action: "switch.off", icon: "st.switches.switch.on", backgroundColor: "#00A0DC" 42 | attributeState "off", label: '${name}', action: "switch.on", icon: "st.switches.switch.off", backgroundColor: "#ffffff" 43 | } 44 | 45 | tileAttribute("device.contact", key: "SECONDARY_CONTROL") { 46 | attributeState("closed", label:'${name}', icon:"st.contact.contact.closed", backgroundColor:"#79b821", action: "open") 47 | attributeState("open", label:'${name}', icon:"st.contact.contact.open", backgroundColor:"#ffa81e", action: "close") 48 | } 49 | } 50 | 51 | standardTile("contact", "device.contact", width: 2, height: 2) { 52 | state("closed", label:'${name}', icon:"st.contact.contact.closed", backgroundColor:"#79b821", action: "open") 53 | state("open", label:'${name}', icon:"st.contact.contact.open", backgroundColor:"#ffa81e", action: "close") 54 | } 55 | 56 | 57 | standardTile("toggle", "device.switch", width: 2, height: 2) { 58 | state("on", label: 'Toggle', action: "push", icon: "st.switches.switch.on", backgroundColor: "#00A0DC") 59 | state("off", label: 'Toggle', action: "push", icon: "st.switches.switch.off", backgroundColor: "#ffffff") 60 | } 61 | 62 | valueTile("empty", "empty", width: 2, height: 2) { 63 | } 64 | 65 | main "main" 66 | details(["main", "contact","empty", "toggle"]) 67 | } 68 | 69 | preferences { 70 | section("Main") { 71 | input(name: "linked", type: "bool", title: "Link Switch and Contact Sensor", description: "", required: false) 72 | } 73 | } 74 | } 75 | 76 | 77 | def parse(String description) { 78 | log.debug "Parsing message is ${description}" 79 | def pair = description.split(":") 80 | switch(pair[0].trim()){ 81 | case 'switch': 82 | (pair[1].trim() == "on") ? on() : off(); 83 | break; 84 | case 'contact': 85 | (pair[1].trim() == "open") ? open() : close(); 86 | break; 87 | case 'momentary': 88 | if (pair[1].trim() == "push") push(); 89 | break; 90 | default: 91 | break; 92 | } 93 | } 94 | 95 | 96 | def on(){ 97 | if (device.currentValue("switch") == "on") return; 98 | _on(); 99 | } 100 | 101 | def off(){ 102 | if (device.currentValue("switch") == "off") return; 103 | _off(); 104 | } 105 | 106 | def open(){ 107 | if (device.currentValue("contact") == "open") return; 108 | _open(); 109 | } 110 | 111 | def close(){ 112 | if (device.currentValue("contact") == "closed") return; 113 | _close(); 114 | } 115 | 116 | def push() { 117 | (device.currentValue("switch") == "on") ? off() : on() 118 | log.debug "Sent 'TOGGLE' command for device: ${device}" 119 | } 120 | 121 | def _on() { 122 | sendEvent(name: "switch", value: "on") 123 | if (linked) sendEvent(name: "contact", value: "open") 124 | log.debug "Sent 'on' command for device: ${device}" 125 | } 126 | 127 | def _off() { 128 | sendEvent(name: "switch", value: "off") 129 | if (linked) sendEvent(name: "contact", value: "closed") 130 | log.debug "Sent 'off' command for device: ${device}" 131 | } 132 | 133 | def _open() { 134 | sendEvent(name: "contact", value: "open") 135 | if (linked) sendEvent(name: "switch", value: "on") 136 | log.debug "Sent 'open' command for device: ${device}" 137 | } 138 | 139 | def _close() { 140 | sendEvent(name: "contact", value: "closed") 141 | if (linked) sendEvent(name: "switch", value: "off") 142 | log.debug "Sent 'close' command for device: ${device}" 143 | } 144 | 145 | -------------------------------------------------------------------------------- /devicetypes/gupta/mqtt/mbs-bridge.src/mbs-bridge.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * An MQTT bridge to SmartThings [MBS-Bridge] - SmartThings Bridge Device Handler 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * Derived from work of previous authors 7 | * - st.john.johnson@gmail.com 8 | * - jeremiah.wuenschel@gmail.com 9 | * 10 | * A lot of initial work was done by the previous two authors 11 | * There is significant refactoring and added functionality since Oct 2019. 12 | * 13 | * Copyright 2019 14 | * 15 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 16 | * in compliance with the License. You may obtain a copy of the License at: 17 | * 18 | * http://www.apache.org/licenses/LICENSE-2.0 19 | * 20 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 21 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 22 | * for the specific language governing permissions and limitations under the License. 23 | */ 24 | 25 | import groovy.json.JsonSlurper 26 | import groovy.json.JsonOutput 27 | 28 | metadata { 29 | definition (name: "MBS Bridge", namespace: "gupta/mqtt", author: "Sandeep Gupta") { 30 | capability "Notification" 31 | attribute "healthStatus", "string" 32 | } 33 | 34 | preferences { 35 | input("ip", "string", 36 | title: "MQTT Bridge IP Address", 37 | description: "MQTT Bridge IP Address", 38 | required: true, 39 | displayDuringSetup: true 40 | ) 41 | input("port", "string", 42 | title: "MQTT Bridge Port", 43 | description: "MQTT Bridge Port", 44 | required: true, 45 | displayDuringSetup: true 46 | ) 47 | input("mac", "string", 48 | title: "MQTT Bridge MAC Address", 49 | description: "MQTT Bridge MAC Address", 50 | required: true, 51 | displayDuringSetup: true 52 | ) 53 | } 54 | 55 | simulator {} 56 | 57 | tiles { 58 | valueTile("basic", "device.ip", width: 3, height: 2) { 59 | state("basic", label:'OK') 60 | } 61 | main "basic" 62 | } 63 | } 64 | 65 | def installed() { 66 | initialize() 67 | } 68 | 69 | def updated() { 70 | log.trace "Executing 'updated'" 71 | initialize() 72 | } 73 | 74 | def initialize() { 75 | log.trace "Executing 'configure'" 76 | sendEvent(name: "DeviceWatch-Enroll", value: [protocol: "cloud", scheme:"untracked"].encodeAsJson(), displayed: false) 77 | markDeviceOnline() 78 | setNetworkAddress() 79 | } 80 | 81 | def markDeviceOnline() { 82 | setDeviceHealth("online") 83 | } 84 | 85 | def markDeviceOffline() { 86 | setDeviceHealth("offline") 87 | } 88 | 89 | private setDeviceHealth(String healthState) { 90 | log.debug("healthStatus: ${device.currentValue('healthStatus')}; DeviceWatch-DeviceStatus: ${device.currentValue('DeviceWatch-DeviceStatus')}") 91 | // ensure healthState is valid 92 | healthState = ["online", "offline"].contains(healthState) ? healthState : device.currentValue("healthStatus") 93 | // set the healthState 94 | sendEvent(name: "DeviceWatch-DeviceStatus", value: healthState) 95 | sendEvent(name: "healthStatus", value: healthState) 96 | } 97 | 98 | 99 | // Store the MAC address as the device ID so that it can talk to SmartThings 100 | def setNetworkAddress() { 101 | // Setting Network Device Id 102 | def hex = "$settings.mac".toUpperCase().replaceAll(':', '') 103 | if (device.deviceNetworkId != "$hex") { 104 | device.deviceNetworkId = "$hex" 105 | log.debug "Device Network Id set to ${device.deviceNetworkId}" 106 | } 107 | } 108 | 109 | // Parse events from the Bridge 110 | def parse(String description) { 111 | setNetworkAddress() 112 | def msg = parseLanMessage(description) 113 | def message = new JsonOutput().toJson(msg.data) 114 | log.debug "Parsed '${message}'" 115 | return createEvent(name: "message", value: message, isStateChange: 'true') 116 | } 117 | 118 | // Send message to the Bridge 119 | def deviceNotification(message) { 120 | if (device.hub == null) 121 | { 122 | log.error "Hub is null, must set the hub in the device settings so we can get local hub IP and port" 123 | return 124 | } 125 | 126 | setNetworkAddress() 127 | log.debug "Sending '${message}' to server" 128 | 129 | def slurper = new JsonSlurper() 130 | def parsed = slurper.parseText(message) 131 | 132 | if (parsed.path == '/subscribe') { 133 | parsed.body.callback = device.hub.getDataValue("localIP") + ":" + device.hub.getDataValue("localSrvPortTCP") 134 | } 135 | 136 | def headers = [:] 137 | headers.put("HOST", "$ip:$port") 138 | headers.put("Content-Type", "application/json") 139 | 140 | def hubAction = new physicalgraph.device.HubAction( 141 | method: "POST", 142 | path: parsed.path, 143 | headers: headers, 144 | body: parsed.body 145 | ) 146 | hubAction 147 | } 148 | -------------------------------------------------------------------------------- /devicetypes/gupta/mqtt/tasmota.src/TasmotaContactSensor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Tasmota Contact Sensor Device Handler 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * 7 | * Version 1.0 - 11/17/2019 8 | * 9 | * Copyright 2019 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 12 | * in compliance with the License. You may obtain a copy of the License at: 13 | * 14 | * http://www.apache.org/licenses/LICENSE-2.0 15 | * 16 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 17 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 18 | * for the specific language governing permissions and limitations under the License. 19 | */ 20 | 21 | metadata { 22 | 23 | definition (name: "Tasmota Contact Sensor", namespace: "gupta/mqtt", author: "Sandeep Gupta") { 24 | capability "Contact Sensor" 25 | capability "Actuator" 26 | capability "Refresh" 27 | 28 | command "open" 29 | command "close" 30 | command "processMQTT" 31 | 32 | attribute "update", "string" 33 | attribute "device_details", "string" 34 | attribute "details", "string" 35 | attribute "wifi", "string" 36 | attribute "rssiLevel", "number" 37 | attribute "healthStatus", "string" 38 | } 39 | 40 | simulator { 41 | status "open": "contact:open" 42 | status "closed": "contact:close" 43 | } 44 | 45 | tiles { 46 | multiAttributeTile(name:"main", type: "generic", canChangeIcon: 'true', canChangeBackground : 'true' ){ 47 | tileAttribute ("device.contact", key: "PRIMARY_CONTROL") { 48 | attributeState "open", label:'${name}', action: "close", icon:"st.contact.contact.open", backgroundColor:"#e86d13" 49 | attributeState "closed", label:'${name}', action: "open", icon:"st.contact.contact.closed", backgroundColor:"#00a0dc" 50 | } 51 | 52 | tileAttribute("device.device_details", key: "SECONDARY_CONTROL") { 53 | attributeState("default", action: "refresh", label: '${currentValue}', icon:"https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/refresh.png") 54 | attributeState("refresh", label: 'Updating data from server...') 55 | } 56 | } 57 | 58 | standardTile("contactclosed", "device.contact", width: 2, height: 2, decoration: "flat") { 59 | state("closed", label:'CLOSE', icon:"st.contact.contact.closed", backgroundColor:"#79b821", action: "closed") 60 | state("open", label:'CLOSE', icon:"st.contact.contact.closed", backgroundColor:"#79b821", action: "closed") 61 | } 62 | 63 | standardTile("contactopen", "device.contact", width: 2, height: 2, decoration: "flat") { 64 | state("closed", label:'OPEN', icon:"st.contact.contact.open", backgroundColor:"#ffa81e", action: "open") 65 | state("open", label:'OPEN', icon:"st.contact.contact.open", backgroundColor:"#ffa81e", action: "open") 66 | } 67 | 68 | valueTile("wifi", "device.wifi", width: 1, height: 1, decoration: "flat") { 69 | state ("default", label: '${currentValue}', backgroundColor: "#e86d13", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/blank1x1-orange.png") 70 | } 71 | 72 | standardTile("rssiLevel", "device.rssiLevel", width: 1, height: 1, decoration: "flat") { 73 | state ("1", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi0.png") 74 | state ("2", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi1.png") 75 | state ("3", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi2.png") 76 | state ("4", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi3.png") 77 | } 78 | 79 | standardTile("healthStatus", "device.healthStatus", width: 2, height: 1, decoration: "flat") { 80 | state "default", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/online1x2.png" 81 | state "online", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/online1x2.png" 82 | state "offline", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/offline1x2.png" 83 | } 84 | 85 | valueTile("details", "device.details", width: 6, height: 2, decoration: "flat") { 86 | state "default", label: '${currentValue}', icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/blank1x3-green.png", backgroundColor: "#90d2a7" 87 | } 88 | 89 | main "main" 90 | details(["main", "contactopen","healthStatus", "contactclosed","wifi", "rssiLevel","details" ]) 91 | } 92 | } 93 | 94 | 95 | def parse(String description) { 96 | log.debug "Parsing message is ${description}" 97 | def pair = description.split(":") 98 | switch(pair[0].trim()){ 99 | case 'contact': 100 | (pair[1].trim() == "open") ? open() : close(); 101 | break; 102 | default: 103 | break; 104 | } 105 | } 106 | def installed() { 107 | configure() 108 | refresh() 109 | } 110 | 111 | def refresh(){ 112 | sendEvent(name : "update", value : 'refresh', isStateChange: 'true', descriptionText : 'Refreshing from Server...'); 113 | log.debug "Sent 'refresh' command for device: ${device}" 114 | } 115 | 116 | def ping(){ 117 | return ((device.currentValue('healthStatus')?: "offline") == "online") 118 | } 119 | 120 | def configure() { 121 | log.trace "Executing 'configure'" 122 | sendEvent(name: "DeviceWatch-Enroll", value: [protocol: "cloud", scheme:"untracked"].encodeAsJson(), displayed: false) 123 | markDeviceOnline() 124 | initialize() 125 | } 126 | 127 | def markDeviceOnline() { 128 | state.pingState = 'online'; 129 | setDeviceHealth("online") 130 | } 131 | 132 | def markDeviceOffline() { 133 | state.pingState = 'offline'; 134 | setDeviceHealth("offline") 135 | } 136 | 137 | private setDeviceHealth(String healthState) { 138 | log.debug("healthStatus: ${device.currentValue('healthStatus')}; DeviceWatch-DeviceStatus: ${device.currentValue('DeviceWatch-DeviceStatus')}") 139 | // ensure healthState is valid 140 | List validHealthStates = ["online", "offline"] 141 | healthState = validHealthStates.contains(healthState) ? healthState : device.currentValue("healthStatus") 142 | // set the healthState 143 | sendEvent(name: "DeviceWatch-DeviceStatus", value: healthState) 144 | sendEvent(name: "healthStatus", value: healthState) 145 | } 146 | 147 | def processMQTT(attribute, value){ 148 | //log.debug "Processing ${attribute} Event: ${value} from MQTT for device: ${device}" 149 | switch (attribute) { 150 | case 'update': 151 | updateTiles(value); 152 | break; 153 | default: 154 | break; 155 | } 156 | } 157 | 158 | def updateTiles(Object val ){ 159 | //log.debug "Msg ${val}" 160 | if (['online','offline'].contains(val.toLowerCase())){ 161 | log.debug "Received Health Check LWT event ${val}" 162 | (val.toLowerCase() == 'online') ? markDeviceOnline() : markDeviceOffline() 163 | return; 164 | } 165 | 166 | state.updates = (state.updates == null) ? "" : state.updates + val + "\n"; 167 | def value = parseJson(val); 168 | 169 | state.update1 = value?.Status ? true : state.update1 ?: false 170 | state.update2 = value?.StatusFWR ? true : state.update2 ?: false 171 | state.update3 = value?.StatusNET ? true : state.update3 ?: false 172 | state.update4 = value?.StatusSTS ? true : state.update4 ?: false 173 | 174 | state.topic = (value?.Status?.Topic) ?: state.topic 175 | state.friendlyName = (value?.Status?.FriendlyName) ?: state.friendlyName 176 | state.firmware = (value?.StatusFWR?.Version) ?: state.firmware 177 | state.macAddress = ( value?.StatusNET?.Mac) ?: state.macAddress 178 | state.ipAddress = (value?.StatusNET?.IPAddress) ?: state.ipAddress 179 | if (value?.StatusSTS?.Time) state.currentTimestamp = Date.parse("yyyy-MM-dd'T'HH:mm:ss",value?.StatusSTS?.Time).format("EEE MMM dd, yyyy 'at' hh:mm:ss a") 180 | state.ssid1 = (value?.StatusSTS?.Wifi?.SSId) ?: state.ssid1 181 | state.upTime = (value?.StatusSTS?.Uptime) ?: state.upTime 182 | state.RSSI = (value?.StatusSTS?.Wifi?.RSSI) ?: state.RSSI 183 | state.rssiLevel = (value?.StatusSTS?.Wifi?.RSSI) ? (0..10).contains(state.RSSI) ? 1 184 | : (11..45).contains(state.RSSI)? 2 185 | : (46..80).contains(state.RSSI)? 3 186 | : (81..100).contains(state.RSSI) ? 4 : 5 187 | : state.rssiLevel 188 | 189 | //log.debug "Are updates ready ${state.update1}, ${state.update2}, ${state.update3}, ${state.update4}" 190 | //log.debug "Time is ${state.currentTimestamp}" 191 | if (state.update1 && state.update2 && state.update3 && state.update4){ 192 | state.update1 = state.update2 = state.update3 = state.update4 = false; 193 | runIn(3,fireEvents) 194 | } 195 | } 196 | 197 | def fireEvents(){ 198 | sendEvent(name: 'device_details', value: state.topic + ", running for: " + state.upTime + 199 | "\nIP: " + state.ipAddress + " [ " + state.ssid1+": "+state.RSSI + "% ]", displayed: 'false') 200 | sendEvent(name: 'details', value: state.topic + "\n" + state.friendlyName + "\n" + state.ipAddress + " [ " +state.macAddress + " ]\n" + 201 | state.firmware + " - Up Time: " + state.upTime + "\nLast Updated: " + state.currentTimestamp +"\n\n" , displayed: 'false') 202 | //sendEvent(name: 'healthStatus', value: (state.pingState?:'online') , displayed: 'false') 203 | (state.RSSI < 100) ? sendEvent(name: 'wifi', value: state.RSSI +"%\nRSSI\n\n", displayed: 'false') 204 | : sendEvent(name: 'wifi', value: state.RSSI +"%\nRSSI\n\n\n", displayed: 'false') 205 | sendEvent(name: 'rssiLevel', value: state.rssiLevel, displayed: 'false') 206 | log.debug "Processed Status updates for device: [${device}]\n ${state.updates}" 207 | state.updates = ""; 208 | state.update1 = state.update2 = state.update3 = state.update4 = false; 209 | } 210 | 211 | def open(){ 212 | if (device.currentValue("contact") == "open") return; 213 | _open(); 214 | } 215 | 216 | def close(){ 217 | if (device.currentValue("contact") == "closed") return; 218 | _close(); 219 | } 220 | 221 | def _open() { 222 | sendEvent(name: "contact", value: "open") 223 | log.debug "Sent 'open' command for device: ${device}" 224 | } 225 | 226 | def _close() { 227 | sendEvent(name: "contact", value: "closed") 228 | log.debug "Sent 'close' command for device: ${device}" 229 | } 230 | 231 | -------------------------------------------------------------------------------- /devicetypes/gupta/mqtt/tasmota.src/TasmotaSensorSwitch.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Tasmota Combo Contact Sensor (Primary) - Switch (Secondary) Device Handler 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * 7 | * Version 1.0 - 11/17/2019 8 | * 9 | * Copyright 2019 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 12 | * in compliance with the License. You may obtain a copy of the License at: 13 | * 14 | * http://www.apache.org/licenses/LICENSE-2.0 15 | * 16 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 17 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 18 | * for the specific language governing permissions and limitations under the License. 19 | */ 20 | 21 | metadata { 22 | 23 | definition (name: "Tasmota SensorSwitch", namespace: "gupta/mqtt", author: "Sandeep Gupta") { 24 | capability "Actuator" 25 | capability "Contact Sensor" 26 | capability "Switch" 27 | capability "Momentary" 28 | capability "Refresh" 29 | 30 | command "open" 31 | command "close" 32 | command "processMQTT" 33 | 34 | attribute "update", "string" 35 | attribute "device_details", "string" 36 | attribute "details", "string" 37 | attribute "wifi", "string" 38 | attribute "rssiLevel", "number" 39 | attribute "healthStatus", "string" 40 | } 41 | 42 | simulator { 43 | status "open": "contact:open" 44 | status "closed": "contact:close" 45 | status "on": "switch:on" 46 | status "off": "switch:off" 47 | status "toggle": "momentary:push" 48 | } 49 | 50 | tiles { 51 | multiAttributeTile(name:"main", type: "generic", canChangeIcon: 'true', canChangeBackground : 'true' ){ 52 | tileAttribute ("device.contact", key: "PRIMARY_CONTROL") { 53 | attributeState "open", label:'${name}', action: "close", icon:"st.contact.contact.open", backgroundColor:"#e86d13" 54 | attributeState "closed", label:'${name}', action: "open", icon:"st.contact.contact.closed", backgroundColor:"#00a0dc" 55 | } 56 | 57 | tileAttribute("device.device_details", key: "SECONDARY_CONTROL") { 58 | attributeState("default", action: "refresh", label: '${currentValue}', icon:"https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/refresh.png") 59 | attributeState("refresh", label: 'Updating data from server...') 60 | } 61 | } 62 | 63 | 64 | standardTile("switch", "device.switch", width: 2, height: 2, decoration: "flat") { 65 | state("on", label: '${name}', action: "switch.off", icon: "st.switches.switch.on", backgroundColor: "#00A0DC") 66 | state("off", label: '${name}', action: "switch.on", icon: "st.switches.switch.off", backgroundColor: "#ffffff") 67 | } 68 | 69 | 70 | standardTile("toggle", "device.switch", width: 2, height: 2, decoration: "flat") { 71 | state("on", label: 'Toggle', action: "push", icon: "st.switches.switch.on", backgroundColor: "#00A0DC") 72 | state("off", label: 'Toggle', action: "push", icon: "st.switches.switch.off", backgroundColor: "#ffffff") 73 | } 74 | 75 | valueTile("wifi", "device.wifi", width: 1, height: 1, decoration: "flat") { 76 | state ("default", label: '${currentValue}', backgroundColor: "#e86d13", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/blank1x1-orange.png") 77 | } 78 | 79 | standardTile("rssiLevel", "device.rssiLevel", width: 1, height: 1, decoration: "flat") { 80 | state ("1", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi0.png") 81 | state ("2", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi1.png") 82 | state ("3", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi2.png") 83 | state ("4", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi3.png") 84 | } 85 | 86 | standardTile("healthStatus", "device.healthStatus", width: 2, height: 1, decoration: "flat") { 87 | state "default", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/online1x2.png" 88 | state "online", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/online1x2.png" 89 | state "offline", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/offline1x2.png" 90 | } 91 | 92 | valueTile("details", "device.details", width: 6, height: 2, decoration: "flat") { 93 | state "default", label: '${currentValue}', icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/blank1x3-green.png", backgroundColor: "#90d2a7" 94 | } 95 | 96 | main "main" 97 | details(["main", "switch","healthStatus", "toggle","wifi", "rssiLevel","details" ]) 98 | } 99 | 100 | preferences { 101 | section("Main") { 102 | input(name: "linked", type: "bool", title: "Link Switch and Contact Sensor", description: "", required: false) 103 | } 104 | } 105 | } 106 | 107 | 108 | def parse(String description) { 109 | log.debug "Parsing message is ${description}" 110 | def pair = description.split(":") 111 | switch(pair[0].trim()){ 112 | case 'switch': 113 | (pair[1].trim() == "on") ? on() : off(); 114 | break; 115 | case 'contact': 116 | (pair[1].trim() == "open") ? open() : close(); 117 | break; 118 | case 'momentary': 119 | if (pair[1].trim() == "push") push(); 120 | break; 121 | default: 122 | break; 123 | } 124 | } 125 | 126 | def installed() { 127 | configure() 128 | refresh() 129 | } 130 | 131 | def refresh(){ 132 | sendEvent(name : "update", value : 'refresh', isStateChange: 'true', descriptionText : 'Refreshing from Server...'); 133 | log.debug "Sent 'refresh' command for device: ${device}" 134 | } 135 | 136 | def ping(){ 137 | return ((device.currentValue('healthStatus')?: "offline") == "online") 138 | } 139 | 140 | def configure() { 141 | log.trace "Executing 'configure'" 142 | sendEvent(name: "DeviceWatch-Enroll", value: [protocol: "cloud", scheme:"untracked"].encodeAsJson(), displayed: false) 143 | markDeviceOnline() 144 | initialize() 145 | } 146 | 147 | def markDeviceOnline() { 148 | state.pingState = 'online'; 149 | setDeviceHealth("online") 150 | } 151 | 152 | def markDeviceOffline() { 153 | state.pingState = 'offline'; 154 | setDeviceHealth("offline") 155 | } 156 | 157 | private setDeviceHealth(String healthState) { 158 | log.debug("healthStatus: ${device.currentValue('healthStatus')}; DeviceWatch-DeviceStatus: ${device.currentValue('DeviceWatch-DeviceStatus')}") 159 | // ensure healthState is valid 160 | List validHealthStates = ["online", "offline"] 161 | healthState = validHealthStates.contains(healthState) ? healthState : device.currentValue("healthStatus") 162 | // set the healthState 163 | sendEvent(name: "DeviceWatch-DeviceStatus", value: healthState) 164 | sendEvent(name: "healthStatus", value: healthState) 165 | } 166 | 167 | def processMQTT(attribute, value){ 168 | //log.debug "Processing ${attribute} Event: ${value} from MQTT for device: ${device}" 169 | switch (attribute) { 170 | case 'update': 171 | updateTiles(value); 172 | break; 173 | default: 174 | break; 175 | } 176 | } 177 | 178 | def updateTiles(Object val ){ 179 | //log.debug "Msg ${val}" 180 | if (['online','offline'].contains(val.toLowerCase())){ 181 | log.debug "Received Health Check LWT event ${val}" 182 | (val.toLowerCase() == 'online') ? markDeviceOnline() : markDeviceOffline() 183 | return; 184 | } 185 | 186 | state.updates = (state.updates == null) ? "" : state.updates + val + "\n"; 187 | def value = parseJson(val); 188 | 189 | state.update1 = value?.Status ? true : state.update1 ?: false 190 | state.update2 = value?.StatusFWR ? true : state.update2 ?: false 191 | state.update3 = value?.StatusNET ? true : state.update3 ?: false 192 | state.update4 = value?.StatusSTS ? true : state.update4 ?: false 193 | 194 | state.topic = (value?.Status?.Topic) ?: state.topic 195 | state.friendlyName = (value?.Status?.FriendlyName) ?: state.friendlyName 196 | state.firmware = (value?.StatusFWR?.Version) ?: state.firmware 197 | state.macAddress = ( value?.StatusNET?.Mac) ?: state.macAddress 198 | state.ipAddress = (value?.StatusNET?.IPAddress) ?: state.ipAddress 199 | if (value?.StatusSTS?.Time) state.currentTimestamp = Date.parse("yyyy-MM-dd'T'HH:mm:ss",value?.StatusSTS?.Time).format("EEE MMM dd, yyyy 'at' hh:mm:ss a") 200 | state.ssid1 = (value?.StatusSTS?.Wifi?.SSId) ?: state.ssid1 201 | state.upTime = (value?.StatusSTS?.Uptime) ?: state.upTime 202 | state.RSSI = (value?.StatusSTS?.Wifi?.RSSI) ?: state.RSSI 203 | state.rssiLevel = (value?.StatusSTS?.Wifi?.RSSI) ? (0..10).contains(state.RSSI) ? 1 204 | : (11..45).contains(state.RSSI)? 2 205 | : (46..80).contains(state.RSSI)? 3 206 | : (81..100).contains(state.RSSI) ? 4 : 5 207 | : state.rssiLevel 208 | 209 | //log.debug "Are updates ready ${state.update1}, ${state.update2}, ${state.update3}, ${state.update4}" 210 | //log.debug "Time is ${state.currentTimestamp}" 211 | if (state.update1 && state.update2 && state.update3 && state.update4){ 212 | state.update1 = state.update2 = state.update3 = state.update4 = false; 213 | runIn(3,fireEvents) 214 | } 215 | } 216 | 217 | def fireEvents(){ 218 | sendEvent(name: 'device_details', value: state.topic + ", running for: " + state.upTime + 219 | "\nIP: " + state.ipAddress + " [ " + state.ssid1+": "+state.RSSI + "% ]", displayed: 'false') 220 | sendEvent(name: 'details', value: state.topic + "\n" + state.friendlyName + "\n" + state.ipAddress + " [ " +state.macAddress + " ]\n" + 221 | state.firmware + " - Up Time: " + state.upTime + "\nLast Updated: " + state.currentTimestamp +"\n\n" , displayed: 'false') 222 | //sendEvent(name: 'healthStatus', value: (state.pingState?:'online') , displayed: 'false') 223 | (state.RSSI < 100) ? sendEvent(name: 'wifi', value: state.RSSI +"%\nRSSI\n\n", displayed: 'false') 224 | : sendEvent(name: 'wifi', value: state.RSSI +"%\nRSSI\n\n\n", displayed: 'false') 225 | sendEvent(name: 'rssiLevel', value: state.rssiLevel, displayed: 'false') 226 | log.debug "Processed Status updates for device: [${device}]\n ${state.updates}" 227 | state.updates = ""; 228 | state.update1 = state.update2 = state.update3 = state.update4 = false; 229 | } 230 | 231 | def on(){ 232 | if (device.currentValue("switch") == "on") return; 233 | _on(); 234 | } 235 | 236 | def off(){ 237 | if (device.currentValue("switch") == "off") return; 238 | _off(); 239 | } 240 | 241 | def open(){ 242 | if (device.currentValue("contact") == "open") return; 243 | _open(); 244 | } 245 | 246 | def close(){ 247 | if (device.currentValue("contact") == "closed") return; 248 | _close(); 249 | } 250 | 251 | def push() { 252 | (device.currentValue("switch") == "on") ? off() : on() 253 | log.debug "Sent 'TOGGLE' command for device: ${device}" 254 | } 255 | 256 | def _on() { 257 | sendEvent(name: "switch", value: "on") 258 | if (linked) sendEvent(name: "contact", value: "open") 259 | log.debug "Sent 'on' command for device: ${device}" 260 | } 261 | 262 | def _off() { 263 | sendEvent(name: "switch", value: "off") 264 | if (linked) sendEvent(name: "contact", value: "closed") 265 | log.debug "Sent 'off' command for device: ${device}" 266 | } 267 | 268 | def _open() { 269 | sendEvent(name: "contact", value: "open") 270 | if (linked) sendEvent(name: "switch", value: "on") 271 | log.debug "Sent 'open' command for device: ${device}" 272 | } 273 | 274 | def _close() { 275 | sendEvent(name: "contact", value: "closed") 276 | if (linked) sendEvent(name: "switch", value: "off") 277 | log.debug "Sent 'close' command for device: ${device}" 278 | } 279 | 280 | -------------------------------------------------------------------------------- /devicetypes/gupta/mqtt/tasmota.src/TasmotaSwitch.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Tasmota Switch Device Handler 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * 7 | * Version 1.0 - 11/17/2019 8 | * 9 | * Copyright 2019 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 12 | * in compliance with the License. You may obtain a copy of the License at: 13 | * 14 | * http://www.apache.org/licenses/LICENSE-2.0 15 | * 16 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 17 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 18 | * for the specific language governing permissions and limitations under the License. 19 | */ 20 | 21 | metadata { 22 | 23 | definition (name: "Tasmota Switch", namespace: "gupta/mqtt", author: "Sandeep Gupta") { 24 | capability "Actuator" 25 | capability "Switch" 26 | capability "Refresh" 27 | 28 | command "processMQTT" 29 | 30 | attribute "update", "string" 31 | attribute "device_details", "string" 32 | attribute "details", "string" 33 | attribute "wifi", "string" 34 | attribute "rssiLevel", "number" 35 | attribute "healthStatus", "string" 36 | } 37 | 38 | simulator { 39 | status "on": "switch:on" 40 | status "off": "switch:off" 41 | } 42 | 43 | tiles { 44 | multiAttributeTile(name:"main", type: "device.switch", width: 6, height: 4, canChangeIcon: 'true', canChangeBackground : 'true' ){ 45 | tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { 46 | attributeState "on", label: '${name}', action: "switch.off", icon: "st.switches.switch.on", backgroundColor: "#00A0DC" 47 | attributeState "off", label: '${name}', action: "switch.on", icon: "st.switches.switch.off", backgroundColor: "#ffffff" 48 | } 49 | 50 | tileAttribute("device.device_details", key: "SECONDARY_CONTROL") { 51 | attributeState("default", action: "refresh", label: '${currentValue}', icon:"https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/refresh.png") 52 | attributeState("refresh", label: 'Updating data from server...') 53 | } 54 | } 55 | 56 | standardTile("switchon", "device.switch", width: 2, height: 2, decoration: "flat") { 57 | state("on", label: 'ON', action: "switch.on", icon: "st.Home.home30") 58 | state("off", label: 'ON', action: "switch.on", icon: "st.Home.home30") 59 | } 60 | 61 | 62 | 63 | standardTile("switchoff", "device.switch", width: 2, height: 2, decoration: "flat") { 64 | state("off", label: 'OFF', action: "switch.off", icon: "st.Home.home30") 65 | state("on", label: 'OFF', action: "switch.off", icon: "st.Home.home30") 66 | } 67 | 68 | valueTile("wifi", "device.wifi", width: 1, height: 1, decoration: "flat") { 69 | state ("default", label: '${currentValue}', backgroundColor: "#e86d13", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/blank1x1-orange.png") 70 | } 71 | 72 | standardTile("rssiLevel", "device.rssiLevel", width: 1, height: 1, decoration: "flat") { 73 | state ("1", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi0.png") 74 | state ("2", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi1.png") 75 | state ("3", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi2.png") 76 | state ("4", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi3.png") 77 | } 78 | 79 | standardTile("healthStatus", "device.healthStatus", width: 2, height: 1, decoration: "flat") { 80 | state "default", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/online1x2.png" 81 | state "online", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/online1x2.png" 82 | state "offline", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/offline1x2.png" 83 | } 84 | 85 | valueTile("details", "device.details", width: 6, height: 2, decoration: "flat") { 86 | state "default", label: '${currentValue}', icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/blank1x3-green.png", backgroundColor: "#90d2a7" 87 | } 88 | 89 | main "main" 90 | details(["main", "switchon","healthStatus", "switchoff","wifi", "rssiLevel","details" ]) 91 | } 92 | } 93 | 94 | 95 | def parse(String description) { 96 | log.debug "Parsing message is ${description}" 97 | def pair = description.split(":") 98 | switch(pair[0].trim()){ 99 | case 'switch': 100 | (pair[1].trim() == "on") ? on() : off(); 101 | break; 102 | default: 103 | break; 104 | } 105 | } 106 | 107 | def installed() { 108 | configure() 109 | refresh() 110 | } 111 | 112 | def refresh(){ 113 | sendEvent(name : "update", value : 'refresh', isStateChange: 'true', descriptionText : 'Refreshing from Server...'); 114 | log.debug "Sent 'refresh' command for device: ${device}" 115 | } 116 | 117 | def ping(){ 118 | return ((device.currentValue('healthStatus')?: "offline") == "online") 119 | } 120 | 121 | def configure() { 122 | log.trace "Executing 'configure'" 123 | sendEvent(name: "DeviceWatch-Enroll", value: [protocol: "cloud", scheme:"untracked"].encodeAsJson(), displayed: false) 124 | markDeviceOnline() 125 | } 126 | 127 | def markDeviceOnline() { 128 | state.pingState = 'online'; 129 | setDeviceHealth("online") 130 | } 131 | 132 | def markDeviceOffline() { 133 | state.pingState = 'offline'; 134 | setDeviceHealth("offline") 135 | } 136 | 137 | private setDeviceHealth(String healthState) { 138 | log.debug("healthStatus: ${device.currentValue('healthStatus')}; DeviceWatch-DeviceStatus: ${device.currentValue('DeviceWatch-DeviceStatus')}") 139 | // ensure healthState is valid 140 | List validHealthStates = ["online", "offline"] 141 | healthState = validHealthStates.contains(healthState) ? healthState : device.currentValue("healthStatus") 142 | // set the healthState 143 | sendEvent(name: "DeviceWatch-DeviceStatus", value: healthState) 144 | sendEvent(name: "healthStatus", value: healthState) 145 | } 146 | 147 | def processMQTT(attribute, value){ 148 | //log.debug "Processing ${attribute} Event: ${value} from MQTT for device: ${device}" 149 | switch (attribute) { 150 | case 'update': 151 | updateTiles(value); 152 | break; 153 | default: 154 | break; 155 | } 156 | } 157 | 158 | def updateTiles(Object val ){ 159 | //log.debug "Msg ${val}" 160 | if (['online','offline'].contains(val.toLowerCase())){ 161 | log.debug "Received Health Check LWT event ${val}" 162 | (val.toLowerCase() == 'online') ? markDeviceOnline() : markDeviceOffline() 163 | return; 164 | } 165 | 166 | state.updates = (state.updates == null) ? "" : state.updates + val + "\n"; 167 | def value = parseJson(val); 168 | 169 | state.update1 = value?.Status ? true : state.update1 ?: false 170 | state.update2 = value?.StatusFWR ? true : state.update2 ?: false 171 | state.update3 = value?.StatusNET ? true : state.update3 ?: false 172 | state.update4 = value?.StatusSTS ? true : state.update4 ?: false 173 | 174 | state.topic = (value?.Status?.Topic) ?: state.topic 175 | state.friendlyName = (value?.Status?.FriendlyName) ?: state.friendlyName 176 | state.firmware = (value?.StatusFWR?.Version) ?: state.firmware 177 | state.macAddress = ( value?.StatusNET?.Mac) ?: state.macAddress 178 | state.ipAddress = (value?.StatusNET?.IPAddress) ?: state.ipAddress 179 | if (value?.StatusSTS?.Time) state.currentTimestamp = Date.parse("yyyy-MM-dd'T'HH:mm:ss",value?.StatusSTS?.Time).format("EEE MMM dd, yyyy 'at' hh:mm:ss a") 180 | state.ssid1 = (value?.StatusSTS?.Wifi?.SSId) ?: state.ssid1 181 | state.upTime = (value?.StatusSTS?.Uptime) ?: state.upTime 182 | state.RSSI = (value?.StatusSTS?.Wifi?.RSSI) ?: state.RSSI 183 | state.rssiLevel = (value?.StatusSTS?.Wifi?.RSSI) ? (0..10).contains(state.RSSI) ? 1 184 | : (11..45).contains(state.RSSI)? 2 185 | : (46..80).contains(state.RSSI)? 3 186 | : (81..100).contains(state.RSSI) ? 4 : 5 187 | : state.rssiLevel 188 | 189 | //log.debug "Are updates ready ${state.update1}, ${state.update2}, ${state.update3}, ${state.update4}" 190 | //log.debug "Time is ${state.currentTimestamp}" 191 | if (state.update1 && state.update2 && state.update3 && state.update4){ 192 | state.update1 = state.update2 = state.update3 = state.update4 = false; 193 | runIn(3,fireEvents) 194 | } 195 | } 196 | 197 | def fireEvents(){ 198 | sendEvent(name: 'device_details', value: state.topic + ", running for: " + state.upTime + 199 | "\nIP: " + state.ipAddress + " [ " + state.ssid1+": "+state.RSSI + "% ]", displayed: 'false') 200 | sendEvent(name: 'details', value: state.topic + "\n" + state.friendlyName + "\n" + state.ipAddress + " [ " +state.macAddress + " ]\n" + 201 | state.firmware + " - Up Time: " + state.upTime + "\nLast Updated: " + state.currentTimestamp +"\n\n" , displayed: 'false') 202 | //sendEvent(name: 'healthStatus', value: (state.pingState?:'online') , displayed: 'false') 203 | (state.RSSI < 100) ? sendEvent(name: 'wifi', value: state.RSSI +"%\nRSSI\n\n", displayed: 'false') 204 | : sendEvent(name: 'wifi', value: state.RSSI +"%\nRSSI\n\n\n", displayed: 'false') 205 | sendEvent(name: 'rssiLevel', value: state.rssiLevel, displayed: 'false') 206 | log.debug "Processed Status updates for device: [${device}]\n ${state.updates}" 207 | state.updates = ""; 208 | state.update1 = state.update2 = state.update3 = state.update4 = false; 209 | } 210 | 211 | def on(){ 212 | if (device.currentValue("switch") == "on") return; 213 | _on(); 214 | } 215 | 216 | def off(){ 217 | if (device.currentValue("switch") == "off") return; 218 | _off(); 219 | } 220 | 221 | def _on() { 222 | sendEvent(name: "switch", value: "on") 223 | log.debug "Sent 'on' command for device: ${device}" 224 | } 225 | 226 | def _off() { 227 | sendEvent(name: "switch", value: "off") 228 | log.debug "Sent 'off' command for device: ${device}" 229 | } 230 | 231 | -------------------------------------------------------------------------------- /devicetypes/gupta/mqtt/tasmota.src/TasmotaSwitchSensor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Tasmota Combo Switch (Primary) - Contact Sensor (Secondary) Device Handler 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * 7 | * Version 1.0 - 11/17/2019 8 | * 9 | * Copyright 2019 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 12 | * in compliance with the License. You may obtain a copy of the License at: 13 | * 14 | * http://www.apache.org/licenses/LICENSE-2.0 15 | * 16 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 17 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 18 | * for the specific language governing permissions and limitations under the License. 19 | */ 20 | 21 | metadata { 22 | 23 | definition (name: "Tasmota SwitchSensor", namespace: "gupta/mqtt", author: "Sandeep Gupta") { 24 | capability "Actuator" 25 | capability "Switch" 26 | capability "Momentary" 27 | capability "Contact Sensor" 28 | capability "Refresh" 29 | 30 | command "open" 31 | command "close" 32 | command "processMQTT" 33 | 34 | attribute "update", "string" 35 | attribute "device_details", "string" 36 | attribute "details", "string" 37 | attribute "wifi", "string" 38 | attribute "rssiLevel", "number" 39 | attribute "healthStatus", "string" 40 | } 41 | 42 | simulator { 43 | status "open": "contact:open" 44 | status "closed": "contact:close" 45 | status "on": "switch:on" 46 | status "off": "switch:off" 47 | status "toggle": "momentary:push" 48 | } 49 | 50 | tiles { 51 | multiAttributeTile(name:"main", type: "device.switch", width: 6, height: 4, canChangeIcon: 'true', canChangeBackground : 'true' ){ 52 | tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { 53 | attributeState "on", label: '${name}', action: "switch.off", icon: "st.switches.switch.on", backgroundColor: "#00A0DC" 54 | attributeState "off", label: '${name}', action: "switch.on", icon: "st.switches.switch.off", backgroundColor: "#ffffff" 55 | } 56 | 57 | tileAttribute("device.device_details", key: "SECONDARY_CONTROL") { 58 | attributeState("default", action: "refresh", label: '${currentValue}', icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/refresh.png") 59 | attributeState("refresh", label: 'Updating data from server...') 60 | } 61 | } 62 | 63 | standardTile("contact", "device.contact", width: 2, height: 2, decoration: "flat") { 64 | state("closed", label:'${name}', icon:"st.contact.contact.closed", backgroundColor:"#79b821", action: "open") 65 | state("open", label:'${name}', icon:"st.contact.contact.open", backgroundColor:"#ffa81e", action: "close") 66 | } 67 | 68 | standardTile("toggle", "device.switch", width: 2, height: 2, decoration: "flat") { 69 | state("on", label: 'Toggle', action: "push", icon: "st.switches.switch.on", backgroundColor: "#00A0DC") 70 | state("off", label: 'Toggle', action: "push", icon: "st.switches.switch.off", backgroundColor: "#ffffff") 71 | } 72 | 73 | valueTile("wifi", "device.wifi", width: 1, height: 1, decoration: "flat") { 74 | state ("default", label: '${currentValue}', backgroundColor: "#e86d13", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/blank1x1-orange.png") 75 | } 76 | 77 | standardTile("rssiLevel", "device.rssiLevel", width: 1, height: 1, decoration: "flat") { 78 | state ("1", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi0.png") 79 | state ("2", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi1.png") 80 | state ("3", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi2.png") 81 | state ("4", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/wifi3.png") 82 | } 83 | 84 | standardTile("healthStatus", "device.healthStatus", width: 2, height: 1, decoration: "flat") { 85 | state "default", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/online1x2.png" 86 | state "online", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/online1x2.png" 87 | state "offline", icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/offline1x2.png" 88 | } 89 | 90 | valueTile("details", "device.details", width: 6, height: 2, decoration: "flat") { 91 | state "default", label: '${currentValue}', icon: "https://github.com/sgupta999/GuptaSmartthingsRepository/raw/master/icons/blank1x3-green.png", backgroundColor: "#90d2a7" 92 | } 93 | 94 | main "main" 95 | details(["main", "contact","healthStatus", "toggle","wifi", "rssiLevel","details" ]) 96 | } 97 | 98 | preferences { 99 | section("Main") { 100 | input(name: "linked", type: "bool", title: "Link Switch and Contact Sensor", description: "", required: false) 101 | } 102 | } 103 | } 104 | 105 | 106 | def parse(String description) { 107 | log.debug "Parsing message is ${description}" 108 | def pair = description.split(":") 109 | switch(pair[0].trim()){ 110 | case 'switch': 111 | (pair[1].trim() == "on") ? on() : off(); 112 | break; 113 | case 'contact': 114 | (pair[1].trim() == "open") ? open() : close(); 115 | break; 116 | case 'momentary': 117 | if (pair[1].trim() == "push") push(); 118 | break; 119 | default: 120 | break; 121 | } 122 | } 123 | 124 | def installed() { 125 | configure() 126 | refresh() 127 | } 128 | 129 | def refresh(){ 130 | sendEvent(name : "update", value : 'refresh', isStateChange: 'true', descriptionText : 'Refreshing from Server...'); 131 | log.debug "Sent 'refresh' command for device: ${device}" 132 | } 133 | 134 | def ping(){ 135 | return ((device.currentValue('healthStatus')?: "offline") == "online") 136 | } 137 | 138 | def configure() { 139 | log.trace "Executing 'configure'" 140 | sendEvent(name: "DeviceWatch-Enroll", value: [protocol: "cloud", scheme:"untracked"].encodeAsJson(), displayed: false) 141 | markDeviceOnline() 142 | initialize() 143 | } 144 | 145 | def markDeviceOnline() { 146 | state.pingState = 'online'; 147 | setDeviceHealth("online") 148 | } 149 | 150 | def markDeviceOffline() { 151 | state.pingState = 'offline'; 152 | setDeviceHealth("offline") 153 | } 154 | 155 | private setDeviceHealth(String healthState) { 156 | log.debug("healthStatus: ${device.currentValue('healthStatus')}; DeviceWatch-DeviceStatus: ${device.currentValue('DeviceWatch-DeviceStatus')}") 157 | // ensure healthState is valid 158 | List validHealthStates = ["online", "offline"] 159 | healthState = validHealthStates.contains(healthState) ? healthState : device.currentValue("healthStatus") 160 | // set the healthState 161 | sendEvent(name: "DeviceWatch-DeviceStatus", value: healthState) 162 | sendEvent(name: "healthStatus", value: healthState) 163 | } 164 | 165 | def processMQTT(attribute, value){ 166 | //log.debug "Processing ${attribute} Event: ${value} from MQTT for device: ${device}" 167 | switch (attribute) { 168 | case 'update': 169 | updateTiles(value); 170 | break; 171 | default: 172 | break; 173 | } 174 | } 175 | 176 | def updateTiles(Object val ){ 177 | //log.debug "Msg ${val}" 178 | if (['online','offline'].contains(val.toLowerCase())){ 179 | log.debug "Received Health Check LWT event ${val}" 180 | (val.toLowerCase() == 'online') ? markDeviceOnline() : markDeviceOffline() 181 | return; 182 | } 183 | 184 | state.updates = (state.updates == null) ? "" : state.updates + val + "\n"; 185 | def value = parseJson(val); 186 | 187 | state.update1 = value?.Status ? true : state.update1 ?: false 188 | state.update2 = value?.StatusFWR ? true : state.update2 ?: false 189 | state.update3 = value?.StatusNET ? true : state.update3 ?: false 190 | state.update4 = value?.StatusSTS ? true : state.update4 ?: false 191 | 192 | state.topic = (value?.Status?.Topic) ?: state.topic 193 | state.friendlyName = (value?.Status?.FriendlyName) ?: state.friendlyName 194 | state.firmware = (value?.StatusFWR?.Version) ?: state.firmware 195 | state.macAddress = ( value?.StatusNET?.Mac) ?: state.macAddress 196 | state.ipAddress = (value?.StatusNET?.IPAddress) ?: state.ipAddress 197 | if (value?.StatusSTS?.Time) state.currentTimestamp = Date.parse("yyyy-MM-dd'T'HH:mm:ss",value?.StatusSTS?.Time).format("EEE MMM dd, yyyy 'at' hh:mm:ss a") 198 | state.ssid1 = (value?.StatusSTS?.Wifi?.SSId) ?: state.ssid1 199 | state.upTime = (value?.StatusSTS?.Uptime) ?: state.upTime 200 | state.RSSI = (value?.StatusSTS?.Wifi?.RSSI) ?: state.RSSI 201 | state.rssiLevel = (value?.StatusSTS?.Wifi?.RSSI) ? (0..10).contains(state.RSSI) ? 1 202 | : (11..45).contains(state.RSSI)? 2 203 | : (46..80).contains(state.RSSI)? 3 204 | : (81..100).contains(state.RSSI) ? 4 : 5 205 | : state.rssiLevel 206 | 207 | //log.debug "Are updates ready ${state.update1}, ${state.update2}, ${state.update3}, ${state.update4}" 208 | //log.debug "Time is ${state.currentTimestamp}" 209 | if (state.update1 && state.update2 && state.update3 && state.update4){ 210 | state.update1 = state.update2 = state.update3 = state.update4 = false; 211 | runIn(3,fireEvents) 212 | } 213 | } 214 | 215 | def fireEvents(){ 216 | sendEvent(name: 'device_details', value: state.topic + ", running for: " + state.upTime + 217 | "\nIP: " + state.ipAddress + " [ " + state.ssid1+": "+state.RSSI + "% ]", displayed: 'false') 218 | sendEvent(name: 'details', value: state.topic + "\n" + state.friendlyName + "\n" + state.ipAddress + " [ " +state.macAddress + " ]\n" + 219 | state.firmware + " - Up Time: " + state.upTime + "\nLast Updated: " + state.currentTimestamp +"\n\n" , displayed: 'false') 220 | //sendEvent(name: 'healthStatus', value: (state.pingState?:'online') , displayed: 'false') 221 | (state.RSSI < 100) ? sendEvent(name: 'wifi', value: state.RSSI +"%\nRSSI\n\n", displayed: 'false') 222 | : sendEvent(name: 'wifi', value: state.RSSI +"%\nRSSI\n\n\n", displayed: 'false') 223 | sendEvent(name: 'rssiLevel', value: state.rssiLevel, displayed: 'false') 224 | log.debug "Processed Status updates for device: [${device}]\n ${state.updates}" 225 | state.updates = ""; 226 | state.update1 = state.update2 = state.update3 = state.update4 = false; 227 | } 228 | 229 | def on(){ 230 | if (device.currentValue("switch") == "on") return; 231 | _on(); 232 | } 233 | 234 | def off(){ 235 | if (device.currentValue("switch") == "off") return; 236 | _off(); 237 | } 238 | 239 | def open(){ 240 | if (device.currentValue("contact") == "open") return; 241 | _open(); 242 | } 243 | 244 | def close(){ 245 | if (device.currentValue("contact") == "closed") return; 246 | _close(); 247 | } 248 | 249 | def push() { 250 | (device.currentValue("switch") == "on") ? off() : on() 251 | log.debug "Sent 'TOGGLE' command for device: ${device}" 252 | } 253 | 254 | def _on() { 255 | sendEvent(name: "switch", value: "on") 256 | if (linked) sendEvent(name: "contact", value: "open") 257 | log.debug "Sent 'on' command for device: ${device}" 258 | } 259 | 260 | def _off() { 261 | sendEvent(name: "switch", value: "off") 262 | if (linked) sendEvent(name: "contact", value: "closed") 263 | log.debug "Sent 'off' command for device: ${device}" 264 | } 265 | 266 | def _open() { 267 | sendEvent(name: "contact", value: "open") 268 | if (linked) sendEvent(name: "switch", value: "on") 269 | log.debug "Sent 'open' command for device: ${device}" 270 | } 271 | 272 | def _close() { 273 | sendEvent(name: "contact", value: "closed") 274 | if (linked) sendEvent(name: "switch", value: "off") 275 | log.debug "Sent 'close' command for device: ${device}" 276 | } 277 | 278 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | container_name: mbs 2 | environment: 3 | TZ:America/Chicago 4 | mqtt: 5 | image: eclipse-mosquitto 6 | volumes: 7 | - D:/data/docker/volumes/mosquitto:/mosquitto 8 | ports: 9 | - 1883:1883 10 | 11 | mqttbridge: 12 | image: sgupta99/mqtt-bridge-smartthings:1.0.3-alpine 13 | volumes: 14 | - D:/data/docker/volumes/mbs:/config 15 | ports: 16 | - 8080:8080 17 | links: 18 | - mqtt -------------------------------------------------------------------------------- /mbs-server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ST-MQTT Bridge Server 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * Derived from work of previous authors 7 | * - st.john.johnson@gmail.com 8 | * - jeremiah.wuenschel@gmail.com 9 | * 10 | * A lot of initial work was done by the previous two authors 11 | * There is significant refactoring and added functionality since Oct 2019. 12 | * 13 | * Copyright 2019 14 | * 15 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 16 | * in compliance with the License. You may obtain a copy of the License at: 17 | * 18 | * http://www.apache.org/licenses/LICENSE-2.0 19 | * 20 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 21 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 22 | * for the specific language governing permissions and limitations under the License. 23 | */ 24 | 25 | /*jslint node: true */ 26 | 'use strict'; 27 | const { createLogger, format } = require('winston'); 28 | const logform = require('logform'); 29 | const { combine, timestamp, label, printf } = logform.format; 30 | 31 | var winston = require('winston'), 32 | daily = require('winston-daily-rotate-file'), 33 | express = require('express'), 34 | expressJoi = require('express-joi-validation').createValidator({}), 35 | expressWinston = require('express-winston'), 36 | bodyparser = require('body-parser'), 37 | mqtt = require('mqtt'), 38 | async = require('async'), 39 | url = require('url'), 40 | joi = require('@hapi/joi'), 41 | yaml = require('js-yaml'), 42 | jsonfile = require('jsonfile'), 43 | fs = require('fs-extra'), 44 | mqttWildcard = require('mqtt-wildcard'), 45 | request = require('request'), 46 | path = require('path'), 47 | 48 | CONFIG_DIR = process.env.CONFIG_DIR || path.join(__dirname,'config'), 49 | SAMPLE_FILE = path.join(CONFIG_DIR, '_config.yml'), 50 | CONFIG_FILE = path.join(CONFIG_DIR, 'config.yml'), 51 | DEVICE_CONFIG_FILE = path.join(CONFIG_DIR, 'devices.yml'), 52 | STATE_FILE = path.join(CONFIG_DIR, 'data', 'state.json'), 53 | STATE_SUMMARY_FILE = path.join(CONFIG_DIR, 'data', 'state.summary.json'), 54 | CURRENT_VERSION = require('./package').version, 55 | // The topic type to get state changes from smartthings 56 | TOPIC_READ_STATE = 'state', 57 | SUFFIX_READ_STATE = 'state_read_suffix', 58 | // The topic type to send commands to smartthings 59 | TOPIC_COMMAND = 'command', 60 | SUFFIX_COMMAND = 'command_suffix', 61 | // The topic type to send state changes to smartthings 62 | TOPIC_WRITE_STATE = 'set_state', 63 | SUFFIX_WRITE_STATE = 'state_write_suffix', 64 | RETAIN = 'retain', 65 | config = loadConfiguration(), 66 | LOGGING_LEVEL = config.loglevel, 67 | 68 | app = express(), 69 | client, 70 | subscriptions = [], 71 | publications = {}, 72 | subscribe = {}, 73 | callback = '', 74 | devices = {}, 75 | st_request = {}, 76 | history = {}, 77 | 78 | // winston transports 79 | consoleLog = new winston.transports.Console(), 80 | eventsLog = new (winston.transports.DailyRotateFile)({ 81 | filename: path.join(CONFIG_DIR, 'log', 'events-%DATE%.log'), 82 | datePattern: 'YYYY-MM-DD', 83 | maxSize: '5m', 84 | maxFiles: '10', 85 | json: false 86 | }), 87 | accessLog = new (winston.transports.DailyRotateFile)({ 88 | filename: path.join(CONFIG_DIR, 'log', 'access-%DATE%.log'), 89 | datePattern: 'YYYY-MM', 90 | maxSize: '5m', 91 | maxFiles: '5', 92 | json: false 93 | }), 94 | errorLog = new (winston.transports.DailyRotateFile)({ 95 | filename: path.join(CONFIG_DIR, 'log', 'error-%DATE%.log'), 96 | datePattern: 'YYYY-MM', 97 | maxSize: '5m', 98 | maxFiles: '5', 99 | json: false 100 | }), 101 | logFormat = combine(format.splat(), 102 | timestamp({format:(new Date()).toLocaleString('en-US'), format: 'YYYY-MM-DD HH:mm:ss A'}), 103 | printf(nfo => {return `${nfo.timestamp} ${nfo.level}: ${nfo.message}`;}) 104 | ); 105 | 106 | 107 | 108 | winston = createLogger({ 109 | level: LOGGING_LEVEL, 110 | format: logFormat, 111 | transports : [eventsLog, consoleLog] 112 | }); 113 | 114 | 115 | function loadConfiguration () { 116 | if (!fs.existsSync(CONFIG_FILE)) { 117 | console.log('No previous configuration found, creating one'); 118 | fs.writeFileSync(CONFIG_FILE, fs.readFileSync(SAMPLE_FILE)); 119 | } 120 | return yaml.safeLoad(fs.readFileSync(CONFIG_FILE)); 121 | } 122 | 123 | /** 124 | * Load device configuration if it exists 125 | * @method loadDeviceConfiguration 126 | * @return {Object} Configuration 127 | */ 128 | function loadDeviceConfiguration () { 129 | subscribe = {}; 130 | if (!config.deviceconfig) return null; 131 | var output; 132 | try { 133 | output = yaml.safeLoad(fs.readFileSync(DEVICE_CONFIG_FILE)); 134 | } catch (ex) { 135 | winston.error(ex); 136 | winston.info('ERROR loading external device configurations, continuing'); 137 | return; 138 | } 139 | Object.keys(output).forEach(function (device) { 140 | winston.debug("Loading config for Device " , device); 141 | Object.keys(output[device]["subscribe"]).forEach (function (attribute){ 142 | Object.keys(output[device]["subscribe"][attribute]).forEach (function (sub){ 143 | let data = {}; 144 | data['device']= device; 145 | data['attribute'] = attribute; 146 | if ((!!output[device]["subscribe"][attribute][sub]) && (!!output[device]["subscribe"][attribute][sub]['command'])) 147 | data['command']= output[device]["subscribe"][attribute][sub]['command']; 148 | if (!subscribe[sub]) subscribe[sub] = {}; 149 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 150 | subscribe[sub][device][attribute] = data; 151 | winston.debug("Subscription %s\t\[%s],[%s],[%s]",sub, subscribe[sub][device][attribute]['device'], 152 | subscribe[sub][device][attribute]['attribute'],subscribe[sub][device][attribute]['command']); 153 | winston.debug("Subscription: %s - Device %s" , sub, subscribe[sub]); 154 | }); 155 | }); 156 | }); 157 | winston.info('============================ALL POSSIBLE SUBSCRIPTIONS FROM ALL EXTERNAL DEVICES ==========================================='); 158 | Object.keys(subscribe).forEach(function (subs){ 159 | Object.keys(subscribe[subs]).forEach(function (dev){ 160 | Object.keys(subscribe[subs][dev]).forEach(function (attribute){ 161 | winston.info("Subscription %s\t\[%s],[%s],[%s]",subs, subscribe[subs][dev][attribute]['device'], 162 | subscribe[subs][dev][attribute]['attribute'], JSON.stringify((!!subscribe[subs][dev][attribute]['command']) ? subscribe[subs][dev][attribute]['command'] : '')); 163 | }); 164 | }); 165 | }); 166 | winston.info('============================================================================================================================'); 167 | 168 | return output; 169 | } 170 | 171 | /** 172 | * Set defaults for missing definitions in config file 173 | * @method configureDefaults 174 | * @param {String} version Version the state was written in before 175 | */ 176 | function configureDefaults() { 177 | // Make sure the object exists 178 | if (!config.mqtt) { 179 | config.mqtt = {}; 180 | } 181 | 182 | if (!config.mqtt.preface) { 183 | config.mqtt.preface = '/smartthings'; 184 | } 185 | 186 | // Default Suffixes 187 | if (!config.mqtt[SUFFIX_READ_STATE]) { 188 | config.mqtt[SUFFIX_READ_STATE] = ''; 189 | } 190 | if (!config.mqtt[SUFFIX_COMMAND]) { 191 | config.mqtt[SUFFIX_COMMAND] = ''; 192 | } 193 | if (!config.mqtt[SUFFIX_WRITE_STATE]) { 194 | config.mqtt[SUFFIX_WRITE_STATE] = ''; 195 | } 196 | 197 | // Default retain 198 | if (!config.mqtt[RETAIN]) { 199 | config.mqtt[RETAIN] = false; 200 | } 201 | 202 | // Default port 203 | if (!config.port) { 204 | config.port = 8080; 205 | } 206 | 207 | // Default protocol 208 | if (!url.parse(config.mqtt.host).protocol) { 209 | config.mqtt.host = 'mqtt://' + config.mqtt.host; 210 | } 211 | 212 | // Default protocol 213 | if (!config.deviceconfig) { 214 | config.deviceconfig = false; 215 | } 216 | } 217 | 218 | /** 219 | * Load the saved previous state from disk 220 | * @method loadSavedState 221 | * @return {Object} Configuration 222 | */ 223 | function loadSavedState () { 224 | var output; 225 | try { 226 | output = jsonfile.readFileSync(STATE_FILE); 227 | } catch (ex) { 228 | winston.info('No previous state found, continuing'); 229 | output = { 230 | version: '0.0.0', 231 | callback: '', 232 | subscriptions: {}, 233 | subscribe: {}, 234 | publications: {}, 235 | history: {}, 236 | st_request:{}, 237 | devices: {} 238 | }; 239 | } 240 | return output; 241 | } 242 | 243 | /** 244 | * Resubscribe on a periodic basis 245 | * @method saveState 246 | */ 247 | function saveState () { 248 | winston.info('Saving current state'); 249 | fs.ensureDir(path.join(CONFIG_DIR,'data')); 250 | jsonfile.writeFileSync(STATE_FILE, { 251 | version: CURRENT_VERSION, 252 | callback: callback, 253 | subscriptions: subscriptions, 254 | subscribe: subscribe, 255 | publications: publications, 256 | history: history, 257 | st_request: st_request, 258 | devices: devices 259 | }, { 260 | spaces: 4 261 | }); 262 | jsonfile.writeFileSync(STATE_SUMMARY_FILE, { 263 | version: CURRENT_VERSION, 264 | callback: callback, 265 | subscriptions: subscriptions, 266 | subscribe: Object.keys(subscribe), 267 | publications: Object.keys(publications), 268 | history: history, 269 | devices: (devices) ? Object.keys(devices) : devices 270 | }, { 271 | spaces: 4 272 | }); 273 | } 274 | 275 | 276 | /** 277 | * Handle Device Change/Push event from SmartThings 278 | * 279 | * @method handlePushEvent 280 | * @param {Request} req 281 | * @param {Object} req.body 282 | * @param {String} req.body.name Device Name (e.g. "Bedroom Light") 283 | * @param {String} req.body.type Device Property (e.g. "state") 284 | * @param {String} req.body.value Value of device (e.g. "on") 285 | * @param {Result} res Result Object 286 | */ 287 | function handlePushEvent (req, res) { 288 | var value = req.body.value, 289 | attribute = req.body.type, 290 | retain = config.mqtt[RETAIN], 291 | topic = "", 292 | device = req.body.name; 293 | winston.debug('From ST: %s - %s - %s', topic, req.body.type, value); 294 | // for devices from config file 295 | if ((!!devices[device]) && (!!devices[device]["publish"]) && (!!devices[device]["publish"][attribute])){ 296 | retain = (!!devices[device].retain) ? devices[device].retain : retain; 297 | winston.debug('ST** --> MQTT: [%s][%s][%s]\t[%s][%s][Retain:%s]', req.body.name, req.body.type, req.body.value, pub, value, retain); 298 | Object.keys(devices[device]["publish"][attribute]).forEach (function (pub){ 299 | value = ((!!devices[device]["publish"][attribute][pub].command) && (!!devices[device]["publish"][attribute][pub].command[value])) 300 | ? devices[device]["publish"][attribute][pub].command[value] : value; 301 | topic = pub; 302 | winston.info('ST** --> MQTT: [%s][%s][%s]\t[%s][%s][Retain:%s]', req.body.name, req.body.type, req.body.value, topic, value, retain); 303 | }); 304 | }else { 305 | // for devices with standard read, write, command suffixes 306 | topic = getTopicFor(device, attribute, TOPIC_READ_STATE); 307 | winston.debug('Device from SmartThings: %s = %s', topic, value); 308 | winston.info('ST --> MQTT: [%s][%s][%s]\t[%s][%s][Retain:%s]', device, attribute, req.body.value, topic, value, retain); 309 | } 310 | mqttPublish(device, attribute, topic, value, retain, res); 311 | } 312 | 313 | function mqttPublish(device, attribute, topic, value, retainflag, res){ 314 | history[topic] = value; 315 | if ((!!publications) && (!publications[topic])){ 316 | let data = {}; 317 | data['device'] = device; 318 | data['attribute'] = attribute; 319 | data['command'] = value; 320 | publications[topic] = {}; 321 | publications[topic][device] = data; 322 | } else if (!!publications[topic][device]) publications[topic][device]['command'] = value; 323 | var sub = isSubscribed(topic); 324 | if ((!!subscribe) && (!!subscribe[sub]) && (!!subscribe[sub][device]) && (!!subscribe[sub][device][attribute])) { 325 | winston.warn('POSSIBLE LOOP. Device[Attribute] %s[%s] is publishing to Topic %s while subscribed to Topic %s', device, attribute, topic, sub); 326 | } else if ((!!subscribe[sub]) && (!!subscribe[sub][device])) { 327 | winston.warn('POSSIBLE LOOP. Device %s is publishing to Topic %s while subscribed to Topic %s', device, topic, sub); 328 | } 329 | client.publish(topic, value, {retain: retainflag}, function () { 330 | res.send({ 331 | status: 'OK' 332 | }); 333 | }); 334 | } 335 | 336 | /** 337 | * Handle Subscribe event from SmartThings 338 | * 339 | * @method handleSubscribeEvent 340 | * @param {Request} req 341 | * @param {Object} req.body 342 | * @param {Object} req.body.devices List of properties => device names 343 | * @param {String} req.body.callback Host and port for SmartThings Hub 344 | * @param {Result} res Result Object 345 | */ 346 | function handleSubscribeEvent (req, res) { 347 | // Subscribe to all events 348 | let oldsubscriptions = subscriptions; 349 | st_request = req.body.devices; 350 | processSubscriptions(st_request); 351 | // Store callback 352 | callback = req.body.callback; 353 | // Store current state on disk 354 | saveState(); 355 | let unsubs = comparearrays(subscriptions, oldsubscriptions), 356 | subs = comparearrays(oldsubscriptions, subscriptions); 357 | if ((!!unsubs) && (unsubs.length > 0)) client.unsubscribe(unsubs); 358 | if ((!!subs) && (subs.length > 0)) { 359 | winston.info('We are mqtt subscribing'); 360 | client.subscribe( subs , function () { 361 | res.send({ 362 | status: 'OK' 363 | }); 364 | }); 365 | } 366 | } 367 | 368 | function processSubscriptions(req){ 369 | if (config.deviceconfig) { 370 | winston.info('Loading Device configuration'); 371 | devices = loadDeviceConfiguration(); 372 | } 373 | subscriptions = []; 374 | Object.keys(req).forEach(function (property) { 375 | winston.debug('Property - %s ', property); 376 | req[property].forEach(function (device) { 377 | winston.debug(' %s - %s ', property, device); 378 | // CRITICAL - if device in DEVICE_CONFIG_FILE, file sub/pub info will supercedes 379 | if (!!devices && (!!devices[device])) { 380 | if ((!!devices[device]["subscribe"]) && (!!devices[device]["subscribe"][property])){ 381 | Object.keys(devices[device]["subscribe"][property]).forEach (function (sub){ 382 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 383 | winston.debug('Subscribing[CUSTOM] ', sub); 384 | }); 385 | } 386 | }else { 387 | let data = {}; 388 | data['device']=device; 389 | data['attribute'] = property; 390 | let sub = getTopicFor(device, property, TOPIC_COMMAND); 391 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 392 | if (!subscribe[sub]) subscribe[sub] = {}; 393 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 394 | subscribe[sub][device][property] = data; 395 | sub = getTopicFor(device, property, TOPIC_WRITE_STATE); 396 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 397 | if (!subscribe[sub]) subscribe[sub] = {}; 398 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 399 | subscribe[sub][device][property] = data; 400 | winston.debug('Subscribing[R] ', sub); 401 | } 402 | }); 403 | }); 404 | // Subscribe to events 405 | winston.info('===================================ACTUAL SUBSCRIPTIONS REQUESTED FROM SMARTAPP ============================================'); 406 | winston.info('Currently subscribed to ' + subscriptions.join(', ')); 407 | winston.info('============================================================================================================================'); 408 | } 409 | 410 | function comparearrays(arr1, arr2){ 411 | if (!arr2) return null; 412 | if (!arr1) return arr2; 413 | let newarray = []; 414 | arr2.forEach (function (sub){ 415 | if (!arr1.includes(sub)) newarray.push(sub); 416 | }); 417 | return newarray; 418 | } 419 | 420 | 421 | /** 422 | * Get the topic name for a standard device that is not in device config file 423 | * @method getTopicFor 424 | * @param {String} device Device Name 425 | * @param {String} property Property 426 | * @param {String} type Type of topic (command or state) 427 | * @return {String} MQTT Topic name 428 | */ 429 | function getTopicFor (device, property, type) { 430 | var tree = [config.mqtt.preface, device, property], 431 | suffix; 432 | 433 | if (type === TOPIC_COMMAND) { 434 | suffix = config.mqtt[SUFFIX_COMMAND]; 435 | } else if (type === TOPIC_READ_STATE) { 436 | suffix = config.mqtt[SUFFIX_READ_STATE]; 437 | } else if (type === TOPIC_WRITE_STATE) { 438 | suffix = config.mqtt[SUFFIX_WRITE_STATE]; 439 | } 440 | 441 | if (suffix) { 442 | tree.push(suffix); 443 | } 444 | return tree.join('/'); 445 | } 446 | 447 | /** 448 | * Check if the topic is subscribed to in external device config file 449 | * Can match subscriptions that MQTT wildcards 450 | * @method isSubscribed 451 | * @param {String} topic topic received from MQTT broker 452 | * @return {String} topic topic from config file (may include wildcards) 453 | */ 454 | function isSubscribed(topic){ 455 | if (!subscriptions) return null; 456 | var topics = []; 457 | for (let i=0; i< subscriptions.length; i++){ 458 | if (subscriptions[i] == topic) { 459 | topics.push(subscriptions[i]); 460 | }else if (mqttWildcard(topic, subscriptions[i]) != null) topics.push(subscriptions[i]); 461 | } 462 | return topics ; 463 | } 464 | 465 | 466 | /** 467 | * Parse incoming message from MQTT 468 | * @method parseMQTTMessage 469 | * @param {String} topic Topic channel the event came from 470 | * @param {String} message Contents of the event 471 | */ 472 | function parseMQTTMessage (incoming, message) { 473 | var contents = message.toString(); 474 | winston.debug('From MQTT: %s = %s', incoming, contents); 475 | var topics = isSubscribed(incoming); 476 | var device, property, cmd, value; 477 | if (!topics) { 478 | winston.warn('%s-%s not subscribed. State error. Ignoring. ', incoming, contents); 479 | return; 480 | } 481 | if (topics.length > 1) winston.info('Incoming topic maps to multiple subscriptions: %s = %s', incoming, topics); 482 | // Topic is subscribe to 483 | if ((!!topics) && (!!subscribe)){ 484 | topics.forEach(function(topic) { 485 | Object.keys(subscribe[topic]).forEach(function(name) { 486 | // Checking if external device for this topic 487 | if (!!devices[name]){ 488 | Object.keys(subscribe[topic][name]).forEach(function(attribute) { 489 | device = subscribe[topic][name][attribute]['device']; 490 | property = subscribe[topic][name][attribute]['attribute']; 491 | value = contents; 492 | if ((!!subscribe[topic][name][attribute]['command']) && (!!subscribe[topic][name][attribute]['command'][contents])) 493 | value = subscribe[topic][name][attribute]['command'][contents]; 494 | cmd = true; 495 | postRequest(topic, contents, device, property, value, cmd, incoming); 496 | }); 497 | } else { 498 | // Remove the preface from the topic before splitting it 499 | var pieces = topic.substr(config.mqtt.preface.length + 1).split('/'); 500 | device = pieces[0]; 501 | property = pieces[1]; 502 | value = contents; 503 | var topicReadState = getTopicFor(device, property, TOPIC_READ_STATE), 504 | topicWriteState = getTopicFor(device, property, TOPIC_WRITE_STATE), 505 | topicSwitchState = getTopicFor(device, 'switch', TOPIC_READ_STATE), 506 | topicLevelCommand = getTopicFor(device, 'level', TOPIC_COMMAND), 507 | cmd = (!pieces[2] || pieces[2] && pieces[2] === config.mqtt[SUFFIX_COMMAND]); 508 | // Deduplicate only if the incoming message topic is the same as the read state topic 509 | if (topic === topicReadState) { 510 | if (history[topic] === contents) { 511 | winston.info('Skipping duplicate message from: %s = %s', topic, contents); 512 | return; 513 | } 514 | } 515 | // If sending level data and the switch is off, don't send anything 516 | // SmartThings will turn the device on (which is confusing) 517 | if (property === 'level' && history[topicSwitchState] === 'off') { 518 | winston.info('Skipping level set due to device being off'); 519 | return; 520 | } 521 | 522 | // If sending switch data and there is already a nonzero level value, send level instead 523 | // SmartThings will turn the device on 524 | if (property === 'switch' && contents === 'on' && 525 | history[topicLevelCommand] > 0) { 526 | winston.info('Passing level instead of switch on'); 527 | property = 'level'; 528 | contents = history[topicLevelCommand]; 529 | } 530 | postRequest(topic, contents, device, property, value, cmd, incoming); 531 | } 532 | }); 533 | }); 534 | } 535 | } 536 | 537 | 538 | function postRequest(topic, contents, device, property, value, cmd, incoming){ 539 | 540 | // If we subscribe to topic we are publishing we get into a loop 541 | if ((!!publications) && (!!publications[incoming]) && (!!publications[incoming][device]) && (!!publications[incoming][device][property])){ 542 | winston.error('Incoming %s for attribute %s for device %s is also being published to: [%s][%s][%s].\nIgnoring: %s = %s', incoming, property, device, device, property, incoming, incoming, contents); 543 | return; 544 | } 545 | history[incoming] = contents; 546 | var msg = (contents.length > 25) ? contents.substr(0,25) + "..." : contents 547 | if (!topic.match(/[*+]/)) { 548 | winston.info('MQTT --> ST - Topic: [%s][%s]\t[%s][%s][%s]', topic, msg , device, property, value) 549 | } else { 550 | winston.info('MQTT --> MQTT[WildCard] - Topic: [%s][%s]\t[%s][%s]', incoming, msg, topic, msg); 551 | winston.info('MQTT[WildCard] --> ST - Topic: [%s][%s]\t[%s][%s][%s]', topic, msg , device, property, value) 552 | } 553 | request.post({ 554 | url: 'http://' + callback, 555 | json: { 556 | name: device, 557 | type: property, 558 | value: value, 559 | command: cmd 560 | } 561 | }, function (error, resp) { 562 | if (error) { 563 | // @TODO handle the response from SmartThings 564 | winston.error('Error from SmartThings Hub: %s', error.toString()); 565 | winston.error(JSON.stringify(error, null, 4)); 566 | winston.error(JSON.stringify(resp, null, 4)); 567 | } 568 | }); 569 | } 570 | 571 | // Main flow 572 | async.series([ 573 | function loadFromDisk (next) { 574 | var state; 575 | winston.info('Starting SmartThings MQTT Bridge - v%s', CURRENT_VERSION); 576 | winston.info('Configuration Directory - %s', CONFIG_DIR); 577 | winston.info('Loading configuration'); 578 | configureDefaults(); 579 | winston.info('Loading previous state'); 580 | state = loadSavedState(); 581 | callback = state.callback; 582 | subscriptions = state.subscriptions; 583 | publications = state.publications; 584 | st_request = state.st_request; 585 | history = state.history; 586 | if (!!st_request) { 587 | try { 588 | winston.info ('Last Request from ST - %s', JSON.stringify(st_request)); 589 | processSubscriptions(st_request); 590 | } catch (ex) { 591 | winston.error(ex); 592 | winston.info('Could not restore subscriptions. Please rebuscribe in IDE, continuing'); 593 | return; 594 | } 595 | } 596 | saveState(); 597 | process.nextTick(next); 598 | }, 599 | function connectToMQTT (next) { 600 | // Default protocol 601 | if (!url.parse(config.mqtt.host).protocol) { 602 | config.mqtt.host = 'mqtt://' + config.mqtt.host; 603 | } 604 | winston.info('Connecting to MQTT at %s', config.mqtt.host); 605 | client = mqtt.connect(config.mqtt.host, config.mqtt); 606 | client.on('message', parseMQTTMessage); 607 | client.on('connect', function () { 608 | if (subscriptions.length > 0) { 609 | client.subscribe(subscriptions); 610 | winston.info('Subscribing to - %s', subscriptions); 611 | } 612 | next(); 613 | // @TODO Not call this twice if we get disconnected 614 | next = function () {}; 615 | }); 616 | }, 617 | function configureCron (next) { 618 | winston.info('Configuring autosave'); 619 | 620 | // Save current state every 15 minutes 621 | setInterval(saveState, 15 * 60 * 1000); 622 | 623 | process.nextTick(next); 624 | }, 625 | function setupApp (next) { 626 | winston.info('Configuring API'); 627 | 628 | // Accept JSON 629 | app.use(bodyparser.json()); 630 | 631 | // Log all requests to disk 632 | app.use(expressWinston.logger({ 633 | format: logFormat, 634 | msg: "HTTP /{{req.method}} {{req.url}} host={{req.hostname}} --> Status Code={{res.statusCode}} ResponseTime={{res.responseTime}}ms", 635 | transports : [accessLog] 636 | })); 637 | 638 | // Push event from SmartThings 639 | app.post('/push', 640 | expressJoi.body(joi.object({ 641 | // "name": "Energy Meter", 642 | name: joi.string().required(), 643 | // "value": "873", 644 | value: joi.string().required(), 645 | // "type": "power", 646 | type: joi.string().required() 647 | })), handlePushEvent); 648 | 649 | // Subscribe event from SmartThings 650 | app.post('/subscribe', 651 | expressJoi.body(joi.object({ 652 | devices: joi.object().required(), 653 | callback: joi.string().required() 654 | })), handleSubscribeEvent); 655 | 656 | // Log all errors to disk 657 | app.use(expressWinston.errorLogger({ 658 | format: logFormat, 659 | msg: " [{{res.statusCode}} {{req.method}}] {{err.message}}\n{{err.stack}}", 660 | transports : [errorLog,consoleLog] 661 | })); 662 | 663 | // Proper error messages with Joi 664 | app.use(function (err, req, res, next) { 665 | if (err.isBoom) { 666 | return res.status(err.output.statusCode).json(err.output.payload); 667 | } 668 | }); 669 | 670 | app.listen(config.port, next); 671 | } 672 | ], function (error) { 673 | if (error) { 674 | return winston.error(error); 675 | } 676 | winston.info('Listening at http://localhost:%s', config.port); 677 | }); 678 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mqtt-bridge-smartthings", 3 | "version": "1.0.5", 4 | "description": "MQTT Bridge to SmartThings [MBS]", 5 | "main": "mbs-server.js", 6 | "bin": { 7 | "mqtt-bridge-smartthings": "./bin/mqtt-bridge-smartthings" 8 | }, 9 | "scripts": { 10 | "pretest": "npm run hint && npm run style", 11 | "test": "jenkins-mocha", 12 | "hint": "jshint --show-non-errors *.js", 13 | "style": "jscs .", 14 | "start": "node mbs-server.js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/sgupta999/mqtt-bridge-smartthings" 19 | }, 20 | "keywords": [ 21 | "smartthings", 22 | "mqtt", 23 | "broker", 24 | "sonoff", 25 | "tasmota" 26 | ], 27 | "author": "Sandeep Gupta", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/sgupta999/mqtt-bridge-smartthings/issues" 31 | }, 32 | "homepage": "https://github.com/sgupta999/mqtt-bridge-smartthings", 33 | "dependencies": { 34 | "async": "^3.1.0", 35 | "body-parser": "^1.19.0", 36 | "express": "^4.17.1", 37 | "express-joi-validation": "^4.0.3", 38 | "express-winston": "^4.0.1", 39 | "fs-extra": "^8.1.0", 40 | "@hapi/joi": "^16.1.7", 41 | "js-yaml": "^3.13.1", 42 | "jsonfile": "^5.0.0", 43 | "mqtt": "^3.0.0", 44 | "mqtt-wildcard": "^3.0.9", 45 | "request": "^2.88.0", 46 | "winston": "^3.2.1", 47 | "winston-daily-rotate-file": "^4.2.1" 48 | }, 49 | "devDependencies": { 50 | "jenkins-mocha": "^8.0.0", 51 | "eslint": "^6.6.0", 52 | "jshint": "^2.10.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /smartapps/gupta/mqtt/mbs-smartapp.src/mbs-smartapp-lite.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * An MQTT bridge to SmartThings [MBS-SmartApp-Lite] - SmartThings SmartApp (Lite Version) 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * Derived from work of previous authors 7 | * - st.john.johnson@gmail.com 8 | * - jeremiah.wuenschel@gmail.com 9 | * 10 | * A lot of initial work was done by the previous two authors 11 | * There is significant refactoring and added functionality since Oct 2019. 12 | * 13 | * Copyright 2019 14 | * 15 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 16 | * in compliance with the License. You may obtain a copy of the License at: 17 | * 18 | * http://www.apache.org/licenses/LICENSE-2.0 19 | * 20 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 21 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 22 | * for the specific language governing permissions and limitations under the License. 23 | */ 24 | import groovy.json.JsonSlurper 25 | import groovy.json.JsonOutput 26 | import groovy.transform.Field 27 | 28 | // Lite lookup tree 29 | // Every device in mbs-server device config file should have one of these defined or else 30 | // They will not interact with SmartThings 31 | @Field CAPABILITY_MAP = [ 32 | // My custom device type 33 | "tasmotaSwitches": [ 34 | // filter name used on input screen 35 | name: "Tasmota Switch", 36 | // only one capability per device type to filter devices on input screen 37 | capability: "capability.switch", 38 | attributes: [ 39 | // any number of actual attributes used by devices filtered by capability above. 40 | // if attribute for device does not exist, command/update structure for that attribute for that device 41 | // will not work. 42 | "switch", 43 | "update" 44 | ], 45 | // When an event is received from the server, control will be passed to device if an action is defined here 46 | // If action is just single string only, that single action method will be invoked for all events received 47 | // from the server for all attributes 48 | // If action is defined as a Map like here, specific action method will be called for events received from server 49 | // for the specified attribute. If an attribute is not mapped to an action command in this map no action will be 50 | // taken on event received from server. 51 | action: [ 52 | switch: "actionOnOff", 53 | // in my custom handlers I am using 'update' as a catch-all attribute, and actionProcessMQTT as a catch-all action 54 | // command. All logic about how these specific commands are generated from SmartThings or events are handled from 55 | // server are handle by the Device Handler 56 | update: "actionProcessMQTT" 57 | ] 58 | ], 59 | "tasmotaSensor": [ 60 | name: "Tasmota Contact Sensor", 61 | capability: "capability.contactSensor", 62 | attributes: [ 63 | "contact", 64 | "update" 65 | ], 66 | action: "actionProcessMQTT" 67 | ], 68 | "contactSensors": [ 69 | name: "Contact Sensor", 70 | capability: "capability.contactSensor", 71 | attributes: [ 72 | "contact" 73 | ], 74 | action: "actionOpenClosed" 75 | ], 76 | // These could be standardized Smartthings virtual switches or any other device that has MQTT functionality implemented 77 | "switches": [ 78 | name: "Switch", 79 | capability: "capability.switch", 80 | attributes: [ 81 | "switch" 82 | ], 83 | action: "actionOnOff" 84 | ], 85 | "presenceSensors": [ 86 | name: "Presence Sensor", 87 | capability: "capability.presenceSensor", 88 | attributes: [ 89 | "presence" 90 | ], 91 | action: "actionPresence", 92 | duplicate: "allow" 93 | ], 94 | // My custom MQTT device - non-tasmota, should not apply to any use case but given as an example here 95 | "customPowerMeters": [ 96 | name: "Custom Power Meter", 97 | capability: "capability.powerMeter", 98 | attributes: [ 99 | "demand", 100 | "mqttmsg" 101 | ], 102 | action: "actionProcessMQTT" 103 | ], 104 | // My custom MQTT device - non-tasmota, should not apply to any use case but given as an example here 105 | "garageDoorOpener": [ 106 | name: "Garage Door Opener", 107 | capability: "capability.garageDoorControl", 108 | attributes: [ 109 | "switch", 110 | "contact1", 111 | "contact2", 112 | "update", 113 | ], 114 | action: "actionProcessMQTT" 115 | ], 116 | "thermostat": [ 117 | name: "Thermostat", 118 | capability: "capability.thermostat", 119 | attributes: [ 120 | "temperature", 121 | "humidity", 122 | "heatingSetpoint", 123 | "coolingSetpoint", 124 | "thermostatSetpoint", 125 | "thermostatMode", 126 | "thermostatFanMode", 127 | "thermostatOperatingState" 128 | ], 129 | action: "actionThermostat" 130 | ], 131 | ] 132 | 133 | definition( 134 | name: "MBS SmartApp Lite", 135 | namespace: "gupta/mqtt", 136 | author: "Sandeep Gupta", 137 | description: "An MQTT bridge to SmartThings [MBS-SmartApp-Lite]", 138 | category: "My Apps", 139 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Connections/Cat-Connections.png", 140 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Connections/Cat-Connections@2x.png", 141 | iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Connections/Cat-Connections@3x.png" 142 | ) 143 | 144 | preferences { 145 | section("Send Notifications?") { 146 | input("recipients", "contact", title: "Send notifications to", multiple: true, required: false) 147 | } 148 | 149 | section ("Input") { 150 | CAPABILITY_MAP.each { key, capability -> 151 | input key, capability["capability"], title: capability["name"], multiple: true, required: false 152 | } 153 | } 154 | 155 | section ("Bridge") { 156 | input "bridge", "capability.notification", title: "Notify this Bridge", required: true, multiple: false 157 | } 158 | } 159 | 160 | def installed() { 161 | log.debug "Installed with settings: ${settings}" 162 | runEvery30Minutes(initialize) 163 | initialize() 164 | } 165 | 166 | def updated() { 167 | log.debug "Updated with settings: ${settings}" 168 | // Unsubscribe from all events 169 | unsubscribe() 170 | // Subscribe to stuff 171 | initialize() 172 | } 173 | 174 | def initialize() { 175 | state.events = [:]; 176 | // Subscribe to new events from devices 177 | CAPABILITY_MAP.each { key, capability -> 178 | capability["attributes"].each { attribute -> 179 | if (settings[key] != null){ 180 | subscribe(settings[key], attribute, inputHandler) 181 | log.debug "Subscribed to event ${attribute} on device ${settings[key]}" 182 | // Create a last event hashmap for each device and attribute so duplicate events and looping can be eliminated 183 | settings[key].each {device -> 184 | state.events[device.displayName] = [:]; 185 | state.events[device.displayName][attribute]=null; 186 | //log.debug "creating json ${attribute} on device ${device.displayName}" 187 | } 188 | } 189 | } 190 | } 191 | // Subscribe to events from the bridge 192 | subscribe(bridge, "message", bridgeHandler) 193 | 194 | // Update the bridge 195 | updateSubscription() 196 | } 197 | 198 | // Update the bridge"s subscription 199 | def updateSubscription() { 200 | def attributes = [ 201 | notify: ["Contacts", "System"] 202 | ] 203 | CAPABILITY_MAP.each { key, capability -> 204 | capability["attributes"].each { attribute -> 205 | if (!attributes.containsKey(attribute)) { 206 | attributes[attribute] = [] 207 | } 208 | settings[key].each {device -> 209 | attributes[attribute].push(device.displayName) 210 | } 211 | } 212 | } 213 | def json = new groovy.json.JsonOutput().toJson([ 214 | path: "/subscribe", 215 | body: [ 216 | devices: attributes 217 | ] 218 | ]) 219 | 220 | log.debug "Updating subscription: ${json}" 221 | bridge.deviceNotification(json) 222 | } 223 | 224 | // Receive an event from the bridge 225 | def bridgeHandler(evt) { 226 | def json = new JsonSlurper().parseText(evt.value) 227 | def data = parseJson(evt.data) 228 | log.debug "Received device event from bridge: ${json}" 229 | 230 | if (json.type == "notify") { 231 | if (json.name == "Contacts") { 232 | sendNotificationToContacts("${json.value}", recipients) 233 | } else { 234 | sendNotificationEvent("${json.value}") 235 | } 236 | return 237 | } 238 | 239 | // @NOTE this is stored AWFUL, we need a faster lookup table 240 | // @NOTE this also has no fast fail, I need to look into how to do that 241 | CAPABILITY_MAP.each { key, capability -> 242 | if (capability["attributes"].contains(json.type)) { 243 | settings[key].each {device -> 244 | if (device.displayName == json.name) { 245 | if (json.command == false) { 246 | if (device.getSupportedCommands().any {it.name == "setStatus"}) { 247 | log.debug "Setting state ${json.type} = ${json.value}" 248 | device.setStatus(json.type, json.value) 249 | state.ignoreEvent = json; 250 | return; 251 | } 252 | } 253 | else { 254 | if (capability.containsKey("action")) { 255 | if (!eventCheck(device.displayName,json.type, json.value)) { 256 | if (!capability.containsKey("duplicate")){ 257 | log.debug "Duplicate of last event, ignoring 'mbs-server' event '${json.value}' on attribute '${json.type}' for device '${device.displayName}'" 258 | return; 259 | } 260 | } 261 | def action = capability["action"] 262 | if (action instanceof String){ 263 | log.debug "Calling action method ${action}, for attribute ${json.type}, for device ${device} with payload ${json.value}" 264 | (data == null) ? "$action"(device, json.type, json.value) : "$action"(device, json.type, json.value, data); 265 | } else if (action.containsKey(json.type)){ 266 | action = action[json.type]; 267 | log.debug "Calling action method ${action}, for attribute ${json.type}, for device ${device} with payload ${json.value}" 268 | (data == null) ? "$action"(device, json.type, json.value) : "$action"(device, json.type, json.value, data); 269 | } 270 | return; 271 | } 272 | } 273 | } 274 | } 275 | } else { 276 | // If server sends an event even if attribute is not defined in capability map, we will look for a COMMAND with same type and invoke that device command 277 | settings[key].each {device -> 278 | if ((device.displayName == json.name) && (json.type != null) && (json.value != null)) { 279 | if (!eventCheck(device.displayName,json.type, json.value)) { 280 | if (!capability.containsKey("duplicate")){ 281 | log.debug "Duplicate of last event, ignoring 'mbs-server' event '${json.value}' on attribute '${json.type}' for device '${device.displayName}'" 282 | return; 283 | } 284 | } 285 | def command = json.type; 286 | if (device.getSupportedCommands().any {it.name == command}) { 287 | log.debug "Setting state for device ${json.name} ${command} = ${json.value}" 288 | device."$command"(json.value); 289 | return; 290 | } 291 | } 292 | } 293 | } 294 | } 295 | } 296 | 297 | // Receive an event from a device 298 | def inputHandler(evt) { 299 | log.debug "Received event ${evt.value} on attribute ${evt.name} for device ${evt.displayName} for BRIDGE " 300 | // This is legacy ignoring duplicate event 301 | if ( 302 | state.ignoreEvent 303 | && state.ignoreEvent.name == evt.displayName 304 | && state.ignoreEvent.type == evt.name 305 | && state.ignoreEvent.value == evt.value 306 | ) { 307 | log.debug "Ignoring event ${state.ignoreEvent}" 308 | state.ignoreEvent = false; 309 | }else if (!eventCheck(evt.displayName,evt.name, evt.value)) { 310 | // Here we will ignore event from device if the last payload for the same event is the same as this one. 311 | log.debug "Duplicate of last event from device '${evt.displayName}'; ignoring event '${evt.value}' on attribute '${evt.name}' for device '${evt.displayName}'" 312 | return; 313 | } else { 314 | def json = new JsonOutput().toJson([ 315 | path: "/push", 316 | body: [ 317 | name: evt.displayName, 318 | value: evt.value, 319 | type: evt.name 320 | ] 321 | ]) 322 | 323 | log.debug "Forwarding device event to bridge: ${json}" 324 | bridge.deviceNotification(json) 325 | } 326 | } 327 | 328 | def eventCheck(device, attribute, value){ 329 | // If last event was same return false, else store event and return true 330 | if ((state?.events[device][attribute] == null) || (state?.events[device][attribute].toLowerCase() != value.toLowerCase())){ 331 | state.events[device][attribute] = value; 332 | return true; 333 | }else return false; 334 | } 335 | 336 | // +---------------------------------+ 337 | // | WARNING, BEYOND HERE BE DRAGONS | 338 | // +---------------------------------+ 339 | // These are the functions that handle incoming messages from MQTT. 340 | // I tried to put them in closures but apparently SmartThings Groovy sandbox 341 | // restricts you from running clsures from an object (it's not safe). 342 | 343 | 344 | // my catch-all action command, processMQTT implementation within device handler handles all he logic 345 | def actionProcessMQTT(device, attribute, value) { 346 | if ((device == null) || (attribute == null) || (value == null)) return; 347 | device.processMQTT(attribute, value); 348 | } 349 | 350 | 351 | def actionOpenClosed(device, attribute, value) { 352 | if (value == "open") { 353 | device.open() 354 | } else if (value == "closed") { 355 | device.close() 356 | } 357 | } 358 | 359 | def actionOnOff(device, attribute, value) { 360 | if (value == "off") { 361 | device.off() 362 | } else if (value == "on") { 363 | device.on() 364 | }else if (value == "toggle") { 365 | if (device.currentValue(attribute) == 'on'){ 366 | device.off(); 367 | } else if (device.currentValue(attribute) == 'off'){ 368 | device.on(); 369 | } 370 | } 371 | } 372 | 373 | def actionPresence(device, attribute, value) { 374 | if (value == "present") { 375 | device.arrived(); 376 | } 377 | else if (value == "not present") { 378 | device.departed(); 379 | } 380 | } 381 | 382 | def actionThermostat(device, attribute, value) { 383 | try { 384 | switch (attribute) { 385 | case 'temperature': 386 | device.setTemperature(value); 387 | break; 388 | case 'humidity': 389 | device.setHumidity(value); 390 | break; 391 | case 'thermostatMode': 392 | device.setThermostatMode(value); 393 | break; 394 | case 'thermostatOperatingState': 395 | device.setOperatingState(value); 396 | break; 397 | case 'heatingSetpoint': 398 | device.setHeatingSetpoint(value); 399 | break; 400 | case 'coolingSetpoint': 401 | device.setCoolingSetpoint(value); 402 | break; 403 | case 'thermostatSetpoint': 404 | device.setThermostatSetpoint(value); 405 | break; 406 | case 'thermostatFanMode': 407 | device.setThermostatFanMode(value); 408 | break; 409 | default: 410 | break; 411 | } 412 | } catch (all) { 413 | log.warn "Action command '$attribute' not defined in Device Handler for device '$device'. Value '$value' not set for attribute '$attribute'" 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /test/mbs-server-json.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ST-MQTT Bridge Server 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * Derived from work of previous authors 7 | * - st.john.johnson@gmail.com 8 | * - jeremiah.wuenschel@gmail.com 9 | * 10 | * A lot of initial work was done by the previous two authors 11 | * There is significant refactoring and added functionality since Oct 2019. 12 | * 13 | * Copyright 2019 14 | * 15 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 16 | * in compliance with the License. You may obtain a copy of the License at: 17 | * 18 | * http://www.apache.org/licenses/LICENSE-2.0 19 | * 20 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 21 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 22 | * for the specific language governing permissions and limitations under the License. 23 | */ 24 | 25 | /*jslint node: true */ 26 | 'use strict'; 27 | const { createLogger, format } = require('winston'); 28 | const logform = require('logform'); 29 | const { combine, timestamp, label, printf } = logform.format; 30 | 31 | var winston = require('winston'), 32 | daily = require('winston-daily-rotate-file'), 33 | express = require('express'), 34 | expressJoi = require('express-joi-validation').createValidator({}), 35 | expressWinston = require('express-winston'), 36 | bodyparser = require('body-parser'), 37 | mqtt = require('mqtt'), 38 | async = require('async'), 39 | url = require('url'), 40 | joi = require('@hapi/joi'), 41 | yaml = require('js-yaml'), 42 | jsonfile = require('jsonfile'), 43 | fs = require('fs-extra'), 44 | mqttWildcard = require('mqtt-wildcard'), 45 | request = require('request'), 46 | path = require('path'), 47 | 48 | CONFIG_DIR = process.env.CONFIG_DIR || path.join(__dirname,'config'), 49 | SAMPLE_FILE = path.join(CONFIG_DIR, '_config.yml'), 50 | CONFIG_FILE = path.join(CONFIG_DIR, 'config.yml'), 51 | DEVICE_CONFIG_FILE = path.join(CONFIG_DIR, 'devices.yml'), 52 | STATE_FILE = path.join(CONFIG_DIR, 'data', 'state.json'), 53 | STATE_SUMMARY_FILE = path.join(CONFIG_DIR, 'data', 'state.summary.json'), 54 | CURRENT_VERSION = require('./package').version, 55 | // The topic type to get state changes from smartthings 56 | TOPIC_READ_STATE = 'state', 57 | SUFFIX_READ_STATE = 'state_read_suffix', 58 | // The topic type to send commands to smartthings 59 | TOPIC_COMMAND = 'command', 60 | SUFFIX_COMMAND = 'command_suffix', 61 | // The topic type to send state changes to smartthings 62 | TOPIC_WRITE_STATE = 'set_state', 63 | SUFFIX_WRITE_STATE = 'state_write_suffix', 64 | RETAIN = 'retain', 65 | config = loadConfiguration(), 66 | LOGGING_LEVEL = config.loglevel, 67 | 68 | app = express(), 69 | client, 70 | subscriptions = [], 71 | publications = {}, 72 | subscribe = {}, 73 | callback = '', 74 | devices = {}, 75 | st_request = {}, 76 | history = {}, 77 | 78 | // winston transports 79 | consoleLog = new winston.transports.Console(), 80 | eventsLog = new (winston.transports.DailyRotateFile)({ 81 | filename: path.join(CONFIG_DIR, 'log', 'events-%DATE%.log'), 82 | datePattern: 'YYYY-MM-DD', 83 | maxSize: '5m', 84 | maxFiles: '10', 85 | json: false 86 | }), 87 | accessLog = new (winston.transports.DailyRotateFile)({ 88 | filename: path.join(CONFIG_DIR, 'log', 'access-%DATE%.log'), 89 | datePattern: 'YYYY-MM', 90 | maxSize: '5m', 91 | maxFiles: '5', 92 | json: false 93 | }), 94 | errorLog = new (winston.transports.DailyRotateFile)({ 95 | filename: path.join(CONFIG_DIR, 'log', 'error-%DATE%.log'), 96 | datePattern: 'YYYY-MM', 97 | maxSize: '5m', 98 | maxFiles: '5', 99 | json: false 100 | }), 101 | logFormat = combine(format.splat(), 102 | timestamp({format:(new Date()).toLocaleString('en-US'), format: 'YYYY-MM-DD HH:mm:ss A'}), 103 | printf(nfo => {return `${nfo.timestamp} ${nfo.level}: ${nfo.message}`;}) 104 | ); 105 | 106 | 107 | 108 | winston = createLogger({ 109 | level: LOGGING_LEVEL, 110 | format: logFormat, 111 | transports : [eventsLog, consoleLog] 112 | }); 113 | 114 | 115 | function loadConfiguration () { 116 | if (!fs.existsSync(CONFIG_FILE)) { 117 | console.log('No previous configuration found, creating one'); 118 | fs.writeFileSync(CONFIG_FILE, fs.readFileSync(SAMPLE_FILE)); 119 | } 120 | return yaml.safeLoad(fs.readFileSync(CONFIG_FILE)); 121 | } 122 | 123 | /** 124 | * Load device configuration if it exists 125 | * @method loadDeviceConfiguration 126 | * @return {Object} Configuration 127 | */ 128 | function loadDeviceConfiguration () { 129 | subscribe = {}; 130 | if (!config.deviceconfig) return null; 131 | var output; 132 | try { 133 | output = yaml.safeLoad(fs.readFileSync(DEVICE_CONFIG_FILE)); 134 | } catch (ex) { 135 | winston.error(ex); 136 | winston.info('ERROR loading external device configurations, continuing'); 137 | return; 138 | } 139 | Object.keys(output).forEach(function (device) { 140 | winston.debug("Loading config for Device " , device); 141 | Object.keys(output[device]["subscribe"]).forEach (function (attribute){ 142 | Object.keys(output[device]["subscribe"][attribute]).forEach (function (sub){ 143 | let data = {}; 144 | data['device']= device; 145 | data['attribute'] = attribute; 146 | if ((!!output[device]["subscribe"][attribute][sub]) && (!!output[device]["subscribe"][attribute][sub]['command'])) 147 | data['command']= output[device]["subscribe"][attribute][sub]['command']; 148 | if (!subscribe[sub]) subscribe[sub] = {}; 149 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 150 | subscribe[sub][device][attribute] = data; 151 | winston.debug("Subscription %s\t\[%s],[%s],[%s]",sub, subscribe[sub][device][attribute]['device'], 152 | subscribe[sub][device][attribute]['attribute'],subscribe[sub][device][attribute]['command']); 153 | winston.debug("Subscription: %s - Device %s" , sub, subscribe[sub]); 154 | }); 155 | }); 156 | }); 157 | winston.info('============================ALL POSSIBLE SUBSCRIPTIONS FROM ALL EXTERNAL DEVICES ==========================================='); 158 | Object.keys(subscribe).forEach(function (subs){ 159 | Object.keys(subscribe[subs]).forEach(function (dev){ 160 | Object.keys(subscribe[subs][dev]).forEach(function (attribute){ 161 | winston.info("Subscription %s\t\[%s],[%s],[%s]",subs, subscribe[subs][dev][attribute]['device'], 162 | subscribe[subs][dev][attribute]['attribute'], JSON.stringify((!!subscribe[subs][dev][attribute]['command']) ? subscribe[subs][dev][attribute]['command'] : '')); 163 | }); 164 | }); 165 | }); 166 | winston.info('============================================================================================================================'); 167 | 168 | return output; 169 | } 170 | 171 | /** 172 | * Set defaults for missing definitions in config file 173 | * @method configureDefaults 174 | * @param {String} version Version the state was written in before 175 | */ 176 | function configureDefaults() { 177 | // Make sure the object exists 178 | if (!config.mqtt) { 179 | config.mqtt = {}; 180 | } 181 | 182 | if (!config.mqtt.preface) { 183 | config.mqtt.preface = '/smartthings'; 184 | } 185 | 186 | // Default Suffixes 187 | if (!config.mqtt[SUFFIX_READ_STATE]) { 188 | config.mqtt[SUFFIX_READ_STATE] = ''; 189 | } 190 | if (!config.mqtt[SUFFIX_COMMAND]) { 191 | config.mqtt[SUFFIX_COMMAND] = ''; 192 | } 193 | if (!config.mqtt[SUFFIX_WRITE_STATE]) { 194 | config.mqtt[SUFFIX_WRITE_STATE] = ''; 195 | } 196 | 197 | // Default retain 198 | if (!config.mqtt[RETAIN]) { 199 | config.mqtt[RETAIN] = false; 200 | } 201 | 202 | // Default port 203 | if (!config.port) { 204 | config.port = 8080; 205 | } 206 | 207 | // Default protocol 208 | if (!url.parse(config.mqtt.host).protocol) { 209 | config.mqtt.host = 'mqtt://' + config.mqtt.host; 210 | } 211 | 212 | // Default protocol 213 | if (!config.deviceconfig) { 214 | config.deviceconfig = false; 215 | } 216 | } 217 | 218 | /** 219 | * Load the saved previous state from disk 220 | * @method loadSavedState 221 | * @return {Object} Configuration 222 | */ 223 | function loadSavedState () { 224 | var output; 225 | try { 226 | output = jsonfile.readFileSync(STATE_FILE); 227 | } catch (ex) { 228 | winston.info('No previous state found, continuing'); 229 | output = { 230 | version: '0.0.0', 231 | callback: '', 232 | subscriptions: {}, 233 | subscribe: {}, 234 | publications: {}, 235 | history: {}, 236 | st_request:{}, 237 | devices: {} 238 | }; 239 | } 240 | return output; 241 | } 242 | 243 | /** 244 | * Resubscribe on a periodic basis 245 | * @method saveState 246 | */ 247 | function saveState () { 248 | winston.info('Saving current state'); 249 | fs.ensureDir(path.join(CONFIG_DIR,'data')); 250 | jsonfile.writeFileSync(STATE_FILE, { 251 | version: CURRENT_VERSION, 252 | callback: callback, 253 | subscriptions: subscriptions, 254 | subscribe: subscribe, 255 | publications: publications, 256 | history: history, 257 | st_request: st_request, 258 | devices: devices 259 | }, { 260 | spaces: 4 261 | }); 262 | jsonfile.writeFileSync(STATE_SUMMARY_FILE, { 263 | version: CURRENT_VERSION, 264 | callback: callback, 265 | subscriptions: subscriptions, 266 | subscribe: Object.keys(subscribe), 267 | publications: Object.keys(publications), 268 | history: history, 269 | devices: (devices) ? Object.keys(devices) : devices 270 | }, { 271 | spaces: 4 272 | }); 273 | } 274 | 275 | 276 | /** 277 | * Handle Device Change/Push event from SmartThings 278 | * 279 | * @method handlePushEvent 280 | * @param {Request} req 281 | * @param {Object} req.body 282 | * @param {String} req.body.name Device Name (e.g. "Bedroom Light") 283 | * @param {String} req.body.type Device Property (e.g. "state") 284 | * @param {String} req.body.value Value of device (e.g. "on") 285 | * @param {Result} res Result Object 286 | */ 287 | function handlePushEvent (req, res) { 288 | var value = req.body.value, 289 | attribute = req.body.type, 290 | retain = config.mqtt[RETAIN], 291 | topic = "", 292 | device = req.body.name; 293 | winston.debug('From ST: %s - %s - %s', topic, req.body.type, value); 294 | // for devices from config file 295 | if ((!!devices[device]) && (!!devices[device]["publish"]) && (!!devices[device]["publish"][attribute])){ 296 | retain = (!!devices[device].retain) ? devices[device].retain : retain; 297 | winston.debug('ST** --> MQTT: [%s][%s][%s]\t[%s][%s][Retain:%s]', req.body.name, req.body.type, req.body.value, pub, value, retain); 298 | Object.keys(devices[device]["publish"][attribute]).forEach (function (pub){ 299 | value = ((!!devices[device]["publish"][attribute][pub].command) && (!!devices[device]["publish"][attribute][pub].command[value])) 300 | ? devices[device]["publish"][attribute][pub].command[value] : value; 301 | topic = pub; 302 | winston.info('ST** --> MQTT: [%s][%s][%s]\t[%s][%s][Retain:%s]', req.body.name, req.body.type, req.body.value, topic, value, retain); 303 | }); 304 | }else { 305 | // for devices with standard read, write, command suffixes 306 | topic = getTopicFor(device, attribute, TOPIC_READ_STATE); 307 | winston.debug('Device from SmartThings: %s = %s', topic, value); 308 | winston.info('ST --> MQTT: [%s][%s][%s]\t[%s][%s][Retain:%s]', device, attribute, req.body.value, topic, value, retain); 309 | } 310 | mqttPublish(device, attribute, topic, value, retain, res); 311 | } 312 | 313 | function mqttPublish(device, attribute, topic, value, retainflag, res){ 314 | history[topic] = value; 315 | if ((!!publications) && (!publications[topic])){ 316 | let data = {}; 317 | data['device'] = device; 318 | data['attribute'] = attribute; 319 | data['command'] = value; 320 | publications[topic] = {}; 321 | publications[topic][device] = data; 322 | } else if (!!publications[topic][device]) publications[topic][device]['command'] = value; 323 | var sub = isSubscribed(topic); 324 | if ((!!subscribe) && (!!subscribe[sub]) && (!!subscribe[sub][device]) && (!!subscribe[sub][device][attribute])) { 325 | winston.warn('POSSIBLE LOOP. Device[Attribute] %s[%s] is publishing to Topic %s while subscribed to Topic %s', device, attribute, topic, sub); 326 | } else if ((!!subscribe[sub]) && (!!subscribe[sub][device])) { 327 | winston.warn('POSSIBLE LOOP. Device %s is publishing to Topic %s while subscribed to Topic %s', device, topic, sub); 328 | } 329 | client.publish(topic, value, {retain: retainflag}, function () { 330 | res.send({ 331 | status: 'OK' 332 | }); 333 | }); 334 | } 335 | 336 | /** 337 | * Handle Subscribe event from SmartThings 338 | * 339 | * @method handleSubscribeEvent 340 | * @param {Request} req 341 | * @param {Object} req.body 342 | * @param {Object} req.body.devices List of properties => device names 343 | * @param {String} req.body.callback Host and port for SmartThings Hub 344 | * @param {Result} res Result Object 345 | */ 346 | function handleSubscribeEvent (req, res) { 347 | // Subscribe to all events 348 | let oldsubscriptions = subscriptions; 349 | st_request = req.body.devices; 350 | processSubscriptions(st_request); 351 | // Store callback 352 | callback = req.body.callback; 353 | // Store current state on disk 354 | saveState(); 355 | let unsubs = comparearrays(subscriptions, oldsubscriptions), 356 | subs = comparearrays(oldsubscriptions, subscriptions); 357 | if ((!!unsubs) && (unsubs.length > 0)) client.unsubscribe(unsubs); 358 | if ((!!subs) && (subs.length > 0)) { 359 | winston.info('We are mqtt subscribing'); 360 | client.subscribe( subs , function () { 361 | res.send({ 362 | status: 'OK' 363 | }); 364 | }); 365 | } 366 | } 367 | 368 | function processSubscriptions(req){ 369 | if (config.deviceconfig) { 370 | winston.info('Loading Device configuration'); 371 | devices = loadDeviceConfiguration(); 372 | } 373 | subscriptions = []; 374 | Object.keys(req).forEach(function (property) { 375 | winston.debug('Property - %s ', property); 376 | req[property].forEach(function (device) { 377 | winston.debug(' %s - %s ', property, device); 378 | // CRITICAL - if device in DEVICE_CONFIG_FILE, file sub/pub info will supercedes 379 | if (!!devices && (!!devices[device])) { 380 | if ((!!devices[device]["subscribe"]) && (!!devices[device]["subscribe"][property])){ 381 | Object.keys(devices[device]["subscribe"][property]).forEach (function (sub){ 382 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 383 | winston.debug('Subscribing[CUSTOM] ', sub); 384 | }); 385 | } 386 | }else { 387 | let data = {}; 388 | data['device']=device; 389 | data['attribute'] = property; 390 | let sub = getTopicFor(device, property, TOPIC_COMMAND); 391 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 392 | if (!subscribe[sub]) subscribe[sub] = {}; 393 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 394 | subscribe[sub][device][property] = data; 395 | sub = getTopicFor(device, property, TOPIC_WRITE_STATE); 396 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 397 | if (!subscribe[sub]) subscribe[sub] = {}; 398 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 399 | subscribe[sub][device][property] = data; 400 | winston.debug('Subscribing[R] ', sub); 401 | } 402 | }); 403 | }); 404 | // Subscribe to events 405 | winston.info('===================================ACTUAL SUBSCRIPTIONS REQUESTED FROM SMARTAPP ============================================'); 406 | winston.info('Currently subscribed to ' + subscriptions.join(', ')); 407 | winston.info('============================================================================================================================'); 408 | } 409 | 410 | function comparearrays(arr1, arr2){ 411 | if (!arr2) return null; 412 | if (!arr1) return arr2; 413 | let newarray = []; 414 | arr2.forEach (function (sub){ 415 | if (!arr1.includes(sub)) newarray.push(sub); 416 | }); 417 | return newarray; 418 | } 419 | 420 | 421 | /** 422 | * Get the topic name for a standard device that is not in device config file 423 | * @method getTopicFor 424 | * @param {String} device Device Name 425 | * @param {String} property Property 426 | * @param {String} type Type of topic (command or state) 427 | * @return {String} MQTT Topic name 428 | */ 429 | function getTopicFor (device, property, type) { 430 | var tree = [config.mqtt.preface, device, property], 431 | suffix; 432 | 433 | if (type === TOPIC_COMMAND) { 434 | suffix = config.mqtt[SUFFIX_COMMAND]; 435 | } else if (type === TOPIC_READ_STATE) { 436 | suffix = config.mqtt[SUFFIX_READ_STATE]; 437 | } else if (type === TOPIC_WRITE_STATE) { 438 | suffix = config.mqtt[SUFFIX_WRITE_STATE]; 439 | } 440 | 441 | if (suffix) { 442 | tree.push(suffix); 443 | } 444 | return tree.join('/'); 445 | } 446 | 447 | /** 448 | * Check if the topic is subscribed to in external device config file 449 | * Can match subscriptions that MQTT wildcards 450 | * @method isSubscribed 451 | * @param {String} topic topic received from MQTT broker 452 | * @return {String} topic topic from config file (may include wildcards) 453 | */ 454 | function isSubscribed(topic){ 455 | if (!subscriptions) return null; 456 | var topics = []; 457 | for (let i=0; i< subscriptions.length; i++){ 458 | if (subscriptions[i] == topic) { 459 | topics.push(subscriptions[i]); 460 | }else if (mqttWildcard(topic, subscriptions[i]) != null) topics.push(subscriptions[i]); 461 | } 462 | return topics ; 463 | } 464 | 465 | 466 | /** 467 | * Parse incoming message from MQTT 468 | * @method parseMQTTMessage 469 | * @param {String} topic Topic channel the event came from 470 | * @param {String} message Contents of the event 471 | */ 472 | function parseMQTTMessage (incoming, message) { 473 | var contents = message.toString(); 474 | winston.debug('From MQTT: %s = %s', incoming, contents); 475 | var topics = isSubscribed(incoming); 476 | var device, property, cmd, value; 477 | if (!topics) { 478 | winston.warn('%s-%s not subscribed. State error. Ignoring. ', incoming, contents); 479 | return; 480 | } 481 | if (topics.length > 1) winston.info('Incoming topic maps to multiple subscriptions: %s = %s', incoming, topics); 482 | // Topic is subscribe to 483 | if ((!!topics) && (!!subscribe)){ 484 | topics.forEach(function(topic) { 485 | Object.keys(subscribe[topic]).forEach(function(name) { 486 | // Checking if external device for this topic 487 | if (!!devices[name]){ 488 | Object.keys(subscribe[topic][name]).forEach(function(attribute) { 489 | device = subscribe[topic][name][attribute]['device']; 490 | property = subscribe[topic][name][attribute]['attribute']; 491 | value = contents; 492 | if ((!!subscribe[topic][name][attribute]['command']) && (!!subscribe[topic][name][attribute]['command'][contents])) 493 | value = subscribe[topic][name][attribute]['command'][contents]; 494 | cmd = true; 495 | postRequest(topic, contents, device, property, value, cmd, incoming); 496 | }); 497 | } else { 498 | // Remove the preface from the topic before splitting it 499 | var pieces = topic.substr(config.mqtt.preface.length + 1).split('/'); 500 | device = pieces[0]; 501 | property = pieces[1]; 502 | value = contents; 503 | var topicReadState = getTopicFor(device, property, TOPIC_READ_STATE), 504 | topicWriteState = getTopicFor(device, property, TOPIC_WRITE_STATE), 505 | topicSwitchState = getTopicFor(device, 'switch', TOPIC_READ_STATE), 506 | topicLevelCommand = getTopicFor(device, 'level', TOPIC_COMMAND), 507 | cmd = (!pieces[2] || pieces[2] && pieces[2] === config.mqtt[SUFFIX_COMMAND]); 508 | // Deduplicate only if the incoming message topic is the same as the read state topic 509 | if (topic === topicReadState) { 510 | if (history[topic] === contents) { 511 | winston.info('Skipping duplicate message from: %s = %s', topic, contents); 512 | return; 513 | } 514 | } 515 | // If sending level data and the switch is off, don't send anything 516 | // SmartThings will turn the device on (which is confusing) 517 | if (property === 'level' && history[topicSwitchState] === 'off') { 518 | winston.info('Skipping level set due to device being off'); 519 | return; 520 | } 521 | 522 | // If sending switch data and there is already a nonzero level value, send level instead 523 | // SmartThings will turn the device on 524 | if (property === 'switch' && contents === 'on' && 525 | history[topicLevelCommand] > 0) { 526 | winston.info('Passing level instead of switch on'); 527 | property = 'level'; 528 | contents = history[topicLevelCommand]; 529 | } 530 | postRequest(topic, contents, device, property, value, cmd, incoming); 531 | } 532 | }); 533 | }); 534 | } 535 | } 536 | 537 | 538 | function postRequest(topic, contents, device, property, value, cmd, incoming){ 539 | 540 | // If we subscribe to topic we are publishing we get into a loop 541 | if ((!!publications) && (!!publications[incoming]) && (!!publications[incoming][device]) && (!!publications[incoming][device][property])){ 542 | winston.error('Incoming %s for attribute %s for device %s is also being published to: [%s][%s][%s].\nIgnoring: %s = %s', incoming, property, device, device, property, incoming, incoming, contents); 543 | return; 544 | } 545 | history[incoming] = contents; 546 | var msg = (contents.length > 25) ? contents.substr(0,25) + "..." : contents 547 | if (!topic.match(/[*+]/)) { 548 | winston.info('MQTT --> ST - Topic: [%s][%s]\t[%s][%s][%s]', topic, msg , device, property, value) 549 | } else { 550 | winston.info('MQTT --> MQTT[WildCard] - Topic: [%s][%s]\t[%s][%s]', incoming, msg, topic, msg); 551 | winston.info('MQTT[WildCard] --> ST - Topic: [%s][%s]\t[%s][%s][%s]', topic, msg , device, property, value) 552 | } 553 | request.post({ 554 | url: 'http://' + callback, 555 | json: { 556 | name: device, 557 | type: property, 558 | value: value, 559 | command: cmd 560 | } 561 | }, function (error, resp) { 562 | if (error) { 563 | // @TODO handle the response from SmartThings 564 | winston.error('Error from SmartThings Hub: %s', error.toString()); 565 | winston.error(JSON.stringify(error, null, 4)); 566 | winston.error(JSON.stringify(resp, null, 4)); 567 | } 568 | }); 569 | } 570 | 571 | // Main flow 572 | async.series([ 573 | function loadFromDisk (next) { 574 | var state; 575 | winston.info('Starting SmartThings MQTT Bridge - v%s', CURRENT_VERSION); 576 | winston.info('Configuration Directory - %s', CONFIG_DIR); 577 | winston.info('Loading configuration'); 578 | configureDefaults(); 579 | winston.info('Loading previous state'); 580 | state = loadSavedState(); 581 | callback = state.callback; 582 | subscriptions = state.subscriptions; 583 | publications = state.publications; 584 | st_request = state.st_request; 585 | history = state.history; 586 | if (!!st_request) { 587 | try { 588 | winston.info ('Last Request from ST - %s', JSON.stringify(st_request)); 589 | processSubscriptions(st_request); 590 | } catch (ex) { 591 | winston.error(ex); 592 | winston.info('Could not restore subscriptions. Please rebuscribe in IDE, continuing'); 593 | return; 594 | } 595 | } 596 | saveState(); 597 | process.nextTick(next); 598 | }, 599 | function connectToMQTT (next) { 600 | // Default protocol 601 | if (!url.parse(config.mqtt.host).protocol) { 602 | config.mqtt.host = 'mqtt://' + config.mqtt.host; 603 | } 604 | winston.info('Connecting to MQTT at %s', config.mqtt.host); 605 | client = mqtt.connect(config.mqtt.host, config.mqtt); 606 | client.on('message', parseMQTTMessage); 607 | client.on('connect', function () { 608 | if (subscriptions.length > 0) { 609 | client.subscribe(subscriptions); 610 | winston.info('Subscribing to - %s', subscriptions); 611 | } 612 | next(); 613 | // @TODO Not call this twice if we get disconnected 614 | next = function () {}; 615 | }); 616 | }, 617 | function configureCron (next) { 618 | winston.info('Configuring autosave'); 619 | 620 | // Save current state every 15 minutes 621 | setInterval(saveState, 15 * 60 * 1000); 622 | 623 | process.nextTick(next); 624 | }, 625 | function setupApp (next) { 626 | winston.info('Configuring API'); 627 | 628 | // Accept JSON 629 | app.use(bodyparser.json()); 630 | 631 | // Log all requests to disk 632 | app.use(expressWinston.logger({ 633 | format: logFormat, 634 | msg: "HTTP /{{req.method}} {{req.url}} host={{req.hostname}} --> Status Code={{res.statusCode}} ResponseTime={{res.responseTime}}ms", 635 | transports : [accessLog] 636 | })); 637 | 638 | // Push event from SmartThings 639 | app.post('/push', 640 | expressJoi.body(joi.object({ 641 | // "name": "Energy Meter", 642 | name: joi.string().required(), 643 | // "value": "873", 644 | value: joi.string().required(), 645 | // "type": "power", 646 | type: joi.string().required() 647 | })), handlePushEvent); 648 | 649 | // Subscribe event from SmartThings 650 | app.post('/subscribe', 651 | expressJoi.body(joi.object({ 652 | devices: joi.object().required(), 653 | callback: joi.string().required() 654 | })), handleSubscribeEvent); 655 | 656 | // Log all errors to disk 657 | app.use(expressWinston.errorLogger({ 658 | format: logFormat, 659 | msg: " [{{res.statusCode}} {{req.method}}] {{err.message}}\n{{err.stack}}", 660 | transports : [errorLog,consoleLog] 661 | })); 662 | 663 | // Proper error messages with Joi 664 | app.use(function (err, req, res, next) { 665 | if (err.isBoom) { 666 | return res.status(err.output.statusCode).json(err.output.payload); 667 | } 668 | }); 669 | 670 | app.listen(config.port, next); 671 | } 672 | ], function (error) { 673 | if (error) { 674 | return winston.error(error); 675 | } 676 | winston.info('Listening at http://localhost:%s', config.port); 677 | }); 678 | -------------------------------------------------------------------------------- /test/mbs-server-retain-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ST-MQTT Bridge Server 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * Derived from work of previous authors 7 | * - st.john.johnson@gmail.com 8 | * - jeremiah.wuenschel@gmail.com 9 | * 10 | * A lot of initial work was done by the previous two authors 11 | * There is significant refactoring and added functionality since Oct 2019. 12 | * 13 | * Copyright 2019 14 | * 15 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 16 | * in compliance with the License. You may obtain a copy of the License at: 17 | * 18 | * http://www.apache.org/licenses/LICENSE-2.0 19 | * 20 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 21 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 22 | * for the specific language governing permissions and limitations under the License. 23 | */ 24 | 25 | /*jslint node: true */ 26 | 'use strict'; 27 | const { createLogger, format } = require('winston'); 28 | const logform = require('logform'); 29 | const { combine, timestamp, label, printf } = logform.format; 30 | 31 | var winston = require('winston'), 32 | daily = require('winston-daily-rotate-file'), 33 | express = require('express'), 34 | expressJoi = require('express-joi-validation').createValidator({}), 35 | expressWinston = require('express-winston'), 36 | bodyparser = require('body-parser'), 37 | mqtt = require('mqtt'), 38 | async = require('async'), 39 | url = require('url'), 40 | joi = require('@hapi/joi'), 41 | yaml = require('js-yaml'), 42 | jsonfile = require('jsonfile'), 43 | fs = require('fs-extra'), 44 | mqttWildcard = require('mqtt-wildcard'), 45 | request = require('request'), 46 | path = require('path'), 47 | 48 | CONFIG_DIR = process.env.CONFIG_DIR || path.join(__dirname,'config'), 49 | SAMPLE_FILE = path.join(CONFIG_DIR, '_config.yml'), 50 | CONFIG_FILE = path.join(CONFIG_DIR, 'config.yml'), 51 | DEVICE_CONFIG_FILE = path.join(CONFIG_DIR, 'devices.yml'), 52 | STATE_FILE = path.join(CONFIG_DIR, 'data', 'state.json'), 53 | STATE_SUMMARY_FILE = path.join(CONFIG_DIR, 'data', 'state.summary.json'), 54 | CURRENT_VERSION = require('./package').version, 55 | // The topic type to get state changes from smartthings 56 | TOPIC_READ_STATE = 'state', 57 | SUFFIX_READ_STATE = 'state_read_suffix', 58 | // The topic type to send commands to smartthings 59 | TOPIC_COMMAND = 'command', 60 | SUFFIX_COMMAND = 'command_suffix', 61 | // The topic type to send state changes to smartthings 62 | TOPIC_WRITE_STATE = 'set_state', 63 | SUFFIX_WRITE_STATE = 'state_write_suffix', 64 | RETAIN = 'retain', 65 | config = loadConfiguration(), 66 | LOGGING_LEVEL = config.loglevel, 67 | 68 | app = express(), 69 | client, 70 | subscriptions = [], 71 | publications = {}, 72 | subscribe = {}, 73 | callback = '', 74 | devices = {}, 75 | st_request = {}, 76 | history = {}, 77 | 78 | // winston transports 79 | consoleLog = new winston.transports.Console(), 80 | eventsLog = new (winston.transports.DailyRotateFile)({ 81 | filename: path.join(CONFIG_DIR, 'log', 'events-%DATE%.log'), 82 | datePattern: 'YYYY-MM-DD', 83 | maxSize: '5m', 84 | maxFiles: '10', 85 | json: false 86 | }), 87 | accessLog = new (winston.transports.DailyRotateFile)({ 88 | filename: path.join(CONFIG_DIR, 'log', 'access-%DATE%.log'), 89 | datePattern: 'YYYY-MM', 90 | maxSize: '5m', 91 | maxFiles: '5', 92 | json: false 93 | }), 94 | errorLog = new (winston.transports.DailyRotateFile)({ 95 | filename: path.join(CONFIG_DIR, 'log', 'error-%DATE%.log'), 96 | datePattern: 'YYYY-MM', 97 | maxSize: '5m', 98 | maxFiles: '5', 99 | json: false 100 | }), 101 | logFormat = combine(format.splat(), 102 | timestamp({format:(new Date()).toLocaleString('en-US'), format: 'YYYY-MM-DD HH:mm:ss A'}), 103 | printf(nfo => {return `${nfo.timestamp} ${nfo.level}: ${nfo.message}`;}) 104 | ); 105 | 106 | 107 | 108 | winston = createLogger({ 109 | level: LOGGING_LEVEL, 110 | format: logFormat, 111 | transports : [eventsLog, consoleLog] 112 | }); 113 | 114 | 115 | function loadConfiguration () { 116 | if (!fs.existsSync(CONFIG_FILE)) { 117 | console.log('No previous configuration found, creating one'); 118 | fs.writeFileSync(CONFIG_FILE, fs.readFileSync(SAMPLE_FILE)); 119 | } 120 | return yaml.safeLoad(fs.readFileSync(CONFIG_FILE)); 121 | } 122 | 123 | /** 124 | * Load device configuration if it exists 125 | * @method loadDeviceConfiguration 126 | * @return {Object} Configuration 127 | */ 128 | function loadDeviceConfiguration () { 129 | subscribe = {}; 130 | if (!config.deviceconfig) return null; 131 | var output; 132 | try { 133 | output = yaml.safeLoad(fs.readFileSync(DEVICE_CONFIG_FILE)); 134 | } catch (ex) { 135 | winston.error(ex); 136 | winston.info('ERROR loading external device configurations, continuing'); 137 | return; 138 | } 139 | Object.keys(output).forEach(function (device) { 140 | winston.debug("Loading config for Device " , device); 141 | Object.keys(output[device]["subscribe"]).forEach (function (attribute){ 142 | Object.keys(output[device]["subscribe"][attribute]).forEach (function (sub){ 143 | let data = {}; 144 | data['device']= device; 145 | data['attribute'] = attribute; 146 | if ((!!output[device]["subscribe"][attribute][sub]) && (!!output[device]["subscribe"][attribute][sub]['command'])) 147 | data['command']= output[device]["subscribe"][attribute][sub]['command']; 148 | if (!subscribe[sub]) subscribe[sub] = {}; 149 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 150 | subscribe[sub][device][attribute] = data; 151 | winston.debug("Subscription %s\t\[%s],[%s],[%s]",sub, subscribe[sub][device][attribute]['device'], 152 | subscribe[sub][device][attribute]['attribute'],subscribe[sub][device][attribute]['command']); 153 | winston.debug("Subscription: %s - Device %s" , sub, subscribe[sub]); 154 | }); 155 | }); 156 | }); 157 | winston.info('============================ALL POSSIBLE SUBSCRIPTIONS FROM ALL EXTERNAL DEVICES ==========================================='); 158 | Object.keys(subscribe).forEach(function (subs){ 159 | Object.keys(subscribe[subs]).forEach(function (dev){ 160 | Object.keys(subscribe[subs][dev]).forEach(function (attribute){ 161 | winston.info("Subscription %s\t\[%s],[%s],[%s]",subs, subscribe[subs][dev][attribute]['device'], 162 | subscribe[subs][dev][attribute]['attribute'], JSON.stringify((!!subscribe[subs][dev][attribute]['command']) ? subscribe[subs][dev][attribute]['command'] : '')); 163 | }); 164 | }); 165 | }); 166 | winston.info('============================================================================================================================'); 167 | 168 | return output; 169 | } 170 | 171 | /** 172 | * Set defaults for missing definitions in config file 173 | * @method configureDefaults 174 | * @param {String} version Version the state was written in before 175 | */ 176 | function configureDefaults() { 177 | // Make sure the object exists 178 | if (!config.mqtt) { 179 | config.mqtt = {}; 180 | } 181 | 182 | if (!config.mqtt.preface) { 183 | config.mqtt.preface = '/smartthings'; 184 | } 185 | 186 | // Default Suffixes 187 | if (!config.mqtt[SUFFIX_READ_STATE]) { 188 | config.mqtt[SUFFIX_READ_STATE] = ''; 189 | } 190 | if (!config.mqtt[SUFFIX_COMMAND]) { 191 | config.mqtt[SUFFIX_COMMAND] = ''; 192 | } 193 | if (!config.mqtt[SUFFIX_WRITE_STATE]) { 194 | config.mqtt[SUFFIX_WRITE_STATE] = ''; 195 | } 196 | 197 | // Default retain 198 | if (!config.mqtt[RETAIN]) { 199 | config.mqtt[RETAIN] = false; 200 | } 201 | 202 | // Default port 203 | if (!config.port) { 204 | config.port = 8080; 205 | } 206 | 207 | // Default protocol 208 | if (!url.parse(config.mqtt.host).protocol) { 209 | config.mqtt.host = 'mqtt://' + config.mqtt.host; 210 | } 211 | 212 | // Default protocol 213 | if (!config.deviceconfig) { 214 | config.deviceconfig = false; 215 | } 216 | } 217 | 218 | /** 219 | * Load the saved previous state from disk 220 | * @method loadSavedState 221 | * @return {Object} Configuration 222 | */ 223 | function loadSavedState () { 224 | var output; 225 | try { 226 | output = jsonfile.readFileSync(STATE_FILE); 227 | } catch (ex) { 228 | winston.info('No previous state found, continuing'); 229 | output = { 230 | version: '0.0.0', 231 | callback: '', 232 | subscriptions: {}, 233 | subscribe: {}, 234 | publications: {}, 235 | history: {}, 236 | st_request:{}, 237 | devices: {} 238 | }; 239 | } 240 | return output; 241 | } 242 | 243 | /** 244 | * Resubscribe on a periodic basis 245 | * @method saveState 246 | */ 247 | function saveState () { 248 | winston.info('Saving current state'); 249 | fs.ensureDir(path.join(CONFIG_DIR,'data')); 250 | jsonfile.writeFileSync(STATE_FILE, { 251 | version: CURRENT_VERSION, 252 | callback: callback, 253 | subscriptions: subscriptions, 254 | subscribe: subscribe, 255 | publications: publications, 256 | history: history, 257 | st_request: st_request, 258 | devices: devices 259 | }, { 260 | spaces: 4 261 | }); 262 | jsonfile.writeFileSync(STATE_SUMMARY_FILE, { 263 | version: CURRENT_VERSION, 264 | callback: callback, 265 | subscriptions: subscriptions, 266 | subscribe: Object.keys(subscribe), 267 | publications: Object.keys(publications), 268 | history: history, 269 | devices: (devices) ? Object.keys(devices) : devices 270 | }, { 271 | spaces: 4 272 | }); 273 | } 274 | 275 | 276 | /** 277 | * Handle Device Change/Push event from SmartThings 278 | * 279 | * @method handlePushEvent 280 | * @param {Request} req 281 | * @param {Object} req.body 282 | * @param {String} req.body.name Device Name (e.g. "Bedroom Light") 283 | * @param {String} req.body.type Device Property (e.g. "state") 284 | * @param {String} req.body.value Value of device (e.g. "on") 285 | * @param {Result} res Result Object 286 | */ 287 | function handlePushEvent (req, res) { 288 | var value = req.body.value, 289 | attribute = req.body.type, 290 | retain = config.mqtt[RETAIN], 291 | topic = "", 292 | device = req.body.name; 293 | winston.debug('From ST: %s - %s - %s', topic, req.body.type, value); 294 | // for devices from config file 295 | if ((!!devices[device]) && (!!devices[device]["publish"]) && (!!devices[device]["publish"][attribute])){ 296 | retain = (!!devices[device].retain) ? devices[device].retain : retain; 297 | Object.keys(devices[device]["publish"][attribute]).forEach (function (pub){ 298 | value = ((!!devices[device]["publish"][attribute][pub].command) && (!!devices[device]["publish"][attribute][pub].command[value])) 299 | ? devices[device].publish[attribute][pub].command[value] : value; 300 | topic = pub; 301 | winston.info('ST** --> MQTT: [%s][%s][%s]\t[%s][%s][Retain:%s]', req.body.name, req.body.type, req.body.value, topic, value, retain); 302 | }); 303 | }else { 304 | // for devices with standard read, write, command suffixes 305 | topic = getTopicFor(device, attribute, TOPIC_READ_STATE); 306 | winston.debug('Device from SmartThings: %s = %s', topic, value); 307 | winston.info('ST --> MQTT: [%s][%s][%s]\t[%s][%s][Retain:%s]', device, attribute, req.body.value, topic, value, retain); 308 | } 309 | mqttPublish(device, attribute, topic, value, retain, res); 310 | } 311 | 312 | function mqttPublish(device, attribute, topic, value, retainflag, res){ 313 | history[topic] = value; 314 | if ((!!publications) && (!publications[topic])){ 315 | let data = {}; 316 | data['device'] = device; 317 | data['attribute'] = attribute; 318 | data['command'] = value; 319 | publications[topic] = {}; 320 | publications[topic][device] = data; 321 | } else if (!!publications[topic][device]) publications[topic][device]['command'] = value; 322 | var sub = isSubscribed(topic); 323 | if ((!!subscribe) && (!!subscribe[sub]) && (!!subscribe[sub][device]) && (!!subscribe[sub][device][attribute])) { 324 | winston.warn('POSSIBLE LOOP. Device[Attribute] %s[%s] is publishing to Topic %s while subscribed to Topic %s', device, attribute, topic, sub); 325 | } else if ((!!subscribe[sub]) && (!!subscribe[sub][device])) { 326 | winston.warn('POSSIBLE LOOP. Device %s is publishing to Topic %s while subscribed to Topic %s', device, topic, sub); 327 | } 328 | client.publish(topic, value, {retain: retainflag}, function () { 329 | res.send({ 330 | status: 'OK' 331 | }); 332 | }); 333 | } 334 | 335 | /** 336 | * Handle Subscribe event from SmartThings 337 | * 338 | * @method handleSubscribeEvent 339 | * @param {Request} req 340 | * @param {Object} req.body 341 | * @param {Object} req.body.devices List of properties => device names 342 | * @param {String} req.body.callback Host and port for SmartThings Hub 343 | * @param {Result} res Result Object 344 | */ 345 | function handleSubscribeEvent (req, res) { 346 | // Subscribe to all events 347 | let oldsubscriptions = subscriptions; 348 | st_request = req.body.devices; 349 | processSubscriptions(st_request); 350 | // Store callback 351 | callback = req.body.callback; 352 | // Store current state on disk 353 | saveState(); 354 | let unsubs = comparearrays(subscriptions, oldsubscriptions), 355 | subs = comparearrays(oldsubscriptions, subscriptions); 356 | if ((!!unsubs) && (unsubs.length > 0)) client.unsubscribe(unsubs); 357 | if ((!!subs) && (subs.length > 0)) { 358 | winston.info('We are mqtt subscribing'); 359 | client.subscribe( subs , function () { 360 | res.send({ 361 | status: 'OK' 362 | }); 363 | }); 364 | } 365 | } 366 | 367 | function processSubscriptions(req){ 368 | if (config.deviceconfig) { 369 | winston.info('Loading Device configuration'); 370 | devices = loadDeviceConfiguration(); 371 | } 372 | subscriptions = []; 373 | Object.keys(req).forEach(function (property) { 374 | winston.debug('Property - %s ', property); 375 | req[property].forEach(function (device) { 376 | winston.debug(' %s - %s ', property, device); 377 | // CRITICAL - if device in DEVICE_CONFIG_FILE, file sub/pub info will supercedes 378 | if (!!devices && (!!devices[device])) { 379 | if ((!!devices[device]["subscribe"]) && (!!devices[device]["subscribe"][property])){ 380 | Object.keys(devices[device]["subscribe"][property]).forEach (function (sub){ 381 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 382 | winston.debug('Subscribing[CUSTOM] ', sub); 383 | }); 384 | } 385 | }else { 386 | let data = {}; 387 | data['device']=device; 388 | data['attribute'] = property; 389 | let sub = getTopicFor(device, property, TOPIC_COMMAND); 390 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 391 | if (!subscribe[sub]) subscribe[sub] = {}; 392 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 393 | subscribe[sub][device][property] = data; 394 | sub = getTopicFor(device, property, TOPIC_WRITE_STATE); 395 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 396 | if (!subscribe[sub]) subscribe[sub] = {}; 397 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 398 | subscribe[sub][device][property] = data; 399 | winston.debug('Subscribing[R] ', sub); 400 | } 401 | }); 402 | }); 403 | // Subscribe to events 404 | winston.info('===================================ACTUAL SUBSCRIPTIONS REQUESTED FROM SMARTAPP ============================================'); 405 | winston.info('Currently subscribed to ' + subscriptions.join(', ')); 406 | winston.info('============================================================================================================================'); 407 | } 408 | 409 | function comparearrays(arr1, arr2){ 410 | if (!arr2) return null; 411 | if (!arr1) return arr2; 412 | let newarray = []; 413 | arr2.forEach (function (sub){ 414 | if (!arr1.includes(sub)) newarray.push(sub); 415 | }); 416 | return newarray; 417 | } 418 | 419 | 420 | /** 421 | * Get the topic name for a standard device that is not in device config file 422 | * @method getTopicFor 423 | * @param {String} device Device Name 424 | * @param {String} property Property 425 | * @param {String} type Type of topic (command or state) 426 | * @return {String} MQTT Topic name 427 | */ 428 | function getTopicFor (device, property, type) { 429 | var tree = [config.mqtt.preface, device, property], 430 | suffix; 431 | 432 | if (type === TOPIC_COMMAND) { 433 | suffix = config.mqtt[SUFFIX_COMMAND]; 434 | } else if (type === TOPIC_READ_STATE) { 435 | suffix = config.mqtt[SUFFIX_READ_STATE]; 436 | } else if (type === TOPIC_WRITE_STATE) { 437 | suffix = config.mqtt[SUFFIX_WRITE_STATE]; 438 | } 439 | 440 | if (suffix) { 441 | tree.push(suffix); 442 | } 443 | return tree.join('/'); 444 | } 445 | 446 | /** 447 | * Check if the topic is subscribed to in external device config file 448 | * Can match subscriptions that MQTT wildcards 449 | * @method isSubscribed 450 | * @param {String} topic topic received from MQTT broker 451 | * @return {String} topic topic from config file (may include wildcards) 452 | */ 453 | function isSubscribed(topic){ 454 | if (!subscriptions) return null; 455 | var topics = []; 456 | for (let i=0; i< subscriptions.length; i++){ 457 | if (subscriptions[i] == topic) { 458 | topics.push(subscriptions[i]); 459 | }else if (mqttWildcard(topic, subscriptions[i]) != null) topics.push(subscriptions[i]); 460 | } 461 | return topics ; 462 | } 463 | 464 | 465 | /** 466 | * Parse incoming message from MQTT 467 | * @method parseMQTTMessage 468 | * @param {String} topic Topic channel the event came from 469 | * @param {String} message Contents of the event 470 | */ 471 | function parseMQTTMessage (incoming, message) { 472 | var contents = message.toString(); 473 | winston.debug('From MQTT: %s = %s', incoming, contents); 474 | var topics = isSubscribed(incoming); 475 | var device, property, cmd, value; 476 | if (!topics) { 477 | winston.warn('%s-%s not subscribed. State error. Ignoring. ', incoming, contents); 478 | return; 479 | } 480 | if (topics.length > 1) winston.info('Incoming topic maps to multiple subscriptions: %s = %s', incoming, topics); 481 | // Topic is subscribe to 482 | if ((!!topics) && (!!subscribe)){ 483 | topics.forEach(function(topic) { 484 | Object.keys(subscribe[topic]).forEach(function(name) { 485 | // Checking if external device for this topic 486 | if (!!devices[name]){ 487 | Object.keys(subscribe[topic][name]).forEach(function(attribute) { 488 | device = subscribe[topic][name][attribute]['device']; 489 | property = subscribe[topic][name][attribute]['attribute']; 490 | value = contents; 491 | if ((!!subscribe[topic][name][attribute]['command']) && (!!subscribe[topic][name][attribute]['command'][contents])) 492 | value = subscribe[topic][name][attribute]['command'][contents]; 493 | cmd = true; 494 | postRequest(topic, contents, device, property, value, cmd, incoming); 495 | }); 496 | } else { 497 | // Remove the preface from the topic before splitting it 498 | var pieces = topic.substr(config.mqtt.preface.length + 1).split('/'); 499 | device = pieces[0]; 500 | property = pieces[1]; 501 | value = contents; 502 | var topicReadState = getTopicFor(device, property, TOPIC_READ_STATE), 503 | topicWriteState = getTopicFor(device, property, TOPIC_WRITE_STATE), 504 | topicSwitchState = getTopicFor(device, 'switch', TOPIC_READ_STATE), 505 | topicLevelCommand = getTopicFor(device, 'level', TOPIC_COMMAND), 506 | cmd = (!pieces[2] || pieces[2] && pieces[2] === config.mqtt[SUFFIX_COMMAND]); 507 | // Deduplicate only if the incoming message topic is the same as the read state topic 508 | if (topic === topicReadState) { 509 | if (history[topic] === contents) { 510 | winston.info('Skipping duplicate message from: %s = %s', topic, contents); 511 | return; 512 | } 513 | } 514 | // If sending level data and the switch is off, don't send anything 515 | // SmartThings will turn the device on (which is confusing) 516 | if (property === 'level' && history[topicSwitchState] === 'off') { 517 | winston.info('Skipping level set due to device being off'); 518 | return; 519 | } 520 | 521 | // If sending switch data and there is already a nonzero level value, send level instead 522 | // SmartThings will turn the device on 523 | if (property === 'switch' && contents === 'on' && 524 | history[topicLevelCommand] > 0) { 525 | winston.info('Passing level instead of switch on'); 526 | property = 'level'; 527 | contents = history[topicLevelCommand]; 528 | } 529 | postRequest(topic, contents, device, property, value, cmd, incoming); 530 | } 531 | }); 532 | }); 533 | } 534 | } 535 | 536 | 537 | function postRequest(topic, contents, device, property, value, cmd, incoming){ 538 | 539 | // If we subscribe to topic we are publishing we get into a loop 540 | if ((!!publications) && (!!publications[incoming]) && (!!publications[incoming][device]) && (!!publications[incoming][device][property])){ 541 | winston.error('Incoming %s for attribute %s for device %s is also being published to: [%s][%s][%s].\nIgnoring: %s = %s', incoming, property, device, device, property, incoming, incoming, contents); 542 | return; 543 | } 544 | history[incoming] = contents; 545 | var msg = (contents.length > 25) ? contents.substr(0,25) + "..." : contents 546 | if (!topic.match(/[*+]/)) { 547 | winston.info('MQTT --> ST - Topic: [%s][%s]\t[%s][%s][%s]', topic, msg , device, property, value) 548 | } else { 549 | winston.info('MQTT --> MQTT[WildCard] - Topic: [%s][%s]\t[%s][%s]', incoming, msg, topic, msg); 550 | winston.info('MQTT[WildCard] --> ST - Topic: [%s][%s]\t[%s][%s][%s]', topic, msg , device, property, value) 551 | } 552 | request.post({ 553 | url: 'http://' + callback, 554 | json: { 555 | name: device, 556 | type: property, 557 | value: value, 558 | command: cmd 559 | } 560 | }, function (error, resp) { 561 | if (error) { 562 | // @TODO handle the response from SmartThings 563 | winston.error('Error from SmartThings Hub: %s', error.toString()); 564 | winston.error(JSON.stringify(error, null, 4)); 565 | winston.error(JSON.stringify(resp, null, 4)); 566 | } 567 | }); 568 | } 569 | 570 | // Main flow 571 | async.series([ 572 | function loadFromDisk (next) { 573 | var state; 574 | winston.info('Starting SmartThings MQTT Bridge - v%s', CURRENT_VERSION); 575 | winston.info('Configuration Directory - %s', CONFIG_DIR); 576 | winston.info('Loading configuration'); 577 | configureDefaults(); 578 | winston.info('Loading previous state'); 579 | state = loadSavedState(); 580 | callback = state.callback; 581 | subscriptions = state.subscriptions; 582 | publications = state.publications; 583 | st_request = state.st_request; 584 | history = state.history; 585 | if (!!st_request) { 586 | try { 587 | winston.info ('Last Request from ST - %s', JSON.stringify(st_request)); 588 | processSubscriptions(st_request); 589 | } catch (ex) { 590 | winston.error(ex); 591 | winston.info('Could not restore subscriptions. Please rebuscribe in IDE, continuing'); 592 | return; 593 | } 594 | } 595 | saveState(); 596 | process.nextTick(next); 597 | }, 598 | function connectToMQTT (next) { 599 | // Default protocol 600 | if (!url.parse(config.mqtt.host).protocol) { 601 | config.mqtt.host = 'mqtt://' + config.mqtt.host; 602 | } 603 | winston.info('Connecting to MQTT at %s', config.mqtt.host); 604 | client = mqtt.connect(config.mqtt.host, config.mqtt); 605 | client.on('message', parseMQTTMessage); 606 | client.on('connect', function () { 607 | if (subscriptions.length > 0) { 608 | client.subscribe(subscriptions); 609 | winston.info('Subscribing to - %s', subscriptions); 610 | } 611 | next(); 612 | // @TODO Not call this twice if we get disconnected 613 | next = function () {}; 614 | }); 615 | }, 616 | function configureCron (next) { 617 | winston.info('Configuring autosave'); 618 | 619 | // Save current state every 15 minutes 620 | setInterval(saveState, 15 * 60 * 1000); 621 | 622 | process.nextTick(next); 623 | }, 624 | function setupApp (next) { 625 | winston.info('Configuring API'); 626 | 627 | // Accept JSON 628 | app.use(bodyparser.json()); 629 | 630 | // Log all requests to disk 631 | app.use(expressWinston.logger({ 632 | format: logFormat, 633 | msg: "HTTP /{{req.method}} {{req.url}} host={{req.hostname}} --> Status Code={{res.statusCode}} ResponseTime={{res.responseTime}}ms", 634 | transports : [accessLog] 635 | })); 636 | 637 | // Push event from SmartThings 638 | app.post('/push', 639 | expressJoi.body(joi.object({ 640 | // "name": "Energy Meter", 641 | name: joi.string().required(), 642 | // "value": "873", 643 | value: joi.string().required(), 644 | // "type": "power", 645 | type: joi.string().required() 646 | })), handlePushEvent); 647 | 648 | // Subscribe event from SmartThings 649 | app.post('/subscribe', 650 | expressJoi.body(joi.object({ 651 | devices: joi.object().required(), 652 | callback: joi.string().required() 653 | })), handleSubscribeEvent); 654 | 655 | // Log all errors to disk 656 | app.use(expressWinston.errorLogger({ 657 | format: logFormat, 658 | msg: " [{{res.statusCode}} {{req.method}}] {{err.message}}\n{{err.stack}}", 659 | transports : [errorLog,consoleLog] 660 | })); 661 | 662 | // Proper error messages with Joi 663 | app.use(function (err, req, res, next) { 664 | if (err.isBoom) { 665 | return res.status(err.output.statusCode).json(err.output.payload); 666 | } 667 | }); 668 | 669 | app.listen(config.port, next); 670 | } 671 | ], function (error) { 672 | if (error) { 673 | return winston.error(error); 674 | } 675 | winston.info('Listening at http://localhost:%s', config.port); 676 | }); 677 | -------------------------------------------------------------------------------- /test/mbs-server-v2.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ST-MQTT Bridge Server 3 | * 4 | * Authors 5 | * - sandeep gupta 6 | * Derived from work of previous authors 7 | * - st.john.johnson@gmail.com 8 | * - jeremiah.wuenschel@gmail.com 9 | * 10 | * A lot of initial work was done by the previous two authors 11 | * There is significant refactoring and added functionality since Oct 2019. 12 | * 13 | * Copyright 2019 14 | * 15 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 16 | * in compliance with the License. You may obtain a copy of the License at: 17 | * 18 | * http://www.apache.org/licenses/LICENSE-2.0 19 | * 20 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 21 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 22 | * for the specific language governing permissions and limitations under the License. 23 | */ 24 | 25 | /*jslint node: true */ 26 | 'use strict'; 27 | const { createLogger, format } = require('winston'); 28 | const logform = require('logform'); 29 | const { combine, timestamp, label, printf } = logform.format; 30 | var winston = require('winston'), 31 | daily = require('winston-daily-rotate-file'), 32 | express = require('express'), 33 | expressJoi = require('express-joi-validator'), 34 | expressWinston = require('express-winston'), 35 | bodyparser = require('body-parser'), 36 | mqtt = require('mqtt'), 37 | async = require('async'), 38 | url = require('url'), 39 | joi = require('joi'), 40 | yaml = require('js-yaml'), 41 | jsonfile = require('jsonfile'), 42 | fs = require('fs-extra'), 43 | mqttWildcard = require('mqtt-wildcard'), 44 | request = require('request'), 45 | path = require('path'), 46 | CONFIG_DIR = __dirname, 47 | SAMPLE_FILE = path.join(CONFIG_DIR, '_config.yml'), 48 | CONFIG_FILE = path.join(CONFIG_DIR, 'config.yml') ; 49 | 50 | function loadConfiguration () { 51 | if (!fs.existsSync(CONFIG_FILE)) { 52 | console.log('No previous configuration found, creating one'); 53 | fs.writeFileSync(CONFIG_FILE, fs.readFileSync(SAMPLE_FILE)); 54 | } 55 | 56 | return yaml.safeLoad(fs.readFileSync(CONFIG_FILE)); 57 | } 58 | 59 | var config = loadConfiguration(), 60 | DEVICE_CONFIG_FILE = path.join(CONFIG_DIR, 'devices.yml'), 61 | STATE_FILE = path.join(CONFIG_DIR, 'data', 'state.json'), 62 | STATE_SUMMARY_FILE = path.join(CONFIG_DIR, 'data', 'state.summary.json'), 63 | CURRENT_VERSION = require('./package').version, 64 | // The topic type to get state changes from smartthings 65 | TOPIC_READ_STATE = 'state', 66 | SUFFIX_READ_STATE = 'state_read_suffix', 67 | // The topic type to send commands to smartthings 68 | TOPIC_COMMAND = 'command', 69 | SUFFIX_COMMAND = 'command_suffix', 70 | // The topic type to send state changes to smartthings 71 | TOPIC_WRITE_STATE = 'set_state', 72 | SUFFIX_WRITE_STATE = 'state_write_suffix', 73 | RETAIN = 'retain', 74 | LOGGING_LEVEL = config.loglevel; 75 | 76 | var app = express(), 77 | client, 78 | subscriptions = [], 79 | publications = {}, 80 | subscribe = {}, 81 | callback = '', 82 | devices = {}, 83 | st_request = {}, 84 | history = {}; 85 | 86 | // winston transports 87 | var consoleLog = new winston.transports.Console(), 88 | eventsLog = new (winston.transports.DailyRotateFile)({ 89 | filename: path.join(CONFIG_DIR, 'log', 'events-%DATE%.log'), 90 | datePattern: 'YYYY-MM-DD', 91 | maxSize: '5m', 92 | maxFiles: '10', 93 | json: false 94 | }), 95 | accessLog = new (winston.transports.DailyRotateFile)({ 96 | filename: path.join(CONFIG_DIR, 'log', 'access-%DATE%.log'), 97 | datePattern: 'YYYY-MM', 98 | maxSize: '5m', 99 | maxFiles: '5', 100 | json: false 101 | }), 102 | errorLog = new (winston.transports.DailyRotateFile)({ 103 | filename: path.join(CONFIG_DIR, 'log', 'error-%DATE%.log'), 104 | datePattern: 'YYYY-MM', 105 | maxSize: '5m', 106 | maxFiles: '5', 107 | json: false 108 | }), 109 | logFormat = combine(format.splat(), 110 | timestamp({format:(new Date()).toLocaleString('en-US'), format: 'YYYY-MM-DD HH:mm:ss A'}), 111 | printf(nfo => {return `${nfo.timestamp} ${nfo.level}: ${nfo.message}`;}) 112 | ), 113 | appFormat = combine(format.splat(), format.json(), 114 | timestamp({format:(new Date()).toLocaleString('en-US'), format: 'YYYY-MM-DD HH:mm:ss A'}), 115 | printf(nfo => {return `${nfo.timestamp} ${nfo.level}: ${JSON.stringify(nfo.meta)})`;}) 116 | ); 117 | 118 | 119 | 120 | winston = createLogger({ 121 | level: LOGGING_LEVEL, 122 | format: logFormat, 123 | transports : [eventsLog, consoleLog] 124 | }); 125 | 126 | /** 127 | * Load device configuration if it exists 128 | * @method loadDeviceConfiguration 129 | * @return {Object} Configuration 130 | */ 131 | function loadDeviceConfiguration () { 132 | subscribe = {}; 133 | if (!config.deviceconfig) return null; 134 | var output; 135 | try { 136 | output = yaml.safeLoad(fs.readFileSync(DEVICE_CONFIG_FILE)); 137 | } catch (ex) { 138 | winston.error(ex); 139 | winston.info('No external device configurations found, continuing'); 140 | return; 141 | } 142 | Object.keys(output).forEach(function (device) { 143 | winston.debug("Loading config for Device " , device); 144 | Object.keys(output[device]["subscribe"]).forEach (function (attribute){ 145 | Object.keys(output[device]["subscribe"][attribute]).forEach (function (sub){ 146 | var data = {}; 147 | data['device']= device; 148 | data['attribute'] = attribute; 149 | if ((!!output[device]["subscribe"][attribute][sub]) && (!!output[device]["subscribe"][attribute][sub]['command'])) 150 | data['command']= output[device]["subscribe"][attribute][sub]['command']; 151 | if (!subscribe[sub]) subscribe[sub] = {}; 152 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 153 | subscribe[sub][device][attribute] = data; 154 | winston.debug("Subscription %s\t\[%s],[%s],[%s]",sub, subscribe[sub][device][attribute]['device'], 155 | subscribe[sub][device][attribute]['attribute'],subscribe[sub][device][attribute]['command']); 156 | winston.debug("Subscription: %s - Device %s" , sub, subscribe[sub]); 157 | }); 158 | }); 159 | }); 160 | winston.info('============================ALL POSSIBLE SUBSCRIPTIONS FROM ALL EXTERNAL DEVICES ==========================================='); 161 | Object.keys(subscribe).forEach(function (subs){ 162 | Object.keys(subscribe[subs]).forEach(function (dev){ 163 | Object.keys(subscribe[subs][dev]).forEach(function (attribute){ 164 | winston.info("Subscription %s\t\[%s],[%s],[%s]",subs, subscribe[subs][dev][attribute]['device'], 165 | subscribe[subs][dev][attribute]['attribute'], JSON.stringify((!!subscribe[subs][dev][attribute]['command']) ? subscribe[subs][dev][attribute]['command'] : '')); 166 | }); 167 | }); 168 | }); 169 | winston.info('============================================================================================================================'); 170 | 171 | return output; 172 | } 173 | 174 | /** 175 | * Set defaults for missing definitions in config file 176 | * @method configureDefaults 177 | * @param {String} version Version the state was written in before 178 | */ 179 | function configureDefaults() { 180 | // Make sure the object exists 181 | if (!config.mqtt) { 182 | config.mqtt = {}; 183 | } 184 | 185 | if (!config.mqtt.preface) { 186 | config.mqtt.preface = '/smartthings'; 187 | } 188 | 189 | // Default Suffixes 190 | if (!config.mqtt[SUFFIX_READ_STATE]) { 191 | config.mqtt[SUFFIX_READ_STATE] = ''; 192 | } 193 | if (!config.mqtt[SUFFIX_COMMAND]) { 194 | config.mqtt[SUFFIX_COMMAND] = ''; 195 | } 196 | if (!config.mqtt[SUFFIX_WRITE_STATE]) { 197 | config.mqtt[SUFFIX_WRITE_STATE] = ''; 198 | } 199 | 200 | // Default retain 201 | if (!config.mqtt[RETAIN]) { 202 | config.mqtt[RETAIN] = false; 203 | } 204 | 205 | // Default port 206 | if (!config.port) { 207 | config.port = 8080; 208 | } 209 | 210 | // Default protocol 211 | if (!url.parse(config.mqtt.host).protocol) { 212 | config.mqtt.host = 'mqtt://' + config.mqtt.host; 213 | } 214 | 215 | // Default protocol 216 | if (!config.deviceconfig) { 217 | config.deviceconfig = false; 218 | } 219 | } 220 | 221 | /** 222 | * Load the saved previous state from disk 223 | * @method loadSavedState 224 | * @return {Object} Configuration 225 | */ 226 | function loadSavedState () { 227 | var output; 228 | try { 229 | output = jsonfile.readFileSync(STATE_FILE); 230 | } catch (ex) { 231 | winston.info('No previous state found, continuing'); 232 | output = { 233 | version: '0.0.0', 234 | callback: '', 235 | subscriptions: {}, 236 | subscribe: {}, 237 | publications: {}, 238 | history: {}, 239 | st_request:{}, 240 | devices: {} 241 | }; 242 | } 243 | return output; 244 | } 245 | 246 | /** 247 | * Resubscribe on a periodic basis 248 | * @method saveState 249 | */ 250 | function saveState () { 251 | winston.info('Saving current state'); 252 | fs.ensureDir(path.join(CONFIG_DIR,'data')); 253 | jsonfile.writeFileSync(STATE_FILE, { 254 | version: CURRENT_VERSION, 255 | callback: callback, 256 | subscriptions: subscriptions, 257 | subscribe: subscribe, 258 | publications: publications, 259 | history: history, 260 | st_request: st_request, 261 | devices: devices 262 | }, { 263 | spaces: 4 264 | }); 265 | jsonfile.writeFileSync(STATE_SUMMARY_FILE, { 266 | version: CURRENT_VERSION, 267 | callback: callback, 268 | subscriptions: subscriptions, 269 | subscribe: Object.keys(subscribe), 270 | publications: Object.keys(publications), 271 | history: history, 272 | devices: (devices) ? Object.keys(devices) : devices 273 | }, { 274 | spaces: 4 275 | }); 276 | } 277 | 278 | 279 | /** 280 | * Handle Device Change/Push event from SmartThings 281 | * 282 | * @method handlePushEvent 283 | * @param {Request} req 284 | * @param {Object} req.body 285 | * @param {String} req.body.name Device Name (e.g. "Bedroom Light") 286 | * @param {String} req.body.type Device Property (e.g. "state") 287 | * @param {String} req.body.value Value of device (e.g. "on") 288 | * @param {Result} res Result Object 289 | */ 290 | function handlePushEvent (req, res) { 291 | var value = req.body.value, 292 | attribute = req.body.type, 293 | retain = config.mqtt[RETAIN], 294 | topic = "", 295 | device = req.body.name; 296 | winston.debug('From ST: %s - %s - %s', topic, req.body.type, value); 297 | // for devices from config file 298 | if ((!!devices[device]) && (!!devices[device]["publish"]) && (!!devices[device]["publish"][attribute])){ 299 | retain = (!!devices[device].retain) ? devices[device].retain : retain; 300 | Object.keys(devices[device]["publish"][attribute]).forEach (function (pub){ 301 | value = ((!!devices[device]["publish"][attribute][pub].command) && (!!devices[device]["publish"][attribute][pub].command[value])) 302 | ? devices[device].publish[attribute][pub].command[value] : value; 303 | topic = pub; 304 | winston.info('ST** --> MQTT: [%s][%s][%s]\t[%s][%s]', req.body.name, req.body.type, req.body.value, topic, value); 305 | mqttPublish(device, attribute, topic, value, retain, res); 306 | }); 307 | }else { 308 | // for devices with standard read, write, command suffixes 309 | topic = getTopicFor(device, attribute, TOPIC_READ_STATE); 310 | winston.debug('Device from SmartThings: %s = %s', topic, value); 311 | winston.info('ST --> MQTT: [%s][%s][%s]\t[%s][%s]', device, attribute, req.body.value, topic, value); 312 | mqttPublish(device, attribute, topic, value, retain, res); 313 | } 314 | } 315 | 316 | function mqttPublish(device, attribute, topic, value, retain, res){ 317 | history[topic] = value; 318 | if ((!!publications) && (!publications[topic])){ 319 | var data = {}; 320 | data['device'] = device; 321 | data['attribute'] = attribute; 322 | data['command'] = value; 323 | publications[topic] = {}; 324 | publications[topic][device] = data; 325 | } 326 | var sub = isSubscribed(topic); 327 | if ((!!subscribe) && (!!subscribe[sub]) && (!!subscribe[sub][device]) && (!!subscribe[sub][device][attribute])) { 328 | winston.warn('POSSIBLE LOOP. Device[Attribute] %s[%s] is publishing to Topic %s while subscribed to Topic %s', device, attribute, topic, sub); 329 | } else if ((!!subscribe[sub]) && (!!subscribe[sub][device])) { 330 | winston.warn('POSSIBLE LOOP. Device %s is publishing to Topic %s while subscribed to Topic %s', device, topic, sub); 331 | } 332 | client.publish(topic, value, retain, function () { 333 | res.send({ 334 | status: 'OK' 335 | }); 336 | }); 337 | } 338 | 339 | /** 340 | * Handle Subscribe event from SmartThings 341 | * 342 | * @method handleSubscribeEvent 343 | * @param {Request} req 344 | * @param {Object} req.body 345 | * @param {Object} req.body.devices List of properties => device names 346 | * @param {String} req.body.callback Host and port for SmartThings Hub 347 | * @param {Result} res Result Object 348 | */ 349 | function handleSubscribeEvent (req, res) { 350 | // Subscribe to all events 351 | var oldsubscriptions = subscriptions; 352 | st_request = req.body.devices; 353 | processSubscriptions(st_request); 354 | // Store callback 355 | callback = req.body.callback; 356 | // Store current state on disk 357 | saveState(); 358 | var unsubs = comparearrays(subscriptions, oldsubscriptions), 359 | subs = comparearrays(oldsubscriptions, subscriptions); 360 | if ((!!unsubs) && (unsubs.length > 0)) client.unsubscribe(unsubs); 361 | if ((!!subs) && (subs.length > 0)) { 362 | winston.info('We are mqtt subscribing'); 363 | client.subscribe( subs , function () { 364 | res.send({ 365 | status: 'OK' 366 | }); 367 | }); 368 | } 369 | } 370 | 371 | function processSubscriptions(req){ 372 | if (config.deviceconfig) { 373 | winston.info('Loading Device configuration'); 374 | devices = loadDeviceConfiguration(); 375 | } 376 | subscriptions = []; 377 | Object.keys(req).forEach(function (property) { 378 | winston.debug('Property - %s ', property); 379 | req[property].forEach(function (device) { 380 | winston.debug(' %s - %s ', property, device); 381 | // CRITICAL - if device in DEVICE_CONFIG_FILE, file sub/pub info will supercedes 382 | if ((!!devices[device])) { 383 | if ((!!devices[device]["subscribe"]) && (!!devices[device]["subscribe"][property])){ 384 | Object.keys(devices[device]["subscribe"][property]).forEach (function (sub){ 385 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 386 | winston.debug('Subscribing[CUSTOM] ', sub); 387 | }); 388 | } 389 | }else { 390 | var data = {}; 391 | data['device']=device; 392 | data['attribute'] = property; 393 | var sub = getTopicFor(device, property, TOPIC_COMMAND); 394 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 395 | if (!subscribe[sub]) subscribe[sub] = {}; 396 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 397 | subscribe[sub][device][property] = data; 398 | sub = getTopicFor(device, property, TOPIC_WRITE_STATE); 399 | if (!subscriptions.includes(sub)) subscriptions.push(sub); 400 | if (!subscribe[sub]) subscribe[sub] = {}; 401 | if (!subscribe[sub][device]) subscribe[sub][device] = {}; 402 | subscribe[sub][device][property] = data; 403 | winston.debug('Subscribing[R] ', sub); 404 | } 405 | }); 406 | }); 407 | // Subscribe to events 408 | winston.info('===================================ACTUAL SUBSCRIPTIONS REQUESTED FROM SMARTAPP ============================================'); 409 | winston.info('Currently subscribed to ' + subscriptions.join(', ')); 410 | winston.info('============================================================================================================================'); 411 | } 412 | 413 | function comparearrays(arr1, arr2){ 414 | if (!arr2) return null; 415 | if (!arr1) return arr2; 416 | var newarray = []; 417 | arr2.forEach (function (sub){ 418 | if (!arr1.includes(sub)) newarray.push(sub); 419 | }); 420 | return newarray; 421 | } 422 | 423 | 424 | /** 425 | * Get the topic name for a standard device that is not in device config file 426 | * @method getTopicFor 427 | * @param {String} device Device Name 428 | * @param {String} property Property 429 | * @param {String} type Type of topic (command or state) 430 | * @return {String} MQTT Topic name 431 | */ 432 | function getTopicFor (device, property, type) { 433 | var tree = [config.mqtt.preface, device, property], 434 | suffix; 435 | 436 | if (type === TOPIC_COMMAND) { 437 | suffix = config.mqtt[SUFFIX_COMMAND]; 438 | } else if (type === TOPIC_READ_STATE) { 439 | suffix = config.mqtt[SUFFIX_READ_STATE]; 440 | } else if (type === TOPIC_WRITE_STATE) { 441 | suffix = config.mqtt[SUFFIX_WRITE_STATE]; 442 | } 443 | 444 | if (suffix) { 445 | tree.push(suffix); 446 | } 447 | return tree.join('/'); 448 | } 449 | 450 | /** 451 | * Check if the topic is subscribed to in external device config file 452 | * Can match subscriptions that MQTT wildcards 453 | * @method isSubscribed 454 | * @param {String} topic topic received from MQTT broker 455 | * @return {String} topic topic from config file (may include wildcards) 456 | */ 457 | function isSubscribed(topic){ 458 | if (!subscriptions) return null; 459 | var topics = [] 460 | var i; 461 | for (i=0; i< subscriptions.length; i++){ 462 | if (subscriptions[i] == topic) { 463 | topics.push(subscriptions[i]); 464 | }else if (mqttWildcard(topic, subscriptions[i]) != null) topics.push(subscriptions[i]); 465 | } 466 | return topics ; 467 | } 468 | 469 | 470 | /** 471 | * Parse incoming message from MQTT 472 | * @method parseMQTTMessage 473 | * @param {String} topic Topic channel the event came from 474 | * @param {String} message Contents of the event 475 | */ 476 | function parseMQTTMessage (incoming, message) { 477 | var contents = message.toString(); 478 | winston.debug('From MQTT: %s = %s', incoming, contents); 479 | var topics = isSubscribed(incoming); 480 | var device, property, cmd, value; 481 | if (!topics) { 482 | winston.warn('%s-%s not subscribed. State error. Ignoring. ', incoming, contents); 483 | return; 484 | } 485 | if (topics.length > 1) winston.info('Incoming topic maps to multiple subscriptions: %s = %s', incoming, topics); 486 | // Topic is subscribe to 487 | if ((!!topics) && (!!subscribe)){ 488 | topics.forEach(function(topic) { 489 | Object.keys(subscribe[topic]).forEach(function(name) { 490 | // Checking if external device for this topic 491 | if (!!devices[name]){ 492 | Object.keys(subscribe[topic][name]).forEach(function(attribute) { 493 | device = subscribe[topic][name][attribute]['device']; 494 | property = subscribe[topic][name][attribute]['attribute']; 495 | value = contents; 496 | if ((!!subscribe[topic][name][attribute]['command']) && (!!subscribe[topic][name][attribute]['command'][contents])) 497 | value = subscribe[topic][name][attribute]['command'][contents]; 498 | cmd = true; 499 | postRequest(topic, contents, device, property, value, cmd, incoming); 500 | }); 501 | } else { 502 | // Remove the preface from the topic before splitting it 503 | var pieces = topic.substr(config.mqtt.preface.length + 1).split('/'); 504 | device = pieces[0]; 505 | property = pieces[1]; 506 | value = contents; 507 | var topicReadState = getTopicFor(device, property, TOPIC_READ_STATE), 508 | topicWriteState = getTopicFor(device, property, TOPIC_WRITE_STATE), 509 | topicSwitchState = getTopicFor(device, 'switch', TOPIC_READ_STATE), 510 | topicLevelCommand = getTopicFor(device, 'level', TOPIC_COMMAND), 511 | cmd = (!pieces[2] || pieces[2] && pieces[2] === config.mqtt[SUFFIX_COMMAND]); 512 | // Deduplicate only if the incoming message topic is the same as the read state topic 513 | if (topic === topicReadState) { 514 | if (history[topic] === contents) { 515 | winston.info('Skipping duplicate message from: %s = %s', topic, contents); 516 | return; 517 | } 518 | } 519 | // If sending level data and the switch is off, don't send anything 520 | // SmartThings will turn the device on (which is confusing) 521 | if (property === 'level' && history[topicSwitchState] === 'off') { 522 | winston.info('Skipping level set due to device being off'); 523 | return; 524 | } 525 | 526 | // If sending switch data and there is already a nonzero level value, send level instead 527 | // SmartThings will turn the device on 528 | if (property === 'switch' && contents === 'on' && 529 | history[topicLevelCommand] > 0) { 530 | winston.info('Passing level instead of switch on'); 531 | property = 'level'; 532 | contents = history[topicLevelCommand]; 533 | } 534 | postRequest(topic, contents, device, property, value, cmd, incoming); 535 | } 536 | }); 537 | }); 538 | } 539 | } 540 | 541 | 542 | function postRequest(topic, contents, device, property, value, cmd, incoming){ 543 | 544 | // If we subscribe to topic we are publishing we get into a loop 545 | if ((!!publications) && (!!publications[incoming]) && (!!publications[incoming][device]) && (!!publications[incoming][device][property])){ 546 | winston.error('Incoming %s for attribute %s for device %s is also being published to: [%s][%s][%s].\nIgnoring: %s = %s', incoming, property, device, device, property, incoming, incoming, contents); 547 | return; 548 | } 549 | history[incoming] = contents; 550 | var msg = (contents.length > 25) ? contents.substr(0,25) + "..." : contents 551 | if (!topic.match(/[*+]/)) { 552 | winston.info('MQTT --> ST - Topic: [%s][%s]\t[%s][%s][%s]', topic, msg , device, property, value) 553 | } else { 554 | winston.info('MQTT --> MQTT[WildCard] - Topic: [%s][%s]\t[%s][%s]', incoming, msg, topic, msg); 555 | winston.info('MQTT[WildCard] --> ST - Topic: [%s][%s]\t[%s][%s][%s]', topic, msg , device, property, value) 556 | } 557 | request.post({ 558 | url: 'http://' + callback, 559 | json: { 560 | name: device, 561 | type: property, 562 | value: value, 563 | command: cmd 564 | } 565 | }, function (error, resp) { 566 | if (error) { 567 | // @TODO handle the response from SmartThings 568 | winston.error('Error from SmartThings Hub: %s', error.toString()); 569 | winston.error(JSON.stringify(error, null, 4)); 570 | winston.error(JSON.stringify(resp, null, 4)); 571 | } 572 | }); 573 | } 574 | 575 | // Main flow 576 | async.series([ 577 | function loadFromDisk (next) { 578 | var state; 579 | 580 | winston.info('Starting SmartThings MQTT Bridge - v%s', CURRENT_VERSION); 581 | winston.info('Configuration Directory - %s', CONFIG_DIR); 582 | winston.info('Loading configuration'); 583 | configureDefaults(); 584 | winston.info('Loading previous state'); 585 | state = loadSavedState(); 586 | callback = state.callback; 587 | subscriptions = state.subscriptions; 588 | publications = state.publications; 589 | st_request = state.st_request; 590 | history = state.history; 591 | if (!!st_request) { 592 | winston.info ('request object - %s', st_request); 593 | processSubscriptions(st_request); 594 | } 595 | saveState(); 596 | process.nextTick(next); 597 | }, 598 | function connectToMQTT (next) { 599 | // Default protocol 600 | if (!url.parse(config.mqtt.host).protocol) { 601 | config.mqtt.host = 'mqtt://' + config.mqtt.host; 602 | } 603 | winston.info('Connecting to MQTT at %s', config.mqtt.host); 604 | client = mqtt.connect(config.mqtt.host, config.mqtt); 605 | client.on('message', parseMQTTMessage); 606 | client.on('connect', function () { 607 | if (subscriptions.length > 0) { 608 | client.subscribe(subscriptions); 609 | winston.info('Subscribing to - %s', subscriptions); 610 | } 611 | next(); 612 | // @TODO Not call this twice if we get disconnected 613 | next = function () {}; 614 | }); 615 | }, 616 | function configureCron (next) { 617 | winston.info('Configuring autosave'); 618 | 619 | // Save current state every 15 minutes 620 | setInterval(saveState, 15 * 60 * 1000); 621 | 622 | process.nextTick(next); 623 | }, 624 | function setupApp (next) { 625 | winston.info('Configuring API'); 626 | 627 | // Accept JSON 628 | app.use(bodyparser.json()); 629 | 630 | // Log all requests to disk 631 | app.use(expressWinston.logger(createLogger({ 632 | format: appFormat, 633 | transports : [accessLog] 634 | }))); 635 | 636 | // Push event from SmartThings 637 | app.post('/push', 638 | expressJoi({ 639 | body: { 640 | // "name": "Energy Meter", 641 | name: joi.string().required(), 642 | // "value": "873", 643 | value: joi.string().required(), 644 | // "type": "power", 645 | type: joi.string().required() 646 | } 647 | }), handlePushEvent); 648 | 649 | // Subscribe event from SmartThings 650 | app.post('/subscribe', 651 | expressJoi({ 652 | body: { 653 | devices: joi.object().required(), 654 | callback: joi.string().required() 655 | } 656 | }), handleSubscribeEvent); 657 | 658 | // Log all errors to disk 659 | app.use(expressWinston.errorLogger(createLogger({ 660 | format: appFormat, 661 | transports : [errorLog] 662 | }))); 663 | 664 | // Proper error messages with Joi 665 | app.use(function (err, req, res, next) { 666 | if (err.isBoom) { 667 | return res.status(err.output.statusCode).json(err.output.payload); 668 | } 669 | }); 670 | 671 | app.listen(config.port, next); 672 | } 673 | ], function (error) { 674 | if (error) { 675 | return winston.error(error); 676 | } 677 | winston.info('Listening at http://localhost:%s', config.port); 678 | }); 679 | -------------------------------------------------------------------------------- /test/mbs-server.test.js: -------------------------------------------------------------------------------- 1 | /*global describe, it */ 2 | var server; 3 | 4 | describe('MQTT Bridge Test Case', function () { 5 | describe('mbs-server', function () { 6 | it('start the service', function (done) { 7 | server = require('../mbs-server'); 8 | done(); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /windows service/Mqtt-Bridge-Smartthings-Service.txt: -------------------------------------------------------------------------------- 1 | SC CREATE mqtt-bridge-smartthings Displayname= "mqtt-bridge-smartthings" binpath= "srvstart.exe mqtt-bridge-smartthings -c Z:mqtt-bridge-smartthings.ini" start= auto 2 | SC DELETE mqtt-bridge-smartthings 3 | sc CONFIG mqtt-bridge-smartthings depend= mosquitto -------------------------------------------------------------------------------- /windows service/Mqtt-Bridge-Smartthings.ini: -------------------------------------------------------------------------------- 1 | [mqtt-bridge-smartthings] 2 | startup="C:\Program Files\nodejs\mqtt-bridge-smartthings.cmd" 3 | shutdown_method=winmessage -------------------------------------------------------------------------------- /windows service/README.txt: -------------------------------------------------------------------------------- 1 | To Run as a Windows Service 2 | 3 | 1. Edit the sample .ini file for your specific paths 4 | 2. Save it in the root of the desired drive - e.g. C:, D: 5 | 3. Make sure srvstart.exe is in the PATH variable (https://github.com/rozanski/srvstart) 6 | 3. Edit commands in the sample text file to CREATE, CONFIG or DELETE the service 7 | --------------------------------------------------------------------------------