In our last MQTT blog post, we got a basic proof of concept MQTT broker up and running. Now let’s get it one step closer to being something useful: a home automation server.
I actually have the code this blog post is based on running on a Raspberry Pi in my own home, and I use it to control a pair of smart outlets: one to turn on the kettle for my coffee every morning, and one to control a lamp in my living room. This blog post will focus on how to use MQTT to make your coffee!
System Overview
By the end of this post, you will have an MQTT broker running on your machine. The MQTT broker will expose some topics to be accessed from MQTTBox or Mosquitto in the terminal, just like the last app.
Each topic will have a corresponding controller on the server to handle emitting MQTT events to the potential smart devices. Furthermore, the controllers will all extend an abstract controller, which defines the interface within which the devices can interact.
The server will act as the middleman, receiving MQTT messages on the topic control/*
and then publishing MQTT messages to the devices on different topics. The MQTT broker, running on our server, acts as the middleman, routing these messages appropriately.
Server Publishing
Currently, the app is just an MQTT broker. It routes messages from publishers to subscribers and logs them along the way. For the app to be able to control the outlets, you’ll need it to be able to publish messages itself for the outlets to receive. Fortunately, aedes
has one built in!
To publish the messages, you just call it like this:
aedes.publish({topic: 'some/topic', payload: JSON.stringify({some: 'payload'})});
Device Interface
Let’s start from the lowest level, and work our way out. That means first you’ll define how the server will interact with the smart devices. In your case, there is only one kind of device: a smart outlet, which can connect to your MQTT broker. The outlet has its own configurable topic that you need to be able to pass in. Then you’ll put another topic level, power
, on the end. Although an outlet only has a single control interface (its power state), if in the future devices with more options are added to the system, the flexibility will make it easier to write code for them.
module.exports = class OutletController {
constructor(aedes, topic) {
if (!topic) {
throw new Error('OutletController called without topic')
}
this.powerTopic = `${topic}/power`;
}
}
Your server will need to be able to publish MQTT messages on the passed-in topic, so you’ll use the principle of dependency injection and pass your app in via the constructor.
constructor(aedes, topic) {
if (!aedes) {
throw new Error('OutletController called without app')
}
if (!topic) {
throw new Error('OutletController called without topic')
}
this.aedes = aedes;
this.topic = `${topic}/power`;
}
Fortunately, outlets do not have particularly complex controls or states to contend with. An outlet is either on or off. As such, you need to be able to change the state of the outlet. To change the outlet’s state, implement the following code:
async setOn() {
const payload = JSON.stringify({operation: 'ON'})
await this.aedes.publish({topic: this.powerTopic, payload});
}
async setOff() {
const payload = JSON.stringify({operation: 'OFF'})
await this.aedes.publish({topic: this.powerTopic, payload});
}
Keeping up with whether the outlet is currently on or off could be troublesome, however. Conveniently, the outlets also expose a helper operation, TOGGLE
, that takes care of that for you, changing the state from on to off, or from off to on, depending on the current state.
async toggle() {
const payload = JSON.stringify({operation: 'TOGGLE'})
await this.aedes.publish({topic: this.powerTopic, payload});
}
Use Cases
With the interface defined, you can make controllers for your specific use cases. The two outlets will be used for a lamp and a kettle. The lamp is the simpler of the two, so let’s handle that one first.
Lamp
The bare minimum is being able to turn the lamp on and off. Which is exactly like what that toggle
helper is for!
Since the toggle is on the parent OutletController
, you don't need to write any extra code to be able to use it in the child LampController
. You’ll also use a Promise library that adds a function you’ll use later in this file.
const Promise = require('bluebird');
const OutletController = require('./OutletController');
module.exports = class LampController extends OutletController {
constructor(aedes) {
super(aedes, 'lamp');
}
}
However, let’s have a little fun and define some lamp-specific behavior. How about making it blink? Easy enough to do with the parent OutletController
methods, plus the bluebird
function Promise.delay
, which I find easier to read than the native JavaScript approach of wrapping setTimeout
in a Promise
.
async blink(delayMilliseconds = 1000) {
await this.toggle()
await Promise.delay(delayMilliseconds);
await this.toggle;
}
Kettle
Similar to the lamp, you could just expose the base functionality and call it a day, but you can make it a simpler, better user experience by making the code a little more complex.
The kettle should come on, boil the water, then shut itself off—no sense in staying on and potentially wasting phantom power. Or worse, if your kettle doesn’t have a physical power switch that cuts off once the water is boiled, then there would be a fire hazard.
Define a method to turn it on for a set period, then turn it back off. In my testing, three minutes is perfect.
const Promise = require('bluebird');
const OutletController = require('./OutletController');
module.exports = class KettleController extends OutletController {
constructor(aedes) {
super(aedes, 'kettle');
this.millisecondsToStayEngaged = 1000 * 60 * 3; // 3 minutes
}
async boilWater() {
await this.setOn();
await Promise.delay(this.millisecondsToStayEngaged);
await this.setOff();
}
}
Wiring It Up
You now have two controllers with custom behavior specific to their respective use cases, but they can’t currently be used. Let’s fix that.
First, you’ll get the app to have a specific function on the control
topic. You can put that in the listener for publishes. Minor terminology note: Your server (or app) is running the broker, and your server will also publish messages for the broker to route, but the broker is not subscribed to the topic. The broker routes messages from the publishers to the subscribers, and nothing more.
// ./app.js
const handleControlRequest = require('./server/handleControlRequest')(aedes);
aedes.on('publish', function({topic, payload}) {
try {
console.log("broker routing published message", topic, payload?.toString());
const [type, machine, operation] = topic.split('/');
if (type === 'control') {
handleControlRequest({machine, operation})
}
}
catch (err) {
aedes.publish({topic: 'error', payload: JSON.stringify(err.message)})
}
});
Now, each time the broker receives a message, it will check if the first topic level is control
, and if so, call a new handleControlRequest
function with the remaining two topic levels. If an error occurs, you publish it out on a topic called error
.
Notice that the require
for handleControlRequest
is being called like a function, with aedes
as the input. handleControlRequest
is being called like a function because you need to pass aedes
into your controllers so they can publish their own messages. This is a principle known as dependency injection.
// ./server/handleControlRequest.js
const getControllers = require('../controllers');
module.exports = function(aedes) {
const controllers = getControllers(aedes);
return function handleControlRequest ({machine, operation}) {
switch(machine) {
case 'lamp':
case 'kettle':
controllers[machine].handleOperation(operation);
break;
default:
throw new Error(`machine ${machine} not supported`);
}
}
}
Here you get your controllers and pass aedes
into them, then return a function handleControlRequest
that takes in the machine
and operation
string, and routes them to the correct machine. Here, if the machine is not supported, it throws an error, which will be published out on the error
channel by the catch added earlier.
Finally, define the handleOperation
method on our two controllers.
// ./controllers/LampController
handleOperation(operation) {
switch (operation) {
case 'on':
return this.setOn();
case 'off':
return this.setOff();
case 'toggle':
return this.toggle();
case 'blink':
return this.blink();
default:
throw new Error(`lamp cannot handle operation ${operation}`)
}
}
// ./controllers/KettleController
handleOperation(operation) {
switch (operation) {
case 'on':
case 'boil':
return this.boilWater();
case 'off':
return this.setOff();
default:
throw new Error(`kettle cannot handle operation ${operation}`)
}
}
The methods above simply map the operation that comes into the controller class method that goes with them.
Note that in the KettleController
's on
case, you fall through to the boil
case. You don’t want the power to the kettle to be left on, so this is just a safety feature that ensures it gets shut off by the boilWater
class method.
Conclusion
Now you have all the MQTT code you need to handle a basic smart outlet setup! It’s time to sit back, relax, and let MQTT make your coffee.
Is your project just a little bit more complicated than coffee?
Bitovi has expert backend consultants ready to dive in and assist you with your project! Schedule a free consultation to get started.
Previous Post