Making an Internet Controlled Lamp
With devices such as Philips Hue becoming more common in our homes, I thought it would be interesting to bulid my own. The Hue system relies on a network bridge to interface between the Hue network, and the home network. In my opinion this is an unnecessary additional cost, especially with devices such as the ESP8266 readily available. My lamp will use a set of WS2812 LEDs and an ESP8266 connected via WiFi to Firebase, with a web interface to control the color of the LEDs.
The Interface
Firebase is Google’s ‘Back-end as a Service’ (BaaS) product, and provides web hosting, database and authentiation APIs amongst other things. What’s more, Firebase is free to use at the level I require.
Our user interface will be a web app hosted on Firebase, which uses the realtime-database to store RGB colour values for the ESP8266 to read. I chose David Durman’s Flexi Color Picker as the colour picker interface. Coupled with Firebase’s database and auth modules, my app looks like this:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Luminous</title>
<!-- Paste Firebase initialization here -->
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<script type="text/javascript" src="scripts/colorpicker.min.js"></script>
<style type="text/css">
.content { border-radius: 5px; background-color: white; padding: 10px; width: 230px; height: 200px; box-shadow: 1px 2px 4px rgba(0, 0, 0, .5);
position: absolute; margin: auto; top: 0; right: 0; bottom: 0; left: 0; }
#login { text-align: center; vertical-align: middle; line-height: 200px; cursor: pointer }
#picker { width: 200px; height: 200px; float: left }
#slide { width: 30px; height: 200px; float: left }
.material-icons { font-size: 150px; line-height: inherit}
</style>
</head>
<body>
<div id="login" class="content"><i class="material-icons">lock_outline</i></div>
<div id="wrapper" class="content" hidden>
<div id="picker"></div>
<div id="slide"></div>
</div>
<script type="text/javascript">
window.onload = function() {
document.getElementById("login").onclick = function() {
var provider = new firebase.auth.GoogleAuthProvider();
firebase.auth().signInWithPopup(provider);
}
firebase.auth().onAuthStateChanged(function(user) {
if (user) {
var userId = firebase.auth().currentUser.uid;
firebase.database().ref('/users/' + userId).once('value').then(function(snapshot) {
var email = snapshot.val();
console.log(email);
// Could boot unauthorised users out at this point?
});
// User is signed in.
console.log(user);
document.getElementById("wrapper").removeAttribute('hidden');
document.getElementById("login").setAttribute('hidden', 'true');
}
});
ColorPicker(
document.getElementById('slide'),
document.getElementById('picker'),
function(hex, hsv, rgb) {
document.body.style.backgroundColor = hex;
console.log(rgb);
setColor(rgb);
});
setColor = function(rgb) {
var ref = firebase.database().ref("test");
ref.set(rgb);
}
};
</script>
</body>
</html>
The Hardware
Now that the interface is in place, let’s look at the hardware. The ESP8266 will run NodeMCU, and connect to the Firebase app to read back the RGB data. The ESP will then send the colour to the WS2812 LEDs.
First, we must connect up the ESP to the LEDs. The NodeMCU WS2812 library uses GPIO2 as the communication pin, and the LEDs are run from 5V. The 3.3V logic of the ESP is adequate to drive the first LED, which buffers logic for the remaining LEDs.
Next, we need to build the NodeMCU firmware for the ESP. For this, I use nodemcu-build.com. The correct modules must be selected to allow us to communicate with both Firebase and the LEDs. As well as the defaults, I included:
- HTTP
- mDNS
- websocket
- WS2812
- TLS/SSL
Once the firmware is downloaded, it can be flashed using esptool.py. The command looks something like this:
esptool.py --port /dev/tty.wchusbserialfa140 write_flash -fm qio -fs 32m 0x00000 nodemcu-master-12-modules-2017-04-12-16-18-36-integer.bin --verify
NodeMCU is programmed with a series of Lua files. When the ESP first starts, it executes ‘init.lua’. If there are any bugs in this file, then the ESP could become stuck in a boot loop.
This can be mittigated by adding a wait into the program, to allow someone to intervene with the boot. The startup
function runs the main program, and is only executed after the WiFi has connected, and the setup is complete.
If a problem in my main.lua
causes a boot loop, I can prevent it by deleting init.lua
by sending file.remove("init.lua")
over the serial port.
Additionally, I start up a telnet server to allow me to remotely update the device.
My init.lua
looks like this:
-- Load credentials from credentials.lua
-- SSID and PASSWORD should be saved according wireless router in use
dofile("credentials.lua")
-- Run this once setup is complete
function startup()
if file.open("init.lua") == nil then
print("init.lua deleted")
else
print("Running")
file.close("init.lua")
dofile("main.lua")
end
end
-- Connect the WiFi
wifi.sta.disconnect()
print("set up wifi mode")
wifi.setmode(wifi.STATION)
wifi.sta.config(SSID,PASSWORD,0)
wifi.sta.connect()
NODENAME = "node-"..string.format("%x", node.chipid())
tmr.alarm(1, 1000, tmr.ALARM_AUTO, function()
if wifi.sta.getip()== nil then
print("IP unavaiable, Waiting...")
else
tmr.stop(1)
print("Config done, IP is "..wifi.sta.getip())
-- Set up mDNS so we can find the ESP on the network
print("Starting mDNS at "..NODENAME..".local")
mdns.register(NODENAME..".local")
-- Start a telnet server to allow us to remotely configure
dofile('telnet_srv.lua')
setupTelnetServer()
-- Pause to allow us to recover from a broken program
print("You have 5 seconds to abort Startup")
print("Waiting...")
tmr.alarm(0, 5000, tmr.ALARM_SINGLE, startup)
end
end)
Sending init.lua
and the other files over to the ESP is done using lutool.py. For example, to copy init'lua
over use the command luatool.py -p /dev/tty.wchusbserialfa140 -b 115200 -f init.lua
.
Specifying the -t
switch allows you to copy the file under a different name. Using my init.lua
requires a couple of other files to be sent over, namely credentials.lua
, telnet_srv.lua
and main.lua
.
Initially main.lua
contains a simple print("Hello World!")
but will be replaced with the full program later.
As Lua is an interpreted language, we are now able to experiment with the WS2812 module. Connecting over serial (or telnet) gets us the Lua prompt.
To set up the WS2812s, use ws2812.init()
. Writing to the LEDs is as simple as calling ws2812.write(string.char(G, R, B)
where G
, R
, and B
are the RGB values we want to send to the LED.
To control more LEDs, extend the string with more values.
Bringing it Together
Now all that remains is to get NodeMCU to read the Firebase realtime-database and set the LEDs accordingly. By default, Firebase sets our database to be only accessible by an authenticated user. For the web app, this isn’t a problem, but for the ESP, it’s a lot of work to authenticate the session. To side-step authentication, I reconfigure the database rules to allow unauthenticated read access. My database rules file looks like this:
{
"rules": {
".read": true,
".write": "auth != null"
}
}
Notice that write access is still governed by the authentication engine - later I can grant only specific users write access to my lamp.
Next, I need to set up the connection to the database. The Firebase REST API describes how to read data. Using the NodeMCU TLS module, I set up the connection as folows:
host = "myprojectid.firebaseio.com"
uri = "/test.json"
srv = tls.createConnection()
srv:on("receive", function(sck, c)
print(c)
end)
srv:connect(443, host)
srv:send("GET "..uri.." HTTP/1.1\r\nHost: "..host.."\r\nConnection: keep-alive\r\nAccept: */*\r\n\r\n")
Sending these lines to my ESP one-by-one gives each command enough time to execute. In the final program I’ll need to use callback functions to ensure each chunk of code is executed in the correct order.
The response I get from firebase consists of a HTTP header, and the content I’ve requested - which looks like this:
HTTP/1.1 200 OK
Server: nginx
Date: Thu, 13 Apr 2017 06:42:18 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 20
Connection: keep-alive
Access-Control-Allow-Origin: *
Cache-Control: no-cache
Strict-Transport-Security: max-age=31556926; includeSubDomains; preload
{"b":12,"g":2,"r":1}
The useful parts of this are the connection status and the content. I’ll use the connection status to know if the server has closed my session.
To extract the data from the HTTP response, Lua’s built in string
library comes into play. Lua uses regex patterns to extract data from the response.
string.gmatch(string, pattern)
allows us to iterate over each occurence of our pattern in the string. First, let’s extract the payload from the response, and then retreive each colour/value pair:
for d in string.gmatch(c, "{(.+)}") do
print("Extracted "..d)
for k, v in string.gmatch(d, '"(%w+)":(%w+)') do
if k == "r" then r = v end
if k == "g" then g = v end
if k == "b" then b = v end
end
end
We can also get the connection status, like so:
for con in string.gmatch(c, "\r\nConnection: (%w+)\r\n") do
if con == "close" then
print("Connection closed by host")
end
end
Lua’s tmr
library allows code to be exectued periodically. I set up two timers, one to read the colour and another to connect to the server, each every half second.
The timers are running in ALARM_SEMI
mode, which means they will execute once, and require resetting. This prevents the timer from repeating before the HTTP response has been processed. Calling tmr.start
sets the timer going again.
The final code looks as follows:
host = "myprojectid.firebaseio.com"
uri = "/test.json"
r = 0
g = 0
b = 0
ws2812.init()
ws2812.write(string.char(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
srv = tls.createConnection()
srv:on("receive", function(sck, c)
--print(c)
for d in string.gmatch(c, "{(.+)}") do
print("Extracted "..d)
for k, v in string.gmatch(d, '"(%w+)":(%w+)') do
if k == "r" then r = v end
if k == "g" then g = v end
if k == "b" then b = v end
end
end
ws2812.write(string.char(g, r, b, g, r, b, g, r, b, g, r, b, g, r, b, g, r, b, g, r, b, g, r, b, g, r, b, g, r, b, g, r, b, g, r, b))
tmr.start(0)
for con in string.gmatch(c, "\r\nConnection: (%w+)\r\n") do
if con == "close" then
print("Connection closed by host")
srv:close()
tmr.start(1)
end
end
end)
function get_colour()
srv:send("GET "..uri.." HTTP/1.1\r\nHost: "..host.."\r\nConnection: keep-alive\r\nAccept: */*\r\n\r\n")
end
srv:on("connection", function(sck, c)
-- Wait for connection before sending.
print("Connected to firebase")
tmr.alarm(0, 500, tmr.ALARM_SEMI, get_colour)
end)
tmr.alarm(1, 500, tmr.ALARM_SEMI, function() srv:connect(443, host) end)
The majority of the work is done by the get_colour
function which sends the HTTP request to Firebase, and srv:on("receive", ...
which processes the result.