Skip to content

Commit

Permalink
Switching default preface to smartthings instead of /smartthings
Browse files Browse the repository at this point in the history
…but leaving backwards compatibility (fixes #10)

Adding saving of history to disk, to prevent startup spam (partial fix for #9)
Do not send level if current state is "off" (fixes #9)
Resubscribe to topics if connection with MQTT is lost (fixes #11)
  • Loading branch information
stjohnjohnson committed Feb 13, 2016
1 parent 60ba4ee commit c3b6392
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 56 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ node_modules
*.log
subscription.json
config.yml

artifacts
5 changes: 4 additions & 1 deletion .jscsrc
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@
"beforeOpeningCurlyBrace": true,
"beforeOpeningRoundBrace": true
},
"maximumLineLength": 120
"maximumLineLength": 120,
"excludeFiles": [
"artifacts/**"
]
}
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions _config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
198 changes: 150 additions & 48 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand All @@ -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();
}

/**
Expand All @@ -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);
Expand All @@ -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'
Expand All @@ -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
Expand All @@ -131,59 +198,94 @@ 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));
}
});
}

// 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());

// Log all requests to disk
app.use(expressWinston.logger({
transports: [
new winston.transports.File({
filename: path.join(CONFIG_DIR, 'access.log'),
filename: ACCESS_LOG,
json: false
})
]
Expand Down Expand Up @@ -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
})
]
Expand Down

0 comments on commit c3b6392

Please sign in to comment.