Last time I worked on a DHT11 sensor and got a lot of feedback, this time I have added another sensor (the BMP180), stored both of them in MySQL, and am now displaying them to the web.
The Raspberry Pi now has an HTTP server running on port 80, and I've built a small little UI to display all my sensor data.
So this is all provided by a few files, one of them is a JavaScript file, one is a CSS file, one is an HTML file, and the rest are python files.
We'll start with the JavaScript for the graphs. This is pretty simple and self explanatory, the biggest issue I have is I need to break this down further so that there's no duplication. This is graphs.js
:
function buildGraph(svgTitle, table, title) {
var svg = d3.select(svgTitle);
var margin = {top: 20, right: 20, bottom: 30, left: 40};
var width = +svg.attr("width") - margin.left - margin.right;
var height = +svg.attr("height") - margin.top - margin.bottom;
var g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
var parseTime = d3.timeParse("%Y-%m-%d %H:%M:%S");
var x = d3.scaleTime().rangeRound([0, width]);
var y = d3.scaleLinear().rangeRound([height, 0]);
var line = d3.line()
.curve(d3.curveMonotoneX)
//.curve(d3.curveBasis)
.x(function(d) { return x(d.Logged); })
.y(function(d) { return y(d.Value); });
var area = d3.area()
.curve(d3.curveMonotoneX)
//.curve(d3.curveBasis)
.x(function(d) { return x(d.Logged); })
.y1(function(d) { return y(d.Value); });
d3.tsv("history.py?LogType=" + table, function(d) {
d.Logged = parseTime(d.Logged);
d.Value = +d.Value;
return d;
}, function(error, data) {
if (error) throw error;
x.domain(d3.extent(data, function(d) { return d.Logged; }));
var yS = y.domain(d3.extent(data, function(d) { return d.Value; })).nice();
area.y0(y(yS.domain()[0]));
g.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x).tickSize(-height));
g.append("g")
.attr("class", "y axis")
.call(d3.axisLeft(y).tickSize(-width))
.append("text")
.attr("fill", "#000")
.attr("transform", "rotate(-90)")
.attr("y", 6)
.attr("dy", "0.71em")
.attr("text-anchor", "em")
.text(title);
g.select(".y")
.select(".domain")
.attr("transform", "translate(-1,0)");
g.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", 1.5)
.attr("d", line);
g.append("path")
.datum(data)
.attr("fill", "steelblue")
.attr("fill-opacity", "0.3")
.attr("stroke", "none")
.attr("d", area);
});
}
function updateGraph(svgTitle, table, title) {
var svg = d3.select(svgTitle);
var margin = {top: 20, right: 20, bottom: 30, left: 40};
var width = +svg.attr("width") - margin.left - margin.right;
var height = +svg.attr("height") - margin.top - margin.bottom;
var g = svg.select("g");
var parseTime = d3.timeParse("%Y-%m-%d %H:%M:%S");
var x = d3.scaleTime().rangeRound([0, width]);
var y = d3.scaleLinear().rangeRound([height, 0]);
var line = d3.line()
.curve(d3.curveMonotoneX)
//.curve(d3.curveBasis)
.x(function(d) { return x(d.Logged); })
.y(function(d) { return y(d.Value); });
var area = d3.area()
.curve(d3.curveMonotoneX)
//.curve(d3.curveBasis)
.x(function(d) { return x(d.Logged); })
.y1(function(d) { return y(d.Value); });
d3.tsv("history.py?LogType=" + table, function(d) {
d.Logged = parseTime(d.Logged);
d.Value = +d.Value;
return d;
}, function(error, data) {
if (error) throw error;
x.domain(d3.extent(data, function(d) { return d.Logged; }));
var yS = y.domain(d3.extent(data, function(d) { return d.Value; })).nice();
area.y0(y(yS.domain()[0]));
g.selectAll("path").remove();
g.selectAll("g").remove();
g.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x).tickSize(-height));
g.append("g")
.attr("class", "y axis")
.call(d3.axisLeft(y).tickSize(-width))
.append("text")
.attr("fill", "#000")
.attr("transform", "rotate(-90)")
.attr("y", 6)
.attr("dy", "0.71em")
.attr("text-anchor", "em")
.text(title);
g.select(".y")
.select(".domain")
.attr("transform", "translate(-1,0)");
g.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", 1.5)
.attr("d", line);
g.append("path")
.datum(data)
.attr("fill", "steelblue")
.attr("fill-opacity", "0.3")
.attr("stroke", "none")
.attr("d", area);
});
}
Next we have the index.html
page, which is not actually loaded by the browser. It's the template for the index.py
to load, though it doesn't have to be now. (Previously I was doing wonky things with it.)
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<link href='bootstrap/css/bootstrap.min.css' rel='stylesheet'>
<link href="style.css" rel="stylesheet" />
<script src='d3/d3.min.js'></script>
<script src='graphs.js'></script>
</head>
<body>
<div class="header">
Last refresh: <span class="lastRefresh"></span>;
Pi time: <span class="piTime"></span>;
Next sensor pull: <span class="nextRefresh"></span> (<span class="nextRefreshSeconds"></span>)
</div>
<div class="current">
Most recent readings (<span id="logValue"></span>):
<span class="">
Temp 1:
<span id="temperatureValue"></span>F
</span>
<span class="">
Humidity:
<span id="humidityValue"></span>%
</span>
<span class="">
Pressure:
<span id="pressureValue"></span>inhg
</span>
<span class="">
Temp 2:
<span id="bmpTempValue"></span>F
</span>
<span class="">
Altitude:
<span id="altitudeValue"></span>ft
</span>
<span class="">
Sea Level Pressure:
<span id="seaPressureValue"></span>inhg
</span>
</div>
<div class='graphBlock'>
<svg class='temp' width='1260' height='240'></svg>
<svg class='humid' width='1260' height='240'></svg>
<svg class='pressure' width='1260' height='240'></svg>
<svg class='bmpTemp' width='1260' height='240'></svg>
<svg class='altitude' width='1260' height='240'></svg>
<svg class='seaPressure' width='1260' height='240'></svg>
</div>
<script src='jquery.1.12.4.min.js'></script>
<script src='bootstrap/js/bootstrap.min.js'></script>
<script>
Number.prototype.padLeft = function(base, chr) {
var len = (String(base || 10).length - String(this).length) + 1;
return len > 0 ? new Array(len).join(chr || '0') + this : this;
}
function round(value, decimals) {
return Number(Math.round(value + 'e' + decimals) + 'e-' + decimals);
}
function updateGraphs(callback) {
if (callback == null) {
callback = updateGraph;
}
callback('.temp', 'Temperature', 'Temp (F)');
callback('.humid', 'Humidity', 'Humidity (%)');
callback('.pressure', 'Pressure', 'Pressure (inhg)');
callback('.bmpTemp', 'BmpTemp', 'Temp (F)');
callback('.altitude', 'Altitude', 'Altitude (ft)');
callback('.seaPressure', 'SeaPressure', 'Sea Level Pressure (inhg)');
var d = new Date;
var dFormat = [d.getUTCFullYear(), (d.getUTCMonth() + 1).padLeft(), d.getUTCDate().padLeft()].join('-');
dFormat += " ";
dFormat += [d.getUTCHours().padLeft(), d.getUTCMinutes().padLeft(), d.getUTCSeconds().padLeft()].join(':');
$('.lastRefresh').html(dFormat);
}
function updateTimes() {
updatePiTime();
}
function updatePiTime(target, params) {
var xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = function() {
if (xmlHttp.readyState == 4 && xmlHttp.status == 200) {
var text = xmlHttp.responseText.trim().split("\n");
$(".nextRefresh").html(text[0]);
$(".nextRefreshSeconds").html(text[1]);
$(".piTime").html(text[2]);
}
}
xmlHttp.open("GET", "time.py?All=true", true);
xmlHttp.send(null);
}
function updateCurrents() {
var xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = function() {
if (xmlHttp.readyState == 4 && xmlHttp.status == 200) {
var text = xmlHttp.responseText.trim().split("\n");
text.forEach(function(line) {
var items = line.split("\t");
switch (items[0]) {
case "Humidity":
$("#humidityValue").html(round(items[2], 2));
break;
case "BmpTemp":
$("#bmpTempValue").html(round(items[2], 2));
break;
case "Pressure":
$("#pressureValue").html(round(items[2], 2));
break;
case "Altitude":
$("#altitudeValue").html(round(items[2], 2));
break;
case "Temperature":
$("#temperatureValue").html(round(items[2], 2));
break;
case "SeaPressure":
$("#seaPressureValue").html(round(items[2], 2));
break;
}
$("#logValue").html(items[1]);
});
}
}
xmlHttp.open("GET", "latest.py", true);
xmlHttp.send(null);
}
updateGraphs(buildGraph);
updateTimes();
updateCurrents();
setInterval(updateGraphs, 5000);
setInterval(updateTimes, 500);
setInterval(updateCurrents, 1000);
</script>
</body>
</html>
As you can see, the root of the web page is JavaScript, that's to keep data updated so that page refreshes aren't required.
All the CSS code, which is pretty simple (style.css
):
body {
background: #202020;
color: #efefef;
}
svg {
display: inline-block;
}
.graphBlock {
width: 100%;
text-align: center;
}
.axis line {
stroke: #303030;
}
.axis.y path,
.axis.y .tick:nth-child(2) line {
stroke: #efefef;
}
.axis path {
stroke: #303030;
}
.axis text {
fill: #efefef;
}
.header {
/*color: #efefef;*/
background: #07507F;
padding: 5px;
}
.current {
/*color: #efefef;*/
background: #07507F;
padding: 5px;
border-bottom: 1px solid #303030;
}
Then we have index.py
, the main landing page:
#!/usr/bin/python
from datetime import datetime
import pymysql
# Now we do the rest of the work
if __name__ == "__main__":
# Print the header first, always
print "Content-type: text/html"
print
with open("index.html") as template:
print template.read()
Then we have the asynchronously requested python scripts. The first is time.py
since it's the shortest:
#!/usr/bin/python
from datetime import datetime, timedelta
import cgi
DEFAULT_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
if __name__ == '__main__':
print "Content-Type: text/html"
print
args = cgi.FieldStorage()
date_format = DEFAULT_DATE_FORMAT
if "DateFormat" in args:
date_format = args["DateFormat"].value
if "Next" in args or "All" in args:
utctime = datetime.utcnow()
print (utctime + timedelta(seconds = 60 - utctime.second)).strftime(date_format)
if "NextIn" in args or "All" in args:
utctime = datetime.utcnow()
print str(60 - utctime.second) + " seconds"
if (not "Next" in args and not "NextIn" in args) or "All" in args:
print datetime.utcnow().strftime(date_format)
You can probably guess that it's only job is to return each time operation.
Next we have latest.py
, which returns the latest value of each metric:
#!/usr/bin/python
from datetime import datetime, timedelta
import cgi
import pymysql
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
DEFAULT_DAY_LIMIT = 7
DEFAULT_MINUTE_GROUPING = 1
if __name__ == '__main__':
print "Content-type: text/html"
print
args = cgi.FieldStorage()
start_date = (datetime.utcnow() - timedelta(days = DEFAULT_DAY_LIMIT)).strftime(DATE_FORMAT)
end_date = datetime.utcnow().strftime(DATE_FORMAT)
minute_group = DEFAULT_MINUTE_GROUPING
gather = []
gather.append(("HumidityLog", lambda x: x))
gather.append(("BmpTempLog", lambda c: c * 1.8 + 32))
gather.append(("PressureLog", lambda p: p * 0.0002953))
gather.append(("AltitudeLog", lambda m: m * 3.28084))
gather.append(("TemperatureLog", lambda c: c * 1.8 + 32))
gather.append(("SeaPressureLog", lambda p: p * 0.0002953))
if "Start" in args:
start_date = args["Start"].value
if "End" in args:
end_date = args["End"].value
conn = pymysql.connect(
db='WeatherLog',
user='WeatherLog',
passwd='Weather!',
host='localhost')
cursor = conn.cursor()
for table, formatter in gather:
cursor.execute("""
SELECT
Logged, AVG(Value)
FROM
{0}
WHERE
Logged >= '{1}' AND Logged <= '{2}'
GROUP BY
ROUND(UNIX_TIMESTAMP(Logged)/({3} * 60))
ORDER BY
Logged DESC
LIMIT
1;""".format(table, start_date, end_date, minute_group))
for row in cursor.fetchall():
print "{2}\t{0}\t{1}".format(row[0], formatter(row[1]), table[:-3])
And then finally we have history.py
, which returns the history for a single metric in tab-delimited format:
#!/usr/bin/python
from datetime import datetime, timedelta
import cgi
import pymysql
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
DEFAULT_DAY_LIMIT = 7
DEFAULT_MINUTE_GROUPING = 3
if __name__ == '__main__':
print "Content-type: text/html"
print
args = cgi.FieldStorage()
start_date = (datetime.utcnow() - timedelta(days = DEFAULT_DAY_LIMIT)).strftime(DATE_FORMAT)
end_date = datetime.utcnow().strftime(DATE_FORMAT)
minute_group = DEFAULT_MINUTE_GROUPING
table = "TemperatureLog"
formatter = lambda c: c * 1.8 + 32
if "LogType" in args:
log_type = args["LogType"].value
if log_type == "Humidity":
table = "HumidityLog"
formatter = lambda x: x
elif log_type == "BmpTemp":
table = "BmpTempLog"
formatter = lambda c: c * 1.8 + 32
elif log_type == "Pressure":
table = "PressureLog"
formatter = lambda p: p * 0.0002953
elif log_type == "Altitude":
table = "AltitudeLog"
formatter = lambda m: m * 3.28084
elif log_type == "SeaPressure":
table = "SeaPressureLog"
formatter = lambda p: p * 0.0002953
if "Start" in args:
start_date = args["Start"].value
if "End" in args:
end_date = args["End"].value
conn = pymysql.connect(
db='WeatherLog',
user='WeatherLog',
passwd='Weather!',
host='localhost')
cursor = conn.cursor()
cursor.execute("""
SELECT
Logged, AVG(Value)
FROM
{0}
WHERE
Logged >= '{1}' AND Logged <= '{2}'
GROUP BY
ROUND(UNIX_TIMESTAMP(Logged)/({3} * 60));""".format(table, start_date, end_date, minute_group))
didfirst = 0
print "Logged\tValue"
for row in cursor.fetchall():
didfirst = 1
print "{0}\t{1}".format(row[0], formatter(row[1]))
Any suggestions, advice and critiques are welcome. This is going to slowly get larger, and I'm going to be significantly increasing what it does so I want to get the structure and design setup correctly initially.