How to Add a Web UI to the RGB LCD Tweet Box



This tutorial extends the previous tutorial, How to build a Tweet controlled RGB LCD, by adding web page control. With this you can change the configuration of the Tweetbox on the fly from your laptop, tablet or phone.
You'll learn useful techniques in this tutorial so that you can add web page control to your own Raspberry Pi projects.
Setup
- First complete the previous tutorial How to build a Tweet controlled RGB LCD
- Install Tornado:
sudo pip install tornado
All the code for this project is available here:
https://github.com/jerbly/tutorials/tree/master/tweetbox
Stage 1: Basic Form
To recap, by the end of the previous tutorial you had a Raspberry Pi with RGB LCD connected to Twitter receiving public tweets matching a filter. The RGB backlight would change colour depending on other matching words in the tweet. To configure the filter and colour map you had to change the code and restart the program.
In this first stage I'll add a web server to the program. Then you can go to a web page and update the configuration live. I'll be using Tornado, there are lots of different web frameworks for Python and I've used many of them over the years but this is now my go-to framework. It ticks a lot of boxes for a lot of different projects.
Take a look at the Hello world example on the Tornado page so you can see how to set up a handler and start a tornado server thread. I'll be using this exact same principle here.
I'll take the tweetbox.py
code from the previous tutorial and add the web framework in. To start with I'll just have a simple form allowing us to change what I'm tracking on Twitter. First, add some imports to the top of the script:
1 |
import tornado.ioloop |
2 |
import tornado.web |
Next I need two handlers: one to show the HTML form and a second to receive the form submission:
1 |
class MainHandler(tornado.web.RequestHandler): |
2 |
def get(self): |
3 |
self.render("templates/form1.html") |
4 |
|
5 |
class ConfigHandler(tornado.web.RequestHandler): |
6 |
def post(self): |
7 |
config_track = self.get_argument("config_track") |
8 |
restart(config_track) |
9 |
self.write("Now tracking %s" % config_track) |
10 |
|
11 |
application = tornado.web.Application([ |
12 |
(r"/", MainHandler), |
13 |
(r"/config", ConfigHandler), |
14 |
])
|
Note that MainHandler
uses get
and ConfigHandler
uses post
, the HTML form uses the post method to submit the form data back to the web server. To serve the form the MainHandler
simply calls the render function to have a template rendered and sent to the browser. More about this in a moment.
When the form data comes back ConfigHandler
extracts one argument, config_track
. This is sent to a restart
function to change the settings with tweepy before returning a simple string showing what is being tracked.
Create the template by adding a directory called templates to the source directory and create the form1.html
file there:
1 |
<!DOCTYPE html>
|
2 |
<html lang="en"> |
3 |
<head>
|
4 |
<title>Raspberry Pi Tweetbox</title> |
5 |
</head>
|
6 |
|
7 |
<body>
|
8 |
<form id="config_form" action="config" method="post"> |
9 |
<input id="config_track" name="config_track" type="text"> |
10 |
<button type="submit">Submit</button> |
11 |
</form>
|
12 |
</body>
|
13 |
</html>
|
This is a very simple form with a text input box and a submit button. It’s all that’s needed at this stage. Next create the restart
function:
1 |
def restart(track_text): |
2 |
stream.disconnect() |
3 |
time.sleep(5) #Allow time for the thread to disconnect... |
4 |
stream.filter(track=[track_text], async=True) |
This disconnects the Twitter stream and then reconnects it with the new filter, track_text
, which is whatever was submitted from the form. An important change here, from the previous tutorial, is that the Twitter stream thread runs in asynchronous mode, async=True
. This runs the stream connection in a background thread so that the web server runs as the main thread.
Add a couple of lines to the end to start the stream in asynchronous mode and to then start the web server:
1 |
stream.filter(track=['jeremy'], async=True) |
2 |
application.listen(8888) |
3 |
tornado.ioloop.IOLoop.instance().start() |
This starts the web server listening on port 8888. Point a web browser at http://{your-raspi-ipaddress}:8888/ and you will see the form:



Enter something else to track like raspberry and click submit. After five seconds it will switch to tracking these tweets instead.
Stage 2: Colour Map and Saving Configuration
In this stage I'll add the colour map to the configuration so you can set the words that trigger the RGB backlight to change. Since there are seven settings I don't really want to re-enter these every time I run the application so I'll save these to a file.
The program uses pickle to save and load the configuration file, plus there's an initial file exists check so add these imports to the top:
1 |
import pickle |
2 |
from genericpath import exists |
The DisplayLoop
class is already responsible for managing the backlight_map
so I'll extend this so it takes care of what I'm currently tracking, track_text
. Read and Write config methods are also added here:
1 |
class DisplayLoop(StreamListener): |
2 |
PICKLE_FILE = '/home/pi/py/tweetbox.pkl' |
3 |
|
4 |
def __init__(self): |
5 |
self.lcd = Adafruit_CharLCDPlate() |
6 |
self.lcd.backlight(self.lcd.RED) |
7 |
self.lcd.clear() |
8 |
self.track_text = 'jeremy' |
9 |
self.backlight_map = {'red':self.lcd.RED, |
10 |
'green':self.lcd.GREEN, |
11 |
'blue':self.lcd.BLUE, |
12 |
'yellow':self.lcd.YELLOW, |
13 |
'teal':self.lcd.TEAL, |
14 |
'violet':self.lcd.VIOLET} |
15 |
self.msglist = [] |
16 |
self.pos = 0 |
17 |
self.tweet = 'Nothing yet' |
18 |
|
19 |
def write_config(self): |
20 |
data = {"track_text":self.track_text, |
21 |
"backlight_map":self.backlight_map} |
22 |
output = open(self.PICKLE_FILE, 'wb') |
23 |
pickle.dump(data, output) |
24 |
output.close() |
25 |
|
26 |
def read_config(self): |
27 |
if exists(self.PICKLE_FILE): |
28 |
pkl_file = open(self.PICKLE_FILE, 'rb') |
29 |
data = pickle.load(pkl_file) |
30 |
pkl_file.close() |
31 |
self.track_text = data["track_text"] |
32 |
self.backlight_map = data["backlight_map"] |
33 |
Change the request handlers to take care of the colours. Also below you'll see that when I render the main page I'm passing in the current configuration. This means I can fill the configuration form with the current settings when it's requested.
1 |
class MainHandler(tornado.web.RequestHandler): |
2 |
def get(self): |
3 |
inverted_map = {v:k for k, v in display_loop_instance.backlight_map.items()} |
4 |
self.render("templates/form3.html", |
5 |
config_track=display_loop_instance.track_text, |
6 |
config_red=inverted_map[Adafruit_CharLCDPlate.RED], |
7 |
config_green=inverted_map[Adafruit_CharLCDPlate.GREEN], |
8 |
config_blue=inverted_map[Adafruit_CharLCDPlate.BLUE], |
9 |
config_yellow=inverted_map[Adafruit_CharLCDPlate.YELLOW], |
10 |
config_teal=inverted_map[Adafruit_CharLCDPlate.TEAL], |
11 |
config_violet=inverted_map[Adafruit_CharLCDPlate.VIOLET]) |
12 |
|
13 |
class ConfigHandler(tornado.web.RequestHandler): |
14 |
def post(self): |
15 |
config_track = self.get_argument("config_track") |
16 |
colour_map = {self.get_argument("config_red"):Adafruit_CharLCDPlate.RED, |
17 |
self.get_argument("config_green"):Adafruit_CharLCDPlate.GREEN, |
18 |
self.get_argument("config_blue"):Adafruit_CharLCDPlate.BLUE, |
19 |
self.get_argument("config_yellow"):Adafruit_CharLCDPlate.YELLOW, |
20 |
self.get_argument("config_teal"):Adafruit_CharLCDPlate.TEAL, |
21 |
self.get_argument("config_violet"):Adafruit_CharLCDPlate.VIOLET |
22 |
}
|
23 |
set_config(config_track, colour_map) |
24 |
self.write("Now tracking %s" % config_track) |
One technique to note here is how I invert the colour map. When processing it I want the map to be word > colour but when configuring it in the form I want colour > word. A Python dictionary comprehension can be used to invert the map in a single statement: {v:k for k, v in display_loop_instance.backlight_map.items()}
A new HTML form is required to support the colour settings. Also I need to populate the form input boxes with the current settings by making use of Tornado's template system. This is very basic, I'm just taking the values passed in to the render
function and pulling them out here in the template for example {{ config_track }}
.
1 |
<!DOCTYPE html>
|
2 |
<html lang="en"> |
3 |
<head>
|
4 |
<title>Raspberry Pi Tweetbox</title> |
5 |
</head>
|
6 |
|
7 |
<body>
|
8 |
<form id="config_form" action="config" method="post"> |
9 |
<label for="config_track">Track:</label> |
10 |
<input id="config_track" name="config_track" type="text" value="{{ config_track }}"> |
11 |
<br/>
|
12 |
<label for="config_red">Red:</label> |
13 |
<input id="config_red" name="config_red" type="text" value="{{ config_red }}"> |
14 |
<br/>
|
15 |
<label for="config_green">Green:</label> |
16 |
<input id="config_green" name="config_green" type="text" value="{{ config_green }}"> |
17 |
<br/>
|
18 |
<label for="config_blue">Blue:</label> |
19 |
<input id="config_blue" name="config_blue" type="text" value="{{ config_blue }}"> |
20 |
<br/>
|
21 |
<label for="config_yellow">Yellow:</label> |
22 |
<input id="config_yellow" name="config_yellow" type="text" value="{{ config_yellow }}"> |
23 |
<br/>
|
24 |
<label for="config_teal">Teal:</label> |
25 |
<input id="config_teal" name="config_teal" type="text" value="{{ config_teal }}"> |
26 |
<br/>
|
27 |
<label for="config_violet">Violet:</label> |
28 |
<input id="config_violet" name="config_violet" type="text" value="{{ config_violet }}"> |
29 |
<br/>
|
30 |
<button type="submit">Submit</button> |
31 |
</form>
|
32 |
</body>
|
33 |
</html>
|
Now that I can save and load the configuration, the former restart
routine needs to be a little more sophisticated:
1 |
def set_config(track_text, colour_map): |
2 |
display_loop_instance.set_text("Updating configuration") |
3 |
stream.disconnect() |
4 |
display_loop_instance.track_text = track_text |
5 |
display_loop_instance.backlight_map = colour_map |
6 |
display_loop_instance.write_config() |
7 |
time.sleep(5) #Allow time for the thread to disconnect... |
8 |
stream.filter(track=[display_loop_instance.track_text], async=True) |
9 |
display_loop_instance.set_text("Updated configuration") |
10 |
Since it's more than a restart it's name is now set_config
. It's here where I now call write_config
to save the changes to file.
All that's left is a couple of changes to read in the configuration at startup:
1 |
display_loop_instance = DisplayLoop() |
2 |
display_loop_instance.read_config() |
And to start the stream from this setting rather than 'jeremy'
:
1 |
stream = Stream(auth, display_loop_instance) |
2 |
stream.filter(track=[display_loop_instance.track_text], async=True) |
Start the program, point a web browser at http://{your-raspi-ipaddress}:8888/ and you will see the form:



Stage 3: Finishing Touches
There are a few things that aren't very slick about this program:
- Delay while updating the configuration
- The Now tracking... response after submitting the form
- Ugly form styling
The delay while the configuration is changing is due to the asynchronous nature of the program. There is a thread managing the Twitter stream, a thread scrolling the display and the main thread running the web server.
When I want to change the settings on the stream I need to disconnect it and then reconnect with the new options. Unfortunately there's no event from tweepy to tell me when I have successfully disconnected and so up until now I have just delayed for five seconds between disconnect and reconnect.
To remove this delay what I'll do is start a new connection while the old one is disconnecting so I don't have to wait. Of course this means that at one point there may be two streams receiving tweets. This would be confusing on the display since you would see the old tracking and the new tracking combined.
Therefore, just prior to disconnecting I'll connect the old stream to a listener which does nothing with the incoming tweets. Here is the definition of the NullListener
and the changes to the set_config
routine:
1 |
class NullListener(StreamListener): |
2 |
def on_data(self, data): |
3 |
pass
|
4 |
|
5 |
def set_config(track_text, colour_map): |
6 |
print "restarting" |
7 |
display_loop_instance.set_text("Updating configuration") |
8 |
#Kill the old stream asynchronously
|
9 |
global stream |
10 |
stream.listener = NullListener() |
11 |
stream.disconnect() |
12 |
|
13 |
display_loop_instance.track_text = track_text |
14 |
display_loop_instance.backlight_map = colour_map |
15 |
display_loop_instance.write_config() |
16 |
|
17 |
#Make a new stream
|
18 |
stream = Stream(auth, display_loop_instance) |
19 |
stream.filter(track=[display_loop_instance.track_text], async=True) |
20 |
display_loop_instance.set_text("Updated configuration") |
Regarding the Now tracking… response, the current version of the form is submitted to the ConfigHandler
which changes the settings and returns this ugly response. Really what I want is for the form to re-appear with the new settings in place.
I can achieve this by redirecting the user back to the /
URL. Also, there's really no need for the ConfigHandler
anyway, I can define the get
and post
methods on the MainHandler
and simply submit the form there instead:
1 |
class MainHandler(tornado.web.RequestHandler): |
2 |
def get(self): |
3 |
inverted_map = {v:k for k, v in display_loop_instance.backlight_map.items()} |
4 |
self.render("templates/form4.html", |
5 |
config_track=display_loop_instance.track_text, |
6 |
config_red=inverted_map[Adafruit_CharLCDPlate.RED], |
7 |
config_green=inverted_map[Adafruit_CharLCDPlate.GREEN], |
8 |
config_blue=inverted_map[Adafruit_CharLCDPlate.BLUE], |
9 |
config_yellow=inverted_map[Adafruit_CharLCDPlate.YELLOW], |
10 |
config_teal=inverted_map[Adafruit_CharLCDPlate.TEAL], |
11 |
config_violet=inverted_map[Adafruit_CharLCDPlate.VIOLET]) |
12 |
|
13 |
def post(self): |
14 |
config_track = self.get_argument("config_track") |
15 |
colour_map = {self.get_argument("config_red"):Adafruit_CharLCDPlate.RED, |
16 |
self.get_argument("config_green"):Adafruit_CharLCDPlate.GREEN, |
17 |
self.get_argument("config_blue"):Adafruit_CharLCDPlate.BLUE, |
18 |
self.get_argument("config_yellow"):Adafruit_CharLCDPlate.YELLOW, |
19 |
self.get_argument("config_teal"):Adafruit_CharLCDPlate.TEAL, |
20 |
self.get_argument("config_violet"):Adafruit_CharLCDPlate.VIOLET |
21 |
}
|
22 |
set_config(config_track, colour_map) |
23 |
#Use a redirect to avoid problems with refreshes in the browser from a form post
|
24 |
self.redirect("/") |
25 |
|
26 |
application = tornado.web.Application([ |
27 |
(r"/", MainHandler), |
28 |
])
|
Finally, styling. Making this look really beautiful could be a whole new tutorial in itself, but a really good start is to introduce a framework to take care of a lot of the styling for you.
I’m Bootstrap for styling and JQuery for scripting. Both of these are available on CDNs so you don't need to download anything, just include them in the head section of the page:
1 |
<head>
|
2 |
<title>Raspberry Pi Tweetbox</title> |
3 |
|
4 |
<script src="//code.jquery.com/jquery-1.11.0.min.js"></script> |
5 |
<script src="//code.jquery.com/jquery-migrate-1.2.1.min.js"></script> |
6 |
|
7 |
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css"> |
8 |
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap-theme.min.css"> |
9 |
<script src="//netdna.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script> |
10 |
|
11 |
<meta name="viewport" content="width=device-width, initial-scale=1"> |
12 |
</head>
|
To make the form look better we'll use Bootstrap's Horizontal Form style:
1 |
<form id="config_form" action="/" method="post" class="form-horizontal" role="form"> |
2 |
<div class="form-group"> |
3 |
<label for="config_track" class="col-sm-1 control-label">Track:</label> |
4 |
<div class="col-sm-2"> |
5 |
<input id="config_track" name="config_track" type="text" value="{{ config_track }}" class="form-control"> |
6 |
</div>
|
7 |
</div>
|
8 |
<div class="form-group"> |
9 |
<label for="config_red" class="col-sm-1 control-label">Red:</label> |
10 |
<div class="col-sm-2"> |
11 |
<input id="config_red" name="config_red" type="text" value="{{ config_red }}" class="form-control"> |
12 |
</div>
|
13 |
</div>
|
14 |
<div class="form-group"> |
15 |
<label for="config_green" class="col-sm-1 control-label">Green:</label> |
16 |
<div class="col-sm-2"> |
17 |
<input id="config_green" name="config_green" type="text" value="{{ config_green }}" class="form-control"> |
18 |
</div>
|
19 |
</div>
|
20 |
<div class="form-group"> |
21 |
<label for="config_blue" class="col-sm-1 control-label">Blue:</label> |
22 |
<div class="col-sm-2"> |
23 |
<input id="config_blue" name="config_blue" type="text" value="{{ config_blue }}" class="form-control"> |
24 |
</div>
|
25 |
</div>
|
26 |
<div class="form-group"> |
27 |
<label for="config_yellow" class="col-sm-1 control-label">Yellow:</label> |
28 |
<div class="col-sm-2"> |
29 |
<input id="config_yellow" name="config_yellow" type="text" value="{{ config_yellow }}" class="form-control"> |
30 |
</div>
|
31 |
</div>
|
32 |
<div class="form-group"> |
33 |
<label for="config_teal" class="col-sm-1 control-label">Teal:</label> |
34 |
<div class="col-sm-2"> |
35 |
<input id="config_teal" name="config_teal" type="text" value="{{ config_teal }}" class="form-control"> |
36 |
</div>
|
37 |
</div>
|
38 |
<div class="form-group"> |
39 |
<label for="config_violet" class="col-sm-1 control-label">Violet:</label> |
40 |
<div class="col-sm-2"> |
41 |
<input id="config_violet" name="config_violet" type="text" value="{{ config_violet }}" class="form-control"> |
42 |
</div>
|
43 |
</div>
|
44 |
<div class="form-group"> |
45 |
<div class="col-sm-offset-1 col-sm-1"> |
46 |
<button type="submit" class="btn btn-default">Submit</button> |
47 |
</div>
|
48 |
</div>
|
49 |
</form>
|
Finally to put a bit of polish on the UI we'll indicate to the user that the Raspberry Pi is updating when they click the submit button. This involves a little bit of Javascript to capture the form submission event, change the button text to Updating... and disable the button:
1 |
<script>
|
2 |
$("form").submit(function(event) { |
3 |
$("form button").html("Updating...").prop("disabled", true); |
4 |
});
|
5 |
</script>
|
And here's the finished web UI:



Conclusion
This tutorial has expanded on the previous one to add a web UI to the RGB LCD Tweet Box. You can now control what you're following on the screen and the backlight colours you want from your phone, tablet or desktop machine.