-
1Step 1
Preconditions
I'm going to assume that you have a Raspberry Pi with a working installation of Raspbian Jessie and a working network connection. Smarter people than me have written instructions for getting these things working:
https://learn.adafruit.com/category/raspberry-pi
(I will try to link to instructions for older versions of Raspbian as I go along.)
-
2Step 2
Install Software
Install the espeak text to speech system:
sudo apt-get install espeak
and the pip library management tool:
sudo apt-get install python-pip
Now the flask Python web server library:sudo pip install flask
and Siegfried Gevatter's Python espeak wrapper:sudo apt-get install python-espeak
(I initially used the pyttsx library but found that it tends to hang after long messages - probably an internal buffer overflow somewhere.) -
3Step 3
Configure Sound Output (Pi Zero only)
If you're using any other kind of Pi, skip this step, a USB sound card is only needed if you're using the Pi Zero, which doesn't have an audio jack.
Even with the Zero, you can avoid using a USB sound card by either using an HDMI cable and your TV's speakers:
(Raspberry Pi audio configuration instructions here.)
or by building a simple RC filter and connecting to PWM output on GPIO 18:
If I was serious about deploying piHole on the Pi Zero I'd choose this second option. I'll have to get around to it some day anyway, but for now I have a cheap USB sound card dongle lying around (previously used with a BBB), so I'll use that.
Configuring a USB Sound Card on the Pi Zero
I'm going to assume that you're using a recent (Jessie) version of Raspbian here. If you're using an older version of Linux, you'll have to modify the file
/etc/modprobe.d/alsa-base.conf
following instructions like those here:
(Old instructions for switching audio output.)
On Raspbian Jessie we'll be modifying the file
/etc/alsa/alsa.conf
First let's identify our card. Plug in your USB sound card and run the commandaplay -l
This will display a "List of PLAYBACK Hardware Devices". The first device listed,
card 0: ALSA [bcm2835 ALSA], device 0: bcm2835 ALSA [bcm2835 ALSA]
is the default PWM output described at the Adafruit link above. The second listed device should be your USB Sound Card. For me the output is:
card 1: Set [C-Media USB Headphone Set], ...
Which is great news as the "C-Media USB Headphone Set" driver is pre-installed in Jessie.
If your card type is CM108 or CM109, you'll have to install a driver by following the instructions at:
(Another great tutorial by Lady Ada.)
Assuming you've got that done, and you're running Raspbian Jessie, we can
sudo nano /etc/alsa/alsa.conf
changing defaults.ctl.card and defaults.pcm.card from the default 0 to 1.
(Don't forget to backup alsa.conf before editing!)
To avoid the default card switching back to 0, I also modified the file .asoundrc in my home directory (/home/pi.).
Mine now reads
pcm.!default plughw:Set ctl.!default plughw:Set
where "Set" is the name of my USB Sound Card: the word immediately after "card 1: " in the output from "aplay -l" above.
(You can also see your card's name by running "aplay -L", some card names are set to "Audio".)
After rebooting, we should now be good to go. Connect the headphone jack of the USB sound card to some PC speakers and enter
aplay /usr/share/sounds/alsa/Front_Center.wav
and you should hear a young lady saying "Front Centre". If not, reboot and try again. If still not, give up. Seriously. Audio configuration is, and probably always willbe, the worst part of Linux (worse than WiFi support and suspend/resume). -
4Step 4
Introducing Flask
Flask is a Python library that essentially does three things:
a. it listens at a specified port (the standard "HTTP" port 80 by default) for "GET" and "POST" requests at specified URLs (or "pages") like "/" or "/about".
b. it sends text (usually in HTML format) to the client's browser in response to these requests. The text can be laid out in pre-prepared HTML templates (which can be modified at run-time using embedded "Jinja2" meta-language statements).
c. it provides a mechanism for passing data (parameters) into a template (e.g. putting data into an HTML form) and for reading data from a "POST" request (e.g. extracting data from a form's fields).
For a great introduction, see Matt Richardson's "Serving Raspberry Pi" tutorial here.
I'll attach a version of piHole that processes GPIO action requests to the downloads section of this project but I won't duplicate his tutorial here.
Before going on to the next section, you should try out Matt's "hello-flask.py" example to get a feel for what Flask is doing.
-
5Step 5
Code Outline
The plan is to implement a simple web site with 3 pages:
- "/" - the root page showing a menu of links to other pages.
- "say" - a form with "name" and "text" fields & a "send" button to send a text-to-speech message to the server.
- "log" - a page showing the last 25 messages, most recent first.
(The attached code download also implements a "gpio" page for toggling LEDs and a "quit" page to force a restart of the server but we won't cover them here.)
For now, the handler functions for each URL (page) just return the name of the page, which will be displayed in the client's browser. We'll look at the implementation of each page separately below. Notice that I've split the "say" page into separate "GET" and "POST" functions to simplify the code. The "GET" function is called when the page is first requested, the "POST" function is called when a "send" button on the page is clicked. (We'll show the HTML for the pages below.)
import time # used to timestamp messages import speech # code to call espeak from flask import Flask, render_template, request global message_log # stores a list of (time, name, text) tuples message_log = [] # empty the message log app = Flask(__name__) # Root Page - Display Main Menu @app.route("/") def root(): return 'root' # Log Page - Display Message Log @app.route('/log') def log(): return 'log' # Say Page - Display Message Entry Form @app.route('/say', methods=['GET']) def say(): return 'say get' # Say Post - Process Message @app.route('/say', methods=['POST']) def say_post(): return 'say post' if __name__ == "__main__": app.run(host='0.0.0.0', port=9012, debug=True)
(You can test this by executing "sudo python piHole.py" from the command line and browsing to http://<your-Pi's-IP-address>:9012.)
-
6Step 6
Root Page - Display Main Menu
Our root page (URL "/") is really easy to implement. The complete Python code is just:
@app.route("/") def root(): return render_template('piHole_root.html')
Here we're telling flask (via the "app" object of type Flask) that when the URL "/" is requested, flask should return the contents of the file templates/piHole_root.html:<!DOCTYPE html> <body> <h1>piHole - Raspberry Pi web server</h1> <a href="/say">Send text-to-speech message</a><br> <a href="/log">View message log</a><br> </body> </html>
The HTML template just contains a title and a link to each of the other pages.
-
7Step 7
Log Page - Display Message Log
The Message Log page code is almost as simple:
@app.route('/log') def log(): templateData = {'log' : message_log[-25:][::-1]} return render_template('piHole_log.html', **templateData)
Here we're building a "templateData" dictionary which maps the label 'log' to a list of the last (most recent) 25 items in the message_log. We do this by taking the slice [-25:]. The list is sorted into descending order (most recent first) by another slice [::-1].For an introduction to Python slices see the section on Strings here.
The HTML file templates/piHole_log.html is as follows:
<!DOCTYPE HTML> <html> <head> {# Define "row" class with tab stops #} <style> {body: font-family: arial;} div.row span{position: absolute;} div.row span:nth-child(1){left: 0px;} div.row span:nth-child(2){left: 80px;} div.row span:nth-child(3){left: 180px;} </style> {# Refresh the page every 15 seconds #} <META HTTP-EQUIV="refresh" CONTENT="15"> </head> <h1>piHole - Message log</h1> <body> {# Column headers #} <div class="row"> <span><b>When?</b></span> <span><b>Who?</b></span> <span><b>What?</b></span> </div><br> {# Jinja2 loop displaying each row tuple in data parameter "log" #} {% for entry in log %} <div class="row"> <span>{{entry.0}}</span> <span>{{entry.1}}</span> <span>{{entry.2}}</span> </div><br> {% endfor %} <br> <a href="/say">Send text-to-speech message</a><br> <a href="/">Main menu</a> </body> </html>
Here I'm defining a "row" style to display each log entry with tab stops at 0px, 80px and 180px. It would probably have been easier to use an HTML table but my HTML sucks and this solution came up first on google. (Thank you StackOverflow.)The <META> tag in the header causes the page to be refreshed every 15 seconds (to pick up new messages).
I'm then using my "row" style to display column headers at the tab stops I defined.
Now comes a bit of Jinja2 - the meta-language for modifying HTML in the Flask system. Jinja2 code is delimited by "{%" and "%}" so I have a for loop iterating over each tuple "entry" in the templateData item "log". The three fields of the log entry are displayed at the tab stops using the "row" style.
The templates ends with links to the "say" and "root" pages.
-
8Step 8
Say Page - Display Message Entry Form
We're splitting the "say" page processing into separate functions: say() to handle "GET" requests at URL "/say" and say_post() to handle "POST" requests.
The "GET" handling code is very simple:
@app.route('/say', methods=['GET']) def say(): return render_template("piHole_say.html")
The only change from our earlier root() function being the "methods=['GET']" qualifier to restrict the function call to "GET" requests only.Our HTML template (templates/piHole_say.html) is only complicated by a couple of Jinja2 conditionals to give the focus to the "name" field of the form when first loaded and to the "text" field on subsequent displays (so the user only has to enter a name once, when sending the first message of the session).
<!DOCTYPE html> <html lang="en"> <body> <h1>piHole - Send text-to-speech message</h1> <form action="/say" method="POST"> Who are you?<br> {# if no name has been entered, give the focus to the name field #} <input type="text" name="name" value="{{name}}"{% if name is not defined %} autofocus{% endif %}><br> What do you want to say?<br> {# otherwise, position the cursor on the text field #} <input type="text" name="text"{% if name is defined %} autofocus{% endif %}><br> <input type="submit" name="say" value="Send"> </form> <br> <a href="/log">View message log</a><br> <a href="/">Main menu</a> </body> </html>
-
9Step 9
Say Post - Process Message
"POST" requests are sent from the client's browser when the "Send" button is pressed on the "say" form.
@app.route('/say', methods=['POST']) def say_post(): name = request.form['name'] text = request.form['text'] speech.say(name + " says " + text) log_add(name, text) templateData = {'name' : name} return render_template("piHole_say.html", **templateData)
We extract the "name" and "text" field contents from the request form fields and call speech.say() function to read out the message "<name> says <text>". The message is added to the message log by a call to the function log_add().def log_add(who, what): global message_log when = time.strftime('%H:%M:%S') message_log.append((when, who, what))
This appends a tuple (<time>, <name>, <message>) to the message log.The only other wrinkle in say_post() is that we put the name extracted from the posted form into a templateData dictionary to ensure that the name field is pre-filled when we re-render the "say" form. Note that because the "name" field is defined, the cursor will be positioned on the "text" field by the Jinja2 code discussed in the previous section.
-
10Step 10
Complete Python Code
import time # used to timestamp messages import speech # code to call espeak from flask import Flask, render_template, request global message_log # stores a list of (time, name, text) tuples message_log = [] # empty the message log app = Flask(__name__) # Root Page - display main menu @app.route("/") def root(): return render_template('piHole_root.html') # Log Page - display message log # Display the last 25 entries in the message log, most recent first @app.route('/log') def log(): templateData = {'log' : message_log[-25:][::-1]} return render_template('piHole_log.html', **templateData) # Say Page - display message entry form @app.route('/say', methods=['GET']) def say(): return render_template("piHole_say.html") # Say Post - process message @app.route('/say', methods=['POST']) def say_post(): name = request.form['name'] text = request.form['text'] speech.say(name + " says " + text) log_add(name, text) templateData = {'name' : name} return render_template("piHole_say.html", **templateData) # add message text and sender name to message log with timestamp def log_add(who, what): global message_log when = time.strftime('%H:%M:%S') message_log.append((when, who, what)) if __name__ == "__main__": app.run(host='0.0.0.0', port=9012, debug=True)
Discussions
Become a Hackaday.io Member
Create an account to leave a comment. Already have an account? Log In.
Works wonderfully! Note in step 11 the code omits the imports, which are in the linked forum post code.
Are you sure? yes | no
Thanks for the feedback Denise. Fixed in §11
Are you sure? yes | no