diff --git a/.gitignore b/.gitignore index 14c7c71..5f81419 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ node_modules *.log subscription.json config.yml + +artifacts diff --git a/.jscsrc b/.jscsrc index d56c187..b3ce948 100644 --- a/.jscsrc +++ b/.jscsrc @@ -9,5 +9,8 @@ "beforeOpeningCurlyBrace": true, "beforeOpeningRoundBrace": true }, - "maximumLineLength": 120 + "maximumLineLength": 120, + "excludeFiles": [ + "artifacts/**" + ] } diff --git a/README.md b/README.md index 1528d6b..6f2c798 100644 --- a/README.md +++ b/README.md @@ -17,33 +17,37 @@ This project was spawned by the desire to [control SmartThings from within Home Events about a device (power, level, switch) are sent to MQTT using the following format: ``` -/smartthings/{DEVICE_NAME}/${ATTRIBUTE} +{PREFACE}/{DEVICE_NAME}/${ATTRIBUTE} ``` +__PREFACE is defined as "smartthings" by default in your configuration__ For example, my Dimmer Z-Wave Lamp is called "Fireplace Lights" in SmartThings. The following topics are published: ``` # Brightness (0-99) -/smartthings/Fireplace Lights/level +smartthings/Fireplace Lights/level # Switch State (on|off) -/smartthings/Fireplace Lights/switch +smartthings/Fireplace Lights/switch ``` The Bridge also subscribes to changes in these topics, so that you can update the device via MQTT. ``` -$ mqtt pub -t '/smartthings/Fireplace Lights/switch' -m 'off' +$ mqtt pub -t 'smartthings/Fireplace Lights/switch' -m 'off' # Light goes off in SmartThings ``` # Configuration -The bridge has one yaml file for configuration. Currently we only have one item you can set: +The bridge has one yaml file for configuration. Currently we only have two items you can set: ``` --- mqtt: - host: 192.168.1.200 + # Specify your MQTT Broker's hostname or IP address here + host: mqtt + # Preface for the topics $PREFACE/$DEVICE_NAME/$PROPERTY + preface: smartthings ``` We'll be adding additional fields as this service progresses (port, username, password, etc). diff --git a/_config.yml b/_config.yml index e6d0882..c715893 100644 --- a/_config.yml +++ b/_config.yml @@ -2,3 +2,5 @@ mqtt: # Specify your MQTT Broker's hostname or IP address here host: mqtt + # Preface for the topics $PREFACE/$DEVICE_NAME/$PROPERTY + preface: smartthings diff --git a/package.json b/package.json index f0c3582..13c3644 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "MQTTBridge", - "version": "1.0.0", + "version": "1.0.2", "description": "Bridge between SmartThings and an MQTT broker", "main": "server.js", "scripts": { @@ -35,6 +35,7 @@ "jsonfile": "^2.2.3", "mqtt": "^1.7.0", "request": "^2.65.0", + "semver": "^5.1.0", "winston": "^2.0.0" }, "devDependencies": { diff --git a/server.js b/server.js index 8e09bf5..e574f1b 100644 --- a/server.js +++ b/server.js @@ -13,19 +13,28 @@ var winston = require('winston'), yaml = require('js-yaml'), jsonfile = require('jsonfile'), fs = require('fs'), + semver = require('semver'), request = require('request'); -var CONFIG_DIR = process.env.CONFIG_DIR || process.cwd(); +var CONFIG_DIR = process.env.CONFIG_DIR || process.cwd(), + CONFIG_FILE = path.join(CONFIG_DIR, 'config.yml'), + SAMPLE_FILE = path.join(__dirname, '_config.yml'), + STATE_FILE = path.join(CONFIG_DIR, 'state.json'), + EVENTS_LOG = path.join(CONFIG_DIR, 'events.log'), + ACCESS_LOG = path.join(CONFIG_DIR, 'access.log'), + ERROR_LOG = path.join(CONFIG_DIR, 'error.log'), + CURRENT_VERSION = require('./package').version; -var config = loadConfiguration(), - app = express(), +var app = express(), client, - subscription, + subscriptions = [], + callback = '', + config = {}, history = {}; // Write all events to disk as well winston.add(winston.transports.File, { - filename: path.join(CONFIG_DIR, 'events.log'), + filename: EVENTS_LOG, json: false }); @@ -35,14 +44,70 @@ winston.add(winston.transports.File, { * @return {Object} Configuration */ function loadConfiguration () { - var configFile = path.join(CONFIG_DIR, 'config.yml'), - sampleFile = path.join(__dirname, '_config.yml'); + if (!fs.existsSync(CONFIG_FILE)) { + winston.info('No previous configuration found, creating one'); + fs.writeFileSync(CONFIG_FILE, fs.readFileSync(SAMPLE_FILE)); + } + + return yaml.safeLoad(fs.readFileSync(CONFIG_FILE)); +} - if (!fs.existsSync(configFile)) { - fs.writeFileSync(configFile, fs.readFileSync(sampleFile)); +/** + * Load the saved previous state from disk + * @method loadSavedState + * @return {Object} Configuration + */ +function loadSavedState () { + var output; + try { + output = jsonfile.readFileSync(STATE_FILE); + } catch (ex) { + winston.info('No previous state found, continuing'); + output = { + subscriptions: [], + callback: '', + history: {}, + version: '0.0.0' + }; } + return output; +} - return yaml.safeLoad(fs.readFileSync(configFile)); +/** + * Resubscribe on a periodic basis + * @method saveState + */ +function saveState () { + winston.info('Saving current state'); + jsonfile.writeFileSync(STATE_FILE, { + subscriptions: subscriptions, + callback: callback, + history: history, + version: CURRENT_VERSION + }, { + spaces: 4 + }); +} + +/** + * Migrate the configuration from the current version to the latest version + * @method migrateState + * @param {String} version Version the state was written in before + */ +function migrateState (version) { + // This is the previous default, but it's totally wrong + if (config.mqtt && !config.mqtt.preface) { + config.mqtt.preface = '/smartthings'; + } + + // Stuff was previously in subscription.json, load that and migrate it + if (semver.lt(version, '1.1.0')) { + var oldState = jsonfile.readFileSync(path.join(CONFIG_DIR, 'subscription.json')); + callback = oldState.callback; + subscriptions = oldState.topics; + } + + saveState(); } /** @@ -57,7 +122,7 @@ function loadConfiguration () { * @param {Result} res Result Object */ function handlePushEvent (req, res) { - var topic = ['', 'smartthings', req.body.name, req.body.type].join('/'), + var topic = getTopicFor(req.body.name, req.body.type), value = req.body.value; winston.info('Incoming message from SmartThings: %s = %s', topic, value); @@ -83,31 +148,22 @@ function handlePushEvent (req, res) { * @param {Result} res Result Object */ function handleSubscribeEvent (req, res) { - subscription = { - topics: [], - callback: '' - }; - // Subscribe to all events - Object.keys(req.body.devices).forEach(function (type) { - req.body.devices[type].forEach(function (device) { - var topicName = ['', 'smartthings', device, type].join('/'); - subscription.topics.push(topicName); + subscriptions = []; + Object.keys(req.body.devices).forEach(function (property) { + req.body.devices[property].forEach(function (device) { + subscriptions.push(getTopicFor(device, property)); }); }); // Store callback - subscription.callback = req.body.callback; + callback = req.body.callback; - // Store config on disk - var subscriptionFile = path.join(CONFIG_DIR, 'subscription.json'); - // @TODO convert to async.series - jsonfile.writeFile(subscriptionFile, subscription, { - spaces: 4 - }, function () { + // Store current state on disk + saveState(function (next) { // Turtles - winston.info('Subscribing to ' + subscription.topics.join(', ')); - client.subscribe(subscription.topics, function () { + winston.info('Subscribing to ' + subscriptions.join(', ')); + client.subscribe(subscriptions, function () { // All the way down res.send({ status: 'OK' @@ -116,6 +172,17 @@ function handleSubscribeEvent (req, res) { }); } +/** + * Get the topic name for a given item + * @method getTopicFor + * @param {String} device Device Name + * @param {String} property Property + * @return {String} MQTT Topic name + */ +function getTopicFor (device, property) { + return [config.mqtt.preface, device, property].join('/'); +} + /** * Parse incoming message from MQTT * @method parseMQTTMessage @@ -131,21 +198,39 @@ function parseMQTTMessage (topic, message) { winston.info('Incoming message from MQTT: %s = %s', topic, contents); history[topic] = contents; - var pieces = topic.split('/'), - name = pieces[2], - type = pieces[3]; + // Remove the preface from the topic before splitting it + var pieces = topic.substr(config.mqtt.preface.length + 1).split('/'), + device = pieces[0], + property = pieces[1]; + + + // If sending level data and the switch is off, don't send anything + // SmartThings will turn the device on (which is confusing) + if (property === 'level' && history[getTopicFor(device, 'switch')] === 'off') { + winston.info('Skipping level set due to device being off'); + return; + } + + // If sending switch data and there is already a level value, send level instead + // SmartThings will turn the device on + if (property === 'switch' && contents === 'on' && history[getTopicFor(device, 'level')] !== undefined) { + winston.info('Passing level instead of switch on'); + property = 'level'; + contents = history[getTopicFor(device, 'level')]; + } request.post({ - url: 'http://' + subscription.callback, + url: 'http://' + callback, json: { - name: name, - type: type, + name: device, + type: property, value: contents } }, function (error, resp) { if (error) { // @TODO handle the response from SmartThings winston.error('Error from SmartThings Hub: %s', error.toString()); + winston.error(JSON.stringify(error, null, 4)); winston.error(JSON.stringify(resp, null, 4)); } }); @@ -153,29 +238,46 @@ function parseMQTTMessage (topic, message) { // Main flow async.series([ + function loadFromDisk (next) { + var state; + + winston.info('Loading configuration'); + config = loadConfiguration(); + + winston.info('Loading previous state'); + state = loadSavedState(); + callback = state.callback; + subscriptions = state.subscriptions; + history = state.history; + + winston.info('Perfoming configuration migration'); + migrateState(state.version); + + process.nextTick(next); + }, function connectToMQTT (next) { winston.info('Connecting to MQTT'); + client = mqtt.connect('mqtt://' + config.mqtt.host); + client.on('message', parseMQTTMessage); client.on('connect', function () { + client.subscribe(subscriptions); next(); // @TODO Not call this twice if we get disconnected next = function () {}; }); - client.on('message', parseMQTTMessage); }, - function loadSavedSubscriptions (next) { - winston.info('Loading Saved Subscriptions'); - jsonfile.readFile(path.join(CONFIG_DIR, 'subscription.json'), function (error, config) { - if (error) { - winston.warn('No stored subscription found'); - return next(); - } - subscription = config; - client.subscribe(subscription.topics, next); - }); + function configureCron (next) { + winston.info('Configuring autosave'); + + // Save current state every 15 minutes + setInterval(saveState, 15 * 60 * 1000); + + process.nextTick(next); }, function setupApp (next) { winston.info('Configuring API'); + // Accept JSON app.use(bodyparser.json()); @@ -183,7 +285,7 @@ async.series([ app.use(expressWinston.logger({ transports: [ new winston.transports.File({ - filename: path.join(CONFIG_DIR, 'access.log'), + filename: ACCESS_LOG, json: false }) ] @@ -215,7 +317,7 @@ async.series([ app.use(expressWinston.errorLogger({ transports: [ new winston.transports.File({ - filename: path.join(CONFIG_DIR, 'error.log'), + filename: ERROR_LOG, json: false }) ]