Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixing #9, #10, and #11 #12

Merged
merged 1 commit into from
Feb 13, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you get rid of the '/'?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

# 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