From 2690abcfed8710392c0a38dc7a883718351fa95f Mon Sep 17 00:00:00 2001
From: Jan Grewe <jan@faked.org>
Date: Tue, 16 Nov 2021 13:19:51 +0100
Subject: [PATCH] make UI nicer with charts and gauge TODO: - BME680 state

---
 data/airqmon.css     |   11 +
 data/airqmon.js      |  162 +++++-
 data/gauge.min.js    |    1 +
 data/gauge.min.js.gz |  Bin 0 -> 5046 bytes
 data/index.html      |   15 +-
 data/smoothie.js     | 1112 ++++++++++++++++++++++++++++++++++++++++++
 data/smoothie.js.gz  |  Bin 0 -> 12011 bytes
 src/main.cpp         |   40 +-
 8 files changed, 1311 insertions(+), 30 deletions(-)
 create mode 100644 data/gauge.min.js
 create mode 100644 data/gauge.min.js.gz
 create mode 100644 data/smoothie.js
 create mode 100644 data/smoothie.js.gz

diff --git a/data/airqmon.css b/data/airqmon.css
index fdc6b04..9d23b2c 100644
--- a/data/airqmon.css
+++ b/data/airqmon.css
@@ -3,6 +3,17 @@ h1 {
     letter-spacing: 3px;
 }
 
+.sensorName,
+.sensorValue {
+  font-size: 1.2rem;
+  font-weight: bolder;
+}
+
+.chart canvas {
+  width: 100%;
+  height: 100px;
+}
+
 #wsSpinner {
   position: fixed;
   bottom: 15px;
diff --git a/data/airqmon.js b/data/airqmon.js
index c14b5be..5e6fada 100644
--- a/data/airqmon.js
+++ b/data/airqmon.js
@@ -1,9 +1,108 @@
+var gauge = new Gauge($('#gauge')[0]);
 var websock;
+var timeseries = {};
+var charts = {};
+
+var gaugeModes = {
+    staticZones: [
+       {strokeStyle: "#00E400", min: 0, max: 50}, // Green
+       {strokeStyle: "#FFFF00", min: 51, max: 100}, // Yellow
+       {strokeStyle: "#FF7E00", min: 101, max: 150}, // Orange
+       {strokeStyle: "#FF0000", min: 151, max: 200}, // Red
+       {strokeStyle: "#8F3F97", min: 201, max: 300}, // Violet
+       {strokeStyle: "#7E0023", min: 301, max: 500}  // Brown
+    ],
+    percentColors: [
+        [0.0, "#00E400" ],
+        [0.1, "#FFFF00" ],
+        [0.2, "#FF7E00"],
+        [0.3, "#FF0000"],
+        [0.4, "#8F3F97"],
+        [0.6, "#7E0023"]
+    ]
+}
+
+
+var metrics = {
+    "iaq": {name: "IAQ", has_accuracy: true, decimals: 0},
+    "siaq": {name: "Static IAQ", has_accuracy: true, decimals: 0},
+    "eco2": {name: "CO2", unit: " ppm", has_accuracy: true, decimals: 0},
+    "bvoc": {name: "Breath VOC", unit: " ppm", has_accuracy: true, decimals: 3},
+    "pm1": {name: "PM ≤1.0µm", unit: " μg/m³"},
+    "pm2p5": {name: "PM ≤2.5µm", unit: " μg/m³"},
+    "pm10": {name: "PM ≤10µm", unit: " μg/m³"},
+    "nc0p3": {name: "NC ≥0.3µm", unit: "/100cm³"},
+    "nc1": {name: "NC ≥0.5µm", unit: "/100cm³"},
+    "nc2p5": {name: "NC ≥1.0µm", unit: "/100cm³"},
+    "nc5": {name: "NC ≥5.0µm", unit: "/100cm³"},
+    "nc10": {name: "NC ≥10µm", unit: "/100cm³"},
+    "temperature": {name: "Temperature", unit: " °C", decimals: 1},
+    "humidity": {name: "Humidity", unit: "%", decimals: 1},
+    "pressure": {name: "Pressure", unit: " hPa", decimals: 0}
+}
+
+var accuracyStatus = {
+    0: {name: "Stabilizing", class: "danger"},
+    1: {name: "Uncertain", class: "warning"},
+    2: {name: "Calibrating", class: "primary"},
+    3: {name: "Calibrated", class: "success"}
+}
+
+var gaugeOptions = {
+    angle: 0, // The span of the gauge arc
+    lineWidth: 0.45, // The line thickness
+    radiusScale: 1, // Relative radius
+    pointer: {
+        length: 0.5, // // Relative to gauge radius
+        strokeWidth: 0.100, // The thickness
+        color: '#000000' // Fill color
+    },
+    colorStart: '#6F6EA0',   // Colors
+    colorStop: '#C0C0DB',    // just experiment with them
+    strokeColor: '#EEEEEE',  // to see which ones work best for you
+    generateGradient: true,
+    highDpiSupport: true,     // High resolution support
+    staticLabels: {
+      font: "10px sans-serif",  // Specifies font
+      labels: [50, 100, 150, 200, 300, 500],  // Print labels at these values
+      color: "#000000",  // Optional: Label text color
+      fractionDigits: 0  // Optional: Numerical precision. 0=round off.
+    }
+};
+
+var chartOptions = {
+    responsive: true,
+    millisPerPixel: 100,
+    grid: {
+        fillStyle:'rgba(255,255,255, 0.75)',
+        strokeStyle:'rgba(128,128,128, 0.10)',
+        verticalSections: 5
+    },
+    labels: {
+        fillStyle:'rgba(0,0,0,0.75)'
+    }
+}
+
+var lineOptions = {
+    lineWidth: 2,
+    strokeStyle:'#00ff00'
+}
 
 $(document).ready(function(event) {
-  startWebsocket();
+    setupGauge('staticZones');
+    startWebsocket();
 });
 
+function setupGauge(mode) {
+    var objColor = {}
+    objColor[mode] = gaugeModes[mode];
+    var opt = Object.assign(gaugeOptions, objColor);
+    gauge.setOptions(opt);
+    gauge.setMinValue(0);
+    gauge.maxValue = 500;
+    gauge.animationSpeed = 128;
+}
+
 function startWebsocket() {
     websock = new WebSocket('ws://' + window.location.hostname + ':81/');
     websock.onopen = function(evt) {
@@ -21,13 +120,70 @@ function startWebsocket() {
     };
     websock.onmessage = function(evt) {
         data = JSON.parse(evt.data);
+        //console.log(data);
         handleWebsocketMessage(data);
     };
 }
 
 function handleWebsocketMessage(data) {
-    console.log(data);
-    $('#data').text(JSON.stringify(data, null, 2));
+    if ('siaq' in data) {
+        gauge.set(data.siaq);
+    }
+    $.each(metrics, function(name, sensor) {
+        if ('has_accuracy' in metrics[name]) {
+            updateMetric(name, data[name], data[name+'_acc']);
+        } else {
+            updateMetric(name, data[name], false);
+        }
+    });
+}
+
+function updateMetric(name, value, accuracy) {
+    var numericValue = value;
+    if ('decimals' in metrics[name]) {
+        value = value.toFixed(metrics[name].decimals);
+    }
+    if ('unit' in metrics[name]) {
+        value += metrics[name].unit;
+    }
+    if (accuracy !== false) {
+        value = '<span class="badge bg-'+accuracyStatus[accuracy].class+'">'+accuracyStatus[accuracy].name+'</span> '+value;
+    }
+    if(!$('#metric_'+name).length && name in metrics) {
+        timeseries[name] = new TimeSeries();
+        timeseries[name].append(new Date().getTime(), numericValue);
+        var metric = $('<li class="list-group-item list-group-item-action" id="metric_'+name+'">'+
+            '<div class="row my-2 rowMetric">'+
+                '<div class="d-flex w-100 justify-content-between">'+
+                    '<div class="sensorName">'+metrics[name].name+'</div>'+
+                    '<div class="sensorValue">'+value+'</div>'+
+                '</div>'+
+            '</div>'+
+          '</li>)');
+        metric.find('.rowMetric').on('click', function() {
+            toggleChart(name);
+        });
+        $('#metrics').append(metric);        
+    } else {
+        $('#metric_'+name+' .sensorValue').html(value);
+        timeseries[name].append(new Date().getTime(), numericValue);
+    }
+}
+
+function toggleChart(name) {
+    if(!$('#chart_'+name).length) {
+        charts[name] = new SmoothieChart(chartOptions);
+        charts[name].addTimeSeries(timeseries[name], lineOptions);
+        $('#metric_'+name).append($('<div class="row rowChart">'+
+            '<div class="chart my-2"><canvas id="chart_'+name+'"></canvas></div>'+
+        '</div>'));
+        charts[name].streamTo(document.getElementById("chart_"+name), 1000);
+    } else {
+        charts[name].removeTimeSeries(timeseries[name]);
+        charts[name].stop();
+        delete charts[name];
+        $('#metric_'+name+' .rowChart').remove();
+    }
 }
 
 jQuery.fn.visible = function() {
diff --git a/data/gauge.min.js b/data/gauge.min.js
new file mode 100644
index 0000000..1b77c1c
--- /dev/null
+++ b/data/gauge.min.js
@@ -0,0 +1 @@
+(function(){function t(t,i){for(var e in i)m.call(i,e)&&(t[e]=i[e]);function s(){this.constructor=t}return s.prototype=i.prototype,t.prototype=new s,t.__super__=i.prototype,t}var i,e,s,n,o,p,a,h,r,l,g,c,u,d=[].slice,m={}.hasOwnProperty,x=[].indexOf||function(t){for(var i=0,e=this.length;i<e;i++)if(i in this&&this[i]===t)return i;return-1};function f(t,i){null==t&&(t=!0),this.clear=null==i||i,t&&AnimationUpdater.add(this)}function v(){return v.__super__.constructor.apply(this,arguments)}function y(t,i){this.el=t,this.fractionDigits=i}function V(t,i){if(this.elem=t,this.text=null!=i&&i,V.__super__.constructor.call(this),void 0===this.elem)throw new Error("The element isn't defined.");this.value=1*this.elem.innerHTML,this.text&&(this.value=0)}function w(t){if(this.gauge=t,void 0===this.gauge)throw new Error("The element isn't defined.");this.ctx=this.gauge.ctx,this.canvas=this.gauge.canvas,w.__super__.constructor.call(this,!1,!1),this.setOptions()}function S(t){this.elem=t}function M(t){var i,e;this.canvas=t,M.__super__.constructor.call(this),this.percentColors=null,"undefined"!=typeof G_vmlCanvasManager&&(this.canvas=window.G_vmlCanvasManager.initElement(this.canvas)),this.ctx=this.canvas.getContext("2d"),i=this.canvas.clientHeight,e=this.canvas.clientWidth,this.canvas.height=i,this.canvas.width=e,this.gp=[new p(this)],this.setOptions()}function C(t){this.canvas=t,C.__super__.constructor.call(this),"undefined"!=typeof G_vmlCanvasManager&&(this.canvas=window.G_vmlCanvasManager.initElement(this.canvas)),this.ctx=this.canvas.getContext("2d"),this.setOptions(),this.render()}function _(){return _.__super__.constructor.apply(this,arguments)}!function(){var s,n,t,o,i,e,a;for(t=0,i=(a=["ms","moz","webkit","o"]).length;t<i&&(e=a[t],!window.requestAnimationFrame);t++)window.requestAnimationFrame=window[e+"RequestAnimationFrame"],window.cancelAnimationFrame=window[e+"CancelAnimationFrame"]||window[e+"CancelRequestAnimationFrame"];s=null,o=0,n={},requestAnimationFrame?window.cancelAnimationFrame||(s=window.requestAnimationFrame,window.requestAnimationFrame=function(t,i){var e;return e=++o,s(function(){if(!n[e])return t()},i),e},window.cancelAnimationFrame=function(t){return n[t]=!0}):(window.requestAnimationFrame=function(t,i){var e,s,n,o;return e=(new Date).getTime(),o=Math.max(0,16-(e-n)),s=window.setTimeout(function(){return t(e+o)},o),n=e+o,s},window.cancelAnimationFrame=function(t){return clearTimeout(t)})}(),u=function(t){var i,e;for(t-=3600*(i=Math.floor(t/3600))+60*(e=Math.floor((t-3600*i)/60)),t+="",e+="";e.length<2;)e="0"+e;for(;t.length<2;)t="0"+t;return(i=i?i+":":"")+e+":"+t},g=function(){var t,i,e;return e=(i=1<=arguments.length?d.call(arguments,0):[])[0],t=i[1],r(e.toFixed(t))},c=function(t,i){var e,s,n;for(e in s={},t)m.call(t,e)&&(n=t[e],s[e]=n);for(e in i)m.call(i,e)&&(n=i[e],s[e]=n);return s},r=function(t){var i,e,s,n;for(s=(e=(t+="").split("."))[0],n="",1<e.length&&(n="."+e[1]),i=/(\d+)(\d{3})/;i.test(s);)s=s.replace(i,"$1,$2");return s+n},l=function(t){return"#"===t.charAt(0)?t.substring(1,7):t},f.prototype.animationSpeed=32,f.prototype.update=function(t){var i;return null==t&&(t=!1),!(!t&&this.displayedValue===this.value||(this.ctx&&this.clear&&this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height),i=this.value-this.displayedValue,Math.abs(i/this.animationSpeed)<=.001?this.displayedValue=this.value:this.displayedValue=this.displayedValue+i/this.animationSpeed,this.render(),0))},t(v,h=f),v.prototype.displayScale=1,v.prototype.forceUpdate=!0,v.prototype.setTextField=function(t,i){return this.textField=t instanceof a?t:new a(t,i)},v.prototype.setMinValue=function(t,i){var e,s,n,o,a;if(this.minValue=t,null==i&&(i=!0),i){for(this.displayedValue=this.minValue,a=[],s=0,n=(o=this.gp||[]).length;s<n;s++)e=o[s],a.push(e.displayedValue=this.minValue);return a}},v.prototype.setOptions=function(t){return null==t&&(t=null),this.options=c(this.options,t),this.textField&&(this.textField.el.style.fontSize=t.fontSize+"px"),.5<this.options.angle&&(this.options.angle=.5),this.configDisplayScale(),this},v.prototype.configDisplayScale=function(){var t,i,e,s,n;return s=this.displayScale,!1===this.options.highDpiSupport?delete this.displayScale:(i=window.devicePixelRatio||1,t=this.ctx.webkitBackingStorePixelRatio||this.ctx.mozBackingStorePixelRatio||this.ctx.msBackingStorePixelRatio||this.ctx.oBackingStorePixelRatio||this.ctx.backingStorePixelRatio||1,this.displayScale=i/t),this.displayScale!==s&&(n=this.canvas.G__width||this.canvas.width,e=this.canvas.G__height||this.canvas.height,this.canvas.width=n*this.displayScale,this.canvas.height=e*this.displayScale,this.canvas.style.width=n+"px",this.canvas.style.height=e+"px",this.canvas.G__width=n,this.canvas.G__height=e),this},v.prototype.parseValue=function(t){return t=parseFloat(t)||Number(t),isFinite(t)?t:0},s=v,y.prototype.render=function(t){return this.el.innerHTML=g(t.displayedValue,this.fractionDigits)},a=y,t(V,h),V.prototype.displayedValue=0,V.prototype.value=0,V.prototype.setVal=function(t){return this.value=1*t},V.prototype.render=function(){var t;return t=this.text?u(this.displayedValue.toFixed(0)):r(g(this.displayedValue)),this.elem.innerHTML=t},i=V,t(w,h),w.prototype.displayedValue=0,w.prototype.value=0,w.prototype.options={strokeWidth:.035,length:.1,color:"#000000",iconPath:null,iconScale:1,iconAngle:0},w.prototype.img=null,w.prototype.setOptions=function(t){if(null==t&&(t=null),this.options=c(this.options,t),this.length=2*this.gauge.radius*this.gauge.options.radiusScale*this.options.length,this.strokeWidth=this.canvas.height*this.options.strokeWidth,this.maxValue=this.gauge.maxValue,this.minValue=this.gauge.minValue,this.animationSpeed=this.gauge.animationSpeed,this.options.angle=this.gauge.options.angle,this.options.iconPath)return this.img=new Image,this.img.src=this.options.iconPath},w.prototype.render=function(){var t,i,e,s,n,o,a,h,r;if(t=this.gauge.getAngle.call(this,this.displayedValue),h=Math.round(this.length*Math.cos(t)),r=Math.round(this.length*Math.sin(t)),o=Math.round(this.strokeWidth*Math.cos(t-Math.PI/2)),a=Math.round(this.strokeWidth*Math.sin(t-Math.PI/2)),i=Math.round(this.strokeWidth*Math.cos(t+Math.PI/2)),e=Math.round(this.strokeWidth*Math.sin(t+Math.PI/2)),this.ctx.beginPath(),this.ctx.fillStyle=this.options.color,this.ctx.arc(0,0,this.strokeWidth,0,2*Math.PI,!1),this.ctx.fill(),this.ctx.beginPath(),this.ctx.moveTo(o,a),this.ctx.lineTo(h,r),this.ctx.lineTo(i,e),this.ctx.fill(),this.img)return s=Math.round(this.img.width*this.options.iconScale),n=Math.round(this.img.height*this.options.iconScale),this.ctx.save(),this.ctx.translate(h,r),this.ctx.rotate(t+Math.PI/180*(90+this.options.iconAngle)),this.ctx.drawImage(this.img,-s/2,-n/2,s,n),this.ctx.restore()},p=w,S.prototype.updateValues=function(t){return this.value=t[0],this.maxValue=t[1],this.avgValue=t[2],this.render()},S.prototype.render=function(){var t,i;return this.textField&&this.textField.text(g(this.value)),0===this.maxValue&&(this.maxValue=2*this.avgValue),i=this.value/this.maxValue*100,t=this.avgValue/this.maxValue*100,$(".bar-value",this.elem).css({width:i+"%"}),$(".typical-value",this.elem).css({width:t+"%"})},t(M,s),M.prototype.elem=null,M.prototype.value=[20],M.prototype.maxValue=80,M.prototype.minValue=0,M.prototype.displayedAngle=0,M.prototype.displayedValue=0,M.prototype.lineWidth=40,M.prototype.paddingTop=.1,M.prototype.paddingBottom=.1,M.prototype.percentColors=null,M.prototype.options={colorStart:"#6fadcf",colorStop:void 0,gradientType:0,strokeColor:"#e0e0e0",pointer:{length:.8,strokeWidth:.035,iconScale:1},angle:.15,lineWidth:.44,radiusScale:1,fontSize:40,limitMax:!1,limitMin:!1},M.prototype.setOptions=function(t){var i,e,s,n,o;for(null==t&&(t=null),M.__super__.setOptions.call(this,t),this.configPercentColors(),this.extraPadding=0,this.options.angle<0&&(n=Math.PI*(1+this.options.angle),this.extraPadding=Math.sin(n)),this.availableHeight=this.canvas.height*(1-this.paddingTop-this.paddingBottom),this.lineWidth=this.availableHeight*this.options.lineWidth,this.radius=(this.availableHeight-this.lineWidth/2)/(1+this.extraPadding),this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height),e=0,s=(o=this.gp).length;e<s;e++)(i=o[e]).setOptions(this.options.pointer),i.render();return this.render(),this},M.prototype.configPercentColors=function(){var t,i,e,s,n,o,a;if(this.percentColors=null,void 0!==this.options.percentColors){for(this.percentColors=new Array,o=[],e=s=0,n=this.options.percentColors.length-1;0<=n?s<=n:n<=s;e=0<=n?++s:--s)a=parseInt(l(this.options.percentColors[e][1]).substring(0,2),16),i=parseInt(l(this.options.percentColors[e][1]).substring(2,4),16),t=parseInt(l(this.options.percentColors[e][1]).substring(4,6),16),o.push(this.percentColors[e]={pct:this.options.percentColors[e][0],color:{r:a,g:i,b:t}});return o}},M.prototype.set=function(t){var i,e,s,n,o,a,h,r,l;for(t instanceof Array||(t=[t]),e=s=0,h=t.length-1;0<=h?s<=h:h<=s;e=0<=h?++s:--s)t[e]=this.parseValue(t[e]);if(t.length>this.gp.length)for(e=n=0,r=t.length-this.gp.length;0<=r?n<r:r<n;e=0<=r?++n:--n)(i=new p(this)).setOptions(this.options.pointer),this.gp.push(i);else t.length<this.gp.length&&(this.gp=this.gp.slice(this.gp.length-t.length));for(a=e=0,o=t.length;a<o;a++)(l=t[a])>this.maxValue?this.options.limitMax?l=this.maxValue:this.maxValue=l+1:l<this.minValue&&(this.options.limitMin?l=this.minValue:this.minValue=l-1),this.gp[e].value=l,this.gp[e++].setOptions({minValue:this.minValue,maxValue:this.maxValue,angle:this.options.angle});return this.value=Math.max(Math.min(t[t.length-1],this.maxValue),this.minValue),AnimationUpdater.add(this),AnimationUpdater.run(this.forceUpdate),this.forceUpdate=!1},M.prototype.getAngle=function(t){return(1+this.options.angle)*Math.PI+(t-this.minValue)/(this.maxValue-this.minValue)*(1-2*this.options.angle)*Math.PI},M.prototype.getColorForPercentage=function(t,i){var e,s,n,o,a,h,r;if(0===t)e=this.percentColors[0].color;else for(e=this.percentColors[this.percentColors.length-1].color,n=o=0,h=this.percentColors.length-1;0<=h?o<=h:h<=o;n=0<=h?++o:--o)if(t<=this.percentColors[n].pct){e=!0===i?(r=this.percentColors[n-1]||this.percentColors[0],s=this.percentColors[n],a=(t-r.pct)/(s.pct-r.pct),{r:Math.floor(r.color.r*(1-a)+s.color.r*a),g:Math.floor(r.color.g*(1-a)+s.color.g*a),b:Math.floor(r.color.b*(1-a)+s.color.b*a)}):this.percentColors[n].color;break}return"rgb("+[e.r,e.g,e.b].join(",")+")"},M.prototype.getColorForValue=function(t,i){var e;return e=(t-this.minValue)/(this.maxValue-this.minValue),this.getColorForPercentage(e,i)},M.prototype.renderStaticLabels=function(t,i,e,s){var n,o,a,h,r,l,p,c,u,d;for(this.ctx.save(),this.ctx.translate(i,e),l=/\d+\.?\d?/,r=(n=t.font||"10px Times").match(l)[0],c=n.slice(r.length),o=parseFloat(r)*this.displayScale,this.ctx.font=o+c,this.ctx.fillStyle=t.color||"#000000",this.ctx.textBaseline="bottom",this.ctx.textAlign="center",a=0,h=(p=t.labels).length;a<h;a++)void 0!==(d=p[a]).label?(!this.options.limitMin||d>=this.minValue)&&(!this.options.limitMax||d<=this.maxValue)&&(r=(n=d.font||t.font).match(l)[0],c=n.slice(r.length),o=parseFloat(r)*this.displayScale,this.ctx.font=o+c,u=this.getAngle(d.label)-3*Math.PI/2,this.ctx.rotate(u),this.ctx.fillText(g(d.label,t.fractionDigits),0,-s-this.lineWidth/2),this.ctx.rotate(-u)):(!this.options.limitMin||d>=this.minValue)&&(!this.options.limitMax||d<=this.maxValue)&&(u=this.getAngle(d)-3*Math.PI/2,this.ctx.rotate(u),this.ctx.fillText(g(d,t.fractionDigits),0,-s-this.lineWidth/2),this.ctx.rotate(-u));return this.ctx.restore()},M.prototype.renderTicks=function(t,i,e,s){var n,o,a,h,r,l,p,c,u,d,g,m,x,f,v,y,V,w,S,M;if(t!=={}){for(l=t.divisions||0,w=t.subDivisions||0,a=t.divColor||"#fff",f=t.subColor||"#fff",h=t.divLength||.7,y=t.subLength||.2,u=parseFloat(this.maxValue)-parseFloat(this.minValue),d=parseFloat(u)/parseFloat(t.divisions),v=parseFloat(d)/parseFloat(t.subDivisions),n=parseFloat(this.minValue),o=0+v,r=(c=u/400)*(t.divWidth||1),V=c*(t.subWidth||1),m=[],S=p=0,g=l+1;p<g;S=p+=1)this.ctx.lineWidth=this.lineWidth*h,x=this.lineWidth/2*(1-h),M=this.radius*this.options.radiusScale+x,this.ctx.strokeStyle=a,this.ctx.beginPath(),this.ctx.arc(0,0,M,this.getAngle(n-r),this.getAngle(n+r),!1),this.ctx.stroke(),o=n+v,n+=d,S!==t.divisions&&0<w?m.push(function(){var t,i,e;for(e=[],t=0,i=w-1;t<i;t+=1)this.ctx.lineWidth=this.lineWidth*y,x=this.lineWidth/2*(1-y),M=this.radius*this.options.radiusScale+x,this.ctx.strokeStyle=f,this.ctx.beginPath(),this.ctx.arc(0,0,M,this.getAngle(o-V),this.getAngle(o+V),!1),this.ctx.stroke(),e.push(o+=v);return e}.call(this)):m.push(void 0);return m}},M.prototype.render=function(){var t,i,e,s,n,o,a,h,r,l,p,c,u,d,g,m;if(g=this.canvas.width/2,e=this.canvas.height*this.paddingTop+this.availableHeight-(this.radius+this.lineWidth/2)*this.extraPadding,t=this.getAngle(this.displayedValue),this.textField&&this.textField.render(this),this.ctx.lineCap="butt",l=this.radius*this.options.radiusScale,this.options.staticLabels&&this.renderStaticLabels(this.options.staticLabels,g,e,l),this.options.staticZones)for(this.ctx.save(),this.ctx.translate(g,e),this.ctx.lineWidth=this.lineWidth,s=0,o=(p=this.options.staticZones).length;s<o;s++)r=(m=p[s]).min,this.options.limitMin&&r<this.minValue&&(r=this.minValue),h=m.max,this.options.limitMax&&h>this.maxValue&&(h=this.maxValue),d=this.radius*this.options.radiusScale,m.height&&(this.ctx.lineWidth=this.lineWidth*m.height,u=this.lineWidth/2*(m.offset||1-m.height),d=this.radius*this.options.radiusScale+u),this.ctx.strokeStyle=m.strokeStyle,this.ctx.beginPath(),this.ctx.arc(0,0,d,this.getAngle(r),this.getAngle(h),!1),this.ctx.stroke();else void 0!==this.options.customFillStyle?i=this.options.customFillStyle(this):null!==this.percentColors?i=this.getColorForValue(this.displayedValue,this.options.generateGradient):void 0!==this.options.colorStop?((i=0===this.options.gradientType?this.ctx.createRadialGradient(g,e,9,g,e,70):this.ctx.createLinearGradient(0,0,g,0)).addColorStop(0,this.options.colorStart),i.addColorStop(1,this.options.colorStop)):i=this.options.colorStart,this.ctx.strokeStyle=i,this.ctx.beginPath(),this.ctx.arc(g,e,l,(1+this.options.angle)*Math.PI,t,!1),this.ctx.lineWidth=this.lineWidth,this.ctx.stroke(),this.ctx.strokeStyle=this.options.strokeColor,this.ctx.beginPath(),this.ctx.arc(g,e,l,t,(2-this.options.angle)*Math.PI,!1),this.ctx.stroke(),this.ctx.save(),this.ctx.translate(g,e);for(this.options.renderTicks&&this.renderTicks(this.options.renderTicks,g,e,l),this.ctx.restore(),this.ctx.translate(g,e),n=0,a=(c=this.gp).length;n<a;n++)c[n].update(!0);return this.ctx.translate(-g,-e)},o=M,t(C,s),C.prototype.lineWidth=15,C.prototype.displayedValue=0,C.prototype.value=33,C.prototype.maxValue=80,C.prototype.minValue=0,C.prototype.options={lineWidth:.1,colorStart:"#6f6ea0",colorStop:"#c0c0db",strokeColor:"#eeeeee",shadowColor:"#d5d5d5",angle:.35,radiusScale:1},C.prototype.getAngle=function(t){return(1-this.options.angle)*Math.PI+(t-this.minValue)/(this.maxValue-this.minValue)*(2+this.options.angle-(1-this.options.angle))*Math.PI},C.prototype.setOptions=function(t){return null==t&&(t=null),C.__super__.setOptions.call(this,t),this.lineWidth=this.canvas.height*this.options.lineWidth,this.radius=this.options.radiusScale*(this.canvas.height/2-this.lineWidth/2),this},C.prototype.set=function(t){return this.value=this.parseValue(t),this.value>this.maxValue?this.options.limitMax?this.value=this.maxValue:this.maxValue=this.value:this.value<this.minValue&&(this.options.limitMin?this.value=this.minValue:this.minValue=this.value),AnimationUpdater.add(this),AnimationUpdater.run(this.forceUpdate),this.forceUpdate=!1},C.prototype.render=function(){var t,i,e,s;return t=this.getAngle(this.displayedValue),s=this.canvas.width/2,e=this.canvas.height/2,this.textField&&this.textField.render(this),(i=this.ctx.createRadialGradient(s,e,39,s,e,70)).addColorStop(0,this.options.colorStart),i.addColorStop(1,this.options.colorStop),this.radius,this.lineWidth,this.radius,this.lineWidth,this.ctx.strokeStyle=this.options.strokeColor,this.ctx.beginPath(),this.ctx.arc(s,e,this.radius,(1-this.options.angle)*Math.PI,(2+this.options.angle)*Math.PI,!1),this.ctx.lineWidth=this.lineWidth,this.ctx.lineCap="round",this.ctx.stroke(),this.ctx.strokeStyle=i,this.ctx.beginPath(),this.ctx.arc(s,e,this.radius,(1-this.options.angle)*Math.PI,t,!1),this.ctx.stroke()},t(_,e=C),_.prototype.strokeGradient=function(t,i,e,s){var n;return(n=this.ctx.createRadialGradient(t,i,e,t,i,s)).addColorStop(0,this.options.shadowColor),n.addColorStop(.12,this.options._orgStrokeColor),n.addColorStop(.88,this.options._orgStrokeColor),n.addColorStop(1,this.options.shadowColor),n},_.prototype.setOptions=function(t){var i,e,s,n;return null==t&&(t=null),_.__super__.setOptions.call(this,t),n=this.canvas.width/2,i=this.canvas.height/2,e=this.radius-this.lineWidth/2,s=this.radius+this.lineWidth/2,this.options._orgStrokeColor=this.options.strokeColor,this.options.strokeColor=this.strokeGradient(n,i,e,s),this},n=_,window.AnimationUpdater={elements:[],animId:null,addAll:function(t){var i,e,s,n;for(n=[],e=0,s=t.length;e<s;e++)i=t[e],n.push(AnimationUpdater.elements.push(i));return n},add:function(t){if(x.call(AnimationUpdater.elements,t)<0)return AnimationUpdater.elements.push(t)},run:function(t){var i,e,s,n,o,a,h;if(null==t&&(t=!1),isFinite(parseFloat(t))||!0===t){for(i=!0,h=[],s=e=0,o=(a=AnimationUpdater.elements).length;e<o;s=++e)a[s].update(!0===t)?i=!1:h.push(s);for(n=h.length-1;0<=n;n+=-1)s=h[n],AnimationUpdater.elements.splice(s,1);return AnimationUpdater.animId=i?null:requestAnimationFrame(AnimationUpdater.run)}if(!1===t)return!0===AnimationUpdater.animId&&cancelAnimationFrame(AnimationUpdater.animId),AnimationUpdater.animId=requestAnimationFrame(AnimationUpdater.run)}},"function"==typeof window.define&&null!=window.define.amd?define(function(){return{Gauge:o,Donut:n,BaseDonut:e,TextRenderer:a}}):"undefined"!=typeof module&&null!=module.exports?module.exports={Gauge:o,Donut:n,BaseDonut:e,TextRenderer:a}:(window.Gauge=o,window.Donut=n,window.BaseDonut=e,window.TextRenderer=a)}).call(this);
\ No newline at end of file
diff --git a/data/gauge.min.js.gz b/data/gauge.min.js.gz
new file mode 100644
index 0000000000000000000000000000000000000000..d0010ff1ae3e134cf1375bbcb055025901489290
GIT binary patch
literal 5046
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
Y*1e@tH1hPxZ~s|$3ckqP;l{uK0O@DGYXATM

literal 0
HcmV?d00001

diff --git a/data/index.html b/data/index.html
index 04d6855..2b9f250 100644
--- a/data/index.html
+++ b/data/index.html
@@ -27,14 +27,13 @@
         <h1>Air Quality Monitor</h1>
     </div>
 
-    <div class="mb-4">
-      <div class="row">
-        
-        <div class="col-lg-10 col-12 my-4">
-          <div id="data"><div>
-        </div>
+    <div class="d-flex flex-row justify-content-center mb-3">
+        <canvas id="gauge"></canvas>
+    </div>
 
-    </div> <!-- row -->
+    <div class="row mx-1 mb-3">
+      <div class="list-group col-12 col-lg-6 offset-lg-3" id="metrics"></div>
+    </div>
     
     <div class="spinner-border" role="status" id="wsSpinner">
         <span class="visually-hidden">Loading...</span>
@@ -44,6 +43,8 @@
 
 <script src="jquery-3.5.1.min.js"></script>
 <script src="bootstrap.min.js"></script>
+<script src="gauge.min.js"></script>
+<script src="smoothie.js"></script>
 <script src="airqmon.js"></script>
 </body>
 </html>
\ No newline at end of file
diff --git a/data/smoothie.js b/data/smoothie.js
new file mode 100644
index 0000000..ad40ed5
--- /dev/null
+++ b/data/smoothie.js
@@ -0,0 +1,1112 @@
+// MIT License:
+//
+// Copyright (c) 2010-2013, Joe Walnes
+//               2013-2018, Drew Noakes
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+/**
+ * Smoothie Charts - http://smoothiecharts.org/
+ * (c) 2010-2013, Joe Walnes
+ *     2013-2018, Drew Noakes
+ *
+ * v1.0: Main charting library, by Joe Walnes
+ * v1.1: Auto scaling of axis, by Neil Dunn
+ * v1.2: fps (frames per second) option, by Mathias Petterson
+ * v1.3: Fix for divide by zero, by Paul Nikitochkin
+ * v1.4: Set minimum, top-scale padding, remove timeseries, add optional timer to reset bounds, by Kelley Reynolds
+ * v1.5: Set default frames per second to 50... smoother.
+ *       .start(), .stop() methods for conserving CPU, by Dmitry Vyal
+ *       options.interpolation = 'bezier' or 'line', by Dmitry Vyal
+ *       options.maxValue to fix scale, by Dmitry Vyal
+ * v1.6: minValue/maxValue will always get converted to floats, by Przemek Matylla
+ * v1.7: options.grid.fillStyle may be a transparent color, by Dmitry A. Shashkin
+ *       Smooth rescaling, by Kostas Michalopoulos
+ * v1.8: Set max length to customize number of live points in the dataset with options.maxDataSetLength, by Krishna Narni
+ * v1.9: Display timestamps along the bottom, by Nick and Stev-io
+ *       (https://groups.google.com/forum/?fromgroups#!topic/smoothie-charts/-Ywse8FCpKI%5B1-25%5D)
+ *       Refactored by Krishna Narni, to support timestamp formatting function
+ * v1.10: Switch to requestAnimationFrame, removed the now obsoleted options.fps, by Gergely Imreh
+ * v1.11: options.grid.sharpLines option added, by @drewnoakes
+ *        Addressed warning seen in Firefox when seriesOption.fillStyle undefined, by @drewnoakes
+ * v1.12: Support for horizontalLines added, by @drewnoakes
+ *        Support for yRangeFunction callback added, by @drewnoakes
+ * v1.13: Fixed typo (#32), by @alnikitich
+ * v1.14: Timer cleared when last TimeSeries removed (#23), by @davidgaleano
+ *        Fixed diagonal line on chart at start/end of data stream, by @drewnoakes
+ * v1.15: Support for npm package (#18), by @dominictarr
+ *        Fixed broken removeTimeSeries function (#24) by @davidgaleano
+ *        Minor performance and tidying, by @drewnoakes
+ * v1.16: Bug fix introduced in v1.14 relating to timer creation/clearance (#23), by @drewnoakes
+ *        TimeSeries.append now deals with out-of-order timestamps, and can merge duplicates, by @zacwitte (#12)
+ *        Documentation and some local variable renaming for clarity, by @drewnoakes
+ * v1.17: Allow control over font size (#10), by @drewnoakes
+ *        Timestamp text won't overlap, by @drewnoakes
+ * v1.18: Allow control of max/min label precision, by @drewnoakes
+ *        Added 'borderVisible' chart option, by @drewnoakes
+ *        Allow drawing series with fill but no stroke (line), by @drewnoakes
+ * v1.19: Avoid unnecessary repaints, and fixed flicker in old browsers having multiple charts in document (#40), by @asbai
+ * v1.20: Add SmoothieChart.getTimeSeriesOptions and SmoothieChart.bringToFront functions, by @drewnoakes
+ * v1.21: Add 'step' interpolation mode, by @drewnoakes
+ * v1.22: Add support for different pixel ratios. Also add optional y limit formatters, by @copacetic
+ * v1.23: Fix bug introduced in v1.22 (#44), by @drewnoakes
+ * v1.24: Fix bug introduced in v1.23, re-adding parseFloat to y-axis formatter defaults, by @siggy_sf
+ * v1.25: Fix bug seen when adding a data point to TimeSeries which is older than the current data, by @Nking92
+ *        Draw time labels on top of series, by @comolosabia
+ *        Add TimeSeries.clear function, by @drewnoakes
+ * v1.26: Add support for resizing on high device pixel ratio screens, by @copacetic
+ * v1.27: Fix bug introduced in v1.26 for non whole number devicePixelRatio values, by @zmbush
+ * v1.28: Add 'minValueScale' option, by @megawac
+ *        Fix 'labelPos' for different size of 'minValueString' 'maxValueString', by @henryn
+ * v1.29: Support responsive sizing, by @drewnoakes
+ * v1.29.1: Include types in package, and make property optional, by @TrentHouliston
+ * v1.30: Fix inverted logic in devicePixelRatio support, by @scanlime
+ * v1.31: Support tooltips, by @Sly1024 and @drewnoakes
+ * v1.32: Support frame rate limit, by @dpuyosa
+ * v1.33: Use Date static method instead of instance, by @nnnoel
+ *        Fix bug with tooltips when multiple charts on a page, by @jpmbiz70
+ * v1.34: Add disabled option to TimeSeries, by @TechGuard (#91)
+ *        Add nonRealtimeData option, by @annazhelt (#92, #93)
+ *        Add showIntermediateLabels option, by @annazhelt (#94)
+ *        Add displayDataFromPercentile option, by @annazhelt (#95)
+ *        Fix bug when hiding tooltip element, by @ralphwetzel (#96)
+ *        Support intermediate y-axis labels, by @beikeland (#99)
+ * v1.35: Fix issue with responsive mode at high DPI, by @drewnoakes (#101)
+ * v1.36: Add tooltipLabel to ITimeSeriesPresentationOptions.
+ *        If tooltipLabel is present, tooltipLabel displays inside tooltip
+ *        next to value, by @jackdesert (#102)
+ *        Fix bug rendering issue in series fill when using scroll backwards, by @olssonfredrik
+ *        Add title option, by @mesca
+ *        Fix data drop stoppage by rejecting NaNs in append(), by @timdrysdale
+ *        Allow setting interpolation per time series, by @WofWca (#123)
+ */
+
+;(function(exports) {
+
+  // Date.now polyfill
+  Date.now = Date.now || function() { return new Date().getTime(); };
+
+  var Util = {
+    extend: function() {
+      arguments[0] = arguments[0] || {};
+      for (var i = 1; i < arguments.length; i++)
+      {
+        for (var key in arguments[i])
+        {
+          if (arguments[i].hasOwnProperty(key))
+          {
+            if (typeof(arguments[i][key]) === 'object') {
+              if (arguments[i][key] instanceof Array) {
+                arguments[0][key] = arguments[i][key];
+              } else {
+                arguments[0][key] = Util.extend(arguments[0][key], arguments[i][key]);
+              }
+            } else {
+              arguments[0][key] = arguments[i][key];
+            }
+          }
+        }
+      }
+      return arguments[0];
+    },
+    binarySearch: function(data, value) {
+      var low = 0,
+          high = data.length;
+      while (low < high) {
+        var mid = (low + high) >> 1;
+        if (value < data[mid][0])
+          high = mid;
+        else
+          low = mid + 1;
+      }
+      return low;
+    }
+  };
+
+  /**
+   * Initialises a new <code>TimeSeries</code> with optional data options.
+   *
+   * Options are of the form (defaults shown):
+   *
+   * <pre>
+   * {
+   *   resetBounds: true,        // enables/disables automatic scaling of the y-axis
+   *   resetBoundsInterval: 3000 // the period between scaling calculations, in millis
+   * }
+   * </pre>
+   *
+   * Presentation options for TimeSeries are specified as an argument to <code>SmoothieChart.addTimeSeries</code>.
+   *
+   * @constructor
+   */
+  function TimeSeries(options) {
+    this.options = Util.extend({}, TimeSeries.defaultOptions, options);
+    this.disabled = false;
+    this.clear();
+  }
+
+  TimeSeries.defaultOptions = {
+    resetBoundsInterval: 3000,
+    resetBounds: true
+  };
+
+  /**
+   * Clears all data and state from this TimeSeries object.
+   */
+  TimeSeries.prototype.clear = function() {
+    this.data = [];
+    this.maxValue = Number.NaN; // The maximum value ever seen in this TimeSeries.
+    this.minValue = Number.NaN; // The minimum value ever seen in this TimeSeries.
+  };
+
+  /**
+   * Recalculate the min/max values for this <code>TimeSeries</code> object.
+   *
+   * This causes the graph to scale itself in the y-axis.
+   */
+  TimeSeries.prototype.resetBounds = function() {
+    if (this.data.length) {
+      // Walk through all data points, finding the min/max value
+      this.maxValue = this.data[0][1];
+      this.minValue = this.data[0][1];
+      for (var i = 1; i < this.data.length; i++) {
+        var value = this.data[i][1];
+        if (value > this.maxValue) {
+          this.maxValue = value;
+        }
+        if (value < this.minValue) {
+          this.minValue = value;
+        }
+      }
+    } else {
+      // No data exists, so set min/max to NaN
+      this.maxValue = Number.NaN;
+      this.minValue = Number.NaN;
+    }
+  };
+
+  /**
+   * Adds a new data point to the <code>TimeSeries</code>, preserving chronological order.
+   *
+   * @param timestamp the position, in time, of this data point
+   * @param value the value of this data point
+   * @param sumRepeatedTimeStampValues if <code>timestamp</code> has an exact match in the series, this flag controls
+   * whether it is replaced, or the values summed (defaults to false.)
+   */
+  TimeSeries.prototype.append = function(timestamp, value, sumRepeatedTimeStampValues) {
+	// Reject NaN
+	if (isNaN(timestamp) || isNaN(value)){
+		return
+	}  
+    // Rewind until we hit an older timestamp
+    var i = this.data.length - 1;
+    while (i >= 0 && this.data[i][0] > timestamp) {
+      i--;
+    }
+
+    if (i === -1) {
+      // This new item is the oldest data
+      this.data.splice(0, 0, [timestamp, value]);
+    } else if (this.data.length > 0 && this.data[i][0] === timestamp) {
+      // Update existing values in the array
+      if (sumRepeatedTimeStampValues) {
+        // Sum this value into the existing 'bucket'
+        this.data[i][1] += value;
+        value = this.data[i][1];
+      } else {
+        // Replace the previous value
+        this.data[i][1] = value;
+      }
+    } else if (i < this.data.length - 1) {
+      // Splice into the correct position to keep timestamps in order
+      this.data.splice(i + 1, 0, [timestamp, value]);
+    } else {
+      // Add to the end of the array
+      this.data.push([timestamp, value]);
+    }
+
+    this.maxValue = isNaN(this.maxValue) ? value : Math.max(this.maxValue, value);
+    this.minValue = isNaN(this.minValue) ? value : Math.min(this.minValue, value);
+  };
+
+  TimeSeries.prototype.dropOldData = function(oldestValidTime, maxDataSetLength) {
+    // We must always keep one expired data point as we need this to draw the
+    // line that comes into the chart from the left, but any points prior to that can be removed.
+    var removeCount = 0;
+    while (this.data.length - removeCount >= maxDataSetLength && this.data[removeCount + 1][0] < oldestValidTime) {
+      removeCount++;
+    }
+    if (removeCount !== 0) {
+      this.data.splice(0, removeCount);
+    }
+  };
+
+  /**
+   * Initialises a new <code>SmoothieChart</code>.
+   *
+   * Options are optional, and should be of the form below. Just specify the values you
+   * need and the rest will be given sensible defaults as shown:
+   *
+   * <pre>
+   * {
+   *   minValue: undefined,                      // specify to clamp the lower y-axis to a given value
+   *   maxValue: undefined,                      // specify to clamp the upper y-axis to a given value
+   *   maxValueScale: 1,                         // allows proportional padding to be added above the chart. for 10% padding, specify 1.1.
+   *   minValueScale: 1,                         // allows proportional padding to be added below the chart. for 10% padding, specify 1.1.
+   *   yRangeFunction: undefined,                // function({min: , max: }) { return {min: , max: }; }
+   *   scaleSmoothing: 0.125,                    // controls the rate at which y-value zoom animation occurs
+   *   millisPerPixel: 20,                       // sets the speed at which the chart pans by
+   *   enableDpiScaling: true,                   // support rendering at different DPI depending on the device
+   *   yMinFormatter: function(min, precision) { // callback function that formats the min y value label
+   *     return parseFloat(min).toFixed(precision);
+   *   },
+   *   yMaxFormatter: function(max, precision) { // callback function that formats the max y value label
+   *     return parseFloat(max).toFixed(precision);
+   *   },
+   *   yIntermediateFormatter: function(intermediate, precision) { // callback function that formats the intermediate y value labels
+   *     return parseFloat(intermediate).toFixed(precision);
+   *   },
+   *   maxDataSetLength: 2,
+   *   interpolation: 'bezier'                   // one of 'bezier', 'linear', or 'step'
+   *   timestampFormatter: null,                 // optional function to format time stamps for bottom of chart
+   *                                             // you may use SmoothieChart.timeFormatter, or your own: function(date) { return ''; }
+   *   scrollBackwards: false,                   // reverse the scroll direction of the chart
+   *   horizontalLines: [],                      // [ { value: 0, color: '#ffffff', lineWidth: 1 } ]
+   *   grid:
+   *   {
+   *     fillStyle: '#000000',                   // the background colour of the chart
+   *     lineWidth: 1,                           // the pixel width of grid lines
+   *     strokeStyle: '#777777',                 // colour of grid lines
+   *     millisPerLine: 1000,                    // distance between vertical grid lines
+   *     sharpLines: false,                      // controls whether grid lines are 1px sharp, or softened
+   *     verticalSections: 2,                    // number of vertical sections marked out by horizontal grid lines
+   *     borderVisible: true                     // whether the grid lines trace the border of the chart or not
+   *   },
+   *   labels
+   *   {
+   *     disabled: false,                        // enables/disables labels showing the min/max values
+   *     fillStyle: '#ffffff',                   // colour for text of labels,
+   *     fontSize: 15,
+   *     fontFamily: 'sans-serif',
+   *     precision: 2,
+   *     showIntermediateLabels: false,          // shows intermediate labels between min and max values along y axis
+   *     intermediateLabelSameAxis: true,
+   *   },
+   *   title
+   *   {
+   *     text: '',                               // the text to display on the left side of the chart
+   *     fillStyle: '#ffffff',                   // colour for text
+   *     fontSize: 15,
+   *     fontFamily: 'sans-serif',
+   *     verticalAlign: 'middle'                 // one of 'top', 'middle', or 'bottom'
+   *   },
+   *   tooltip: false                            // show tooltip when mouse is over the chart
+   *   tooltipLine: {                            // properties for a vertical line at the cursor position
+   *     lineWidth: 1,
+   *     strokeStyle: '#BBBBBB'
+   *   },
+   *   tooltipFormatter: SmoothieChart.tooltipFormatter, // formatter function for tooltip text
+   *   nonRealtimeData: false,                   // use time of latest data as current time
+   *   displayDataFromPercentile: 1,             // display not latest data, but data from the given percentile
+   *                                             // useful when trying to see old data saved by setting a high value for maxDataSetLength
+   *                                             // should be a value between 0 and 1
+   *   responsive: false,                        // whether the chart should adapt to the size of the canvas
+   *   limitFPS: 0                               // maximum frame rate the chart will render at, in FPS (zero means no limit)
+   * }
+   * </pre>
+   *
+   * @constructor
+   */
+  function SmoothieChart(options) {
+    this.options = Util.extend({}, SmoothieChart.defaultChartOptions, options);
+    this.seriesSet = [];
+    this.currentValueRange = 1;
+    this.currentVisMinValue = 0;
+    this.lastRenderTimeMillis = 0;
+    this.lastChartTimestamp = 0;
+
+    this.mousemove = this.mousemove.bind(this);
+    this.mouseout = this.mouseout.bind(this);
+  }
+
+  /** Formats the HTML string content of the tooltip. */
+  SmoothieChart.tooltipFormatter = function (timestamp, data) {
+      var timestampFormatter = this.options.timestampFormatter || SmoothieChart.timeFormatter,
+          lines = [timestampFormatter(new Date(timestamp))],
+          label;
+
+      for (var i = 0; i < data.length; ++i) {
+        label = data[i].series.options.tooltipLabel || ''
+        if (label !== ''){
+            label = label + ' ';
+        }
+        lines.push('<span style="color:' + data[i].series.options.strokeStyle + '">' +
+        label +
+        this.options.yMaxFormatter(data[i].value, this.options.labels.precision) + '</span>');
+      }
+
+      return lines.join('<br>');
+  };
+
+  SmoothieChart.defaultChartOptions = {
+    millisPerPixel: 20,
+    enableDpiScaling: true,
+    yMinFormatter: function(min, precision) {
+      return parseFloat(min).toFixed(precision);
+    },
+    yMaxFormatter: function(max, precision) {
+      return parseFloat(max).toFixed(precision);
+    },
+    yIntermediateFormatter: function(intermediate, precision) {
+      return parseFloat(intermediate).toFixed(precision);
+    },
+    maxValueScale: 1,
+    minValueScale: 1,
+    interpolation: 'bezier',
+    scaleSmoothing: 0.125,
+    maxDataSetLength: 2,
+    scrollBackwards: false,
+    displayDataFromPercentile: 1,
+    grid: {
+      fillStyle: '#000000',
+      strokeStyle: '#777777',
+      lineWidth: 1,
+      sharpLines: false,
+      millisPerLine: 1000,
+      verticalSections: 2,
+      borderVisible: true
+    },
+    labels: {
+      fillStyle: '#ffffff',
+      disabled: false,
+      fontSize: 10,
+      fontFamily: 'monospace',
+      precision: 2,
+      showIntermediateLabels: false,
+      intermediateLabelSameAxis: true,
+    },
+    title: {
+      text: '',
+      fillStyle: '#ffffff',
+      fontSize: 15,
+      fontFamily: 'monospace',
+      verticalAlign: 'middle'
+    },
+    horizontalLines: [],
+    tooltip: false,
+    tooltipLine: {
+      lineWidth: 1,
+      strokeStyle: '#BBBBBB'
+    },
+    tooltipFormatter: SmoothieChart.tooltipFormatter,
+    nonRealtimeData: false,
+    responsive: false,
+    limitFPS: 0
+  };
+
+  // Based on http://inspirit.github.com/jsfeat/js/compatibility.js
+  SmoothieChart.AnimateCompatibility = (function() {
+    var requestAnimationFrame = function(callback, element) {
+          var requestAnimationFrame =
+            window.requestAnimationFrame        ||
+            window.webkitRequestAnimationFrame  ||
+            window.mozRequestAnimationFrame     ||
+            window.oRequestAnimationFrame       ||
+            window.msRequestAnimationFrame      ||
+            function(callback) {
+              return window.setTimeout(function() {
+                callback(Date.now());
+              }, 16);
+            };
+          return requestAnimationFrame.call(window, callback, element);
+        },
+        cancelAnimationFrame = function(id) {
+          var cancelAnimationFrame =
+            window.cancelAnimationFrame ||
+            function(id) {
+              clearTimeout(id);
+            };
+          return cancelAnimationFrame.call(window, id);
+        };
+
+    return {
+      requestAnimationFrame: requestAnimationFrame,
+      cancelAnimationFrame: cancelAnimationFrame
+    };
+  })();
+
+  SmoothieChart.defaultSeriesPresentationOptions = {
+    lineWidth: 1,
+    strokeStyle: '#ffffff'
+  };
+
+  /**
+   * Adds a <code>TimeSeries</code> to this chart, with optional presentation options.
+   *
+   * Presentation options should be of the form (defaults shown):
+   *
+   * <pre>
+   * {
+   *   lineWidth: 1,
+   *   strokeStyle: '#ffffff',
+   *   fillStyle: undefined,
+   *   interpolation: undefined;
+   *   tooltipLabel: undefined
+   * }
+   * </pre>
+   */
+  SmoothieChart.prototype.addTimeSeries = function(timeSeries, options) {
+    this.seriesSet.push({timeSeries: timeSeries, options: Util.extend({}, SmoothieChart.defaultSeriesPresentationOptions, options)});
+    if (timeSeries.options.resetBounds && timeSeries.options.resetBoundsInterval > 0) {
+      timeSeries.resetBoundsTimerId = setInterval(
+        function() {
+          timeSeries.resetBounds();
+        },
+        timeSeries.options.resetBoundsInterval
+      );
+    }
+  };
+
+  /**
+   * Removes the specified <code>TimeSeries</code> from the chart.
+   */
+  SmoothieChart.prototype.removeTimeSeries = function(timeSeries) {
+    // Find the correct timeseries to remove, and remove it
+    var numSeries = this.seriesSet.length;
+    for (var i = 0; i < numSeries; i++) {
+      if (this.seriesSet[i].timeSeries === timeSeries) {
+        this.seriesSet.splice(i, 1);
+        break;
+      }
+    }
+    // If a timer was operating for that timeseries, remove it
+    if (timeSeries.resetBoundsTimerId) {
+      // Stop resetting the bounds, if we were
+      clearInterval(timeSeries.resetBoundsTimerId);
+    }
+  };
+
+  /**
+   * Gets render options for the specified <code>TimeSeries</code>.
+   *
+   * As you may use a single <code>TimeSeries</code> in multiple charts with different formatting in each usage,
+   * these settings are stored in the chart.
+   */
+  SmoothieChart.prototype.getTimeSeriesOptions = function(timeSeries) {
+    // Find the correct timeseries to remove, and remove it
+    var numSeries = this.seriesSet.length;
+    for (var i = 0; i < numSeries; i++) {
+      if (this.seriesSet[i].timeSeries === timeSeries) {
+        return this.seriesSet[i].options;
+      }
+    }
+  };
+
+  /**
+   * Brings the specified <code>TimeSeries</code> to the top of the chart. It will be rendered last.
+   */
+  SmoothieChart.prototype.bringToFront = function(timeSeries) {
+    // Find the correct timeseries to remove, and remove it
+    var numSeries = this.seriesSet.length;
+    for (var i = 0; i < numSeries; i++) {
+      if (this.seriesSet[i].timeSeries === timeSeries) {
+        var set = this.seriesSet.splice(i, 1);
+        this.seriesSet.push(set[0]);
+        break;
+      }
+    }
+  };
+
+  /**
+   * Instructs the <code>SmoothieChart</code> to start rendering to the provided canvas, with specified delay.
+   *
+   * @param canvas the target canvas element
+   * @param delayMillis an amount of time to wait before a data point is shown. This can prevent the end of the series
+   * from appearing on screen, with new values flashing into view, at the expense of some latency.
+   */
+  SmoothieChart.prototype.streamTo = function(canvas, delayMillis) {
+    this.canvas = canvas;
+    this.delay = delayMillis;
+    this.start();
+  };
+
+  SmoothieChart.prototype.getTooltipEl = function () {
+    // Create the tool tip element lazily
+    if (!this.tooltipEl) {
+      this.tooltipEl = document.createElement('div');
+      this.tooltipEl.className = 'smoothie-chart-tooltip';
+      this.tooltipEl.style.pointerEvents = 'none';
+      this.tooltipEl.style.position = 'absolute';
+      this.tooltipEl.style.display = 'none';
+      document.body.appendChild(this.tooltipEl);
+    }
+    return this.tooltipEl;
+  };
+
+  SmoothieChart.prototype.updateTooltip = function () {
+    if(!this.options.tooltip){
+     return; 
+    }
+    var el = this.getTooltipEl();
+
+    if (!this.mouseover || !this.options.tooltip) {
+      el.style.display = 'none';
+      return;
+    }
+
+    var time = this.lastChartTimestamp;
+
+    // x pixel to time
+    var t = this.options.scrollBackwards
+      ? time - this.mouseX * this.options.millisPerPixel
+      : time - (this.canvas.offsetWidth - this.mouseX) * this.options.millisPerPixel;
+
+    var data = [];
+
+     // For each data set...
+    for (var d = 0; d < this.seriesSet.length; d++) {
+      var timeSeries = this.seriesSet[d].timeSeries;
+      if (timeSeries.disabled) {
+          continue;
+      }
+
+      // find datapoint closest to time 't'
+      var closeIdx = Util.binarySearch(timeSeries.data, t);
+      if (closeIdx > 0 && closeIdx < timeSeries.data.length) {
+        data.push({ series: this.seriesSet[d], index: closeIdx, value: timeSeries.data[closeIdx][1] });
+      }
+    }
+
+    if (data.length) {
+      el.innerHTML = this.options.tooltipFormatter.call(this, t, data);
+      el.style.display = 'block';
+    } else {
+      el.style.display = 'none';
+    }
+  };
+
+  SmoothieChart.prototype.mousemove = function (evt) {
+    this.mouseover = true;
+    this.mouseX = evt.offsetX;
+    this.mouseY = evt.offsetY;
+    this.mousePageX = evt.pageX;
+    this.mousePageY = evt.pageY;
+    if(!this.options.tooltip){
+     return; 
+    }
+    var el = this.getTooltipEl();
+    el.style.top = Math.round(this.mousePageY) + 'px';
+    el.style.left = Math.round(this.mousePageX) + 'px';
+    this.updateTooltip();
+  };
+
+  SmoothieChart.prototype.mouseout = function () {
+    this.mouseover = false;
+    this.mouseX = this.mouseY = -1;
+    if (this.tooltipEl)
+      this.tooltipEl.style.display = 'none';
+  };
+
+  /**
+   * Make sure the canvas has the optimal resolution for the device's pixel ratio.
+   */
+  SmoothieChart.prototype.resize = function () {
+    var dpr = !this.options.enableDpiScaling || !window ? 1 : window.devicePixelRatio,
+        width, height;
+    if (this.options.responsive) {
+      // Newer behaviour: Use the canvas's size in the layout, and set the internal
+      // resolution according to that size and the device pixel ratio (eg: high DPI)
+      width = this.canvas.offsetWidth;
+      height = this.canvas.offsetHeight;
+
+      if (width !== this.lastWidth) {
+        this.lastWidth = width;
+        this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString());
+        this.canvas.getContext('2d').scale(dpr, dpr);
+      }
+      if (height !== this.lastHeight) {
+        this.lastHeight = height;
+        this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString());
+        this.canvas.getContext('2d').scale(dpr, dpr);
+      }
+    } else if (dpr !== 1) {
+      // Older behaviour: use the canvas's inner dimensions and scale the element's size
+      // according to that size and the device pixel ratio (eg: high DPI)
+      width = parseInt(this.canvas.getAttribute('width'));
+      height = parseInt(this.canvas.getAttribute('height'));
+
+      if (!this.originalWidth || (Math.floor(this.originalWidth * dpr) !== width)) {
+        this.originalWidth = width;
+        this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString());
+        this.canvas.style.width = width + 'px';
+        this.canvas.getContext('2d').scale(dpr, dpr);
+      }
+
+      if (!this.originalHeight || (Math.floor(this.originalHeight * dpr) !== height)) {
+        this.originalHeight = height;
+        this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString());
+        this.canvas.style.height = height + 'px';
+        this.canvas.getContext('2d').scale(dpr, dpr);
+      }
+    }
+  };
+
+  /**
+   * Starts the animation of this chart.
+   */
+  SmoothieChart.prototype.start = function() {
+    if (this.frame) {
+      // We're already running, so just return
+      return;
+    }
+
+    this.canvas.addEventListener('mousemove', this.mousemove);
+    this.canvas.addEventListener('mouseout', this.mouseout);
+
+    // Renders a frame, and queues the next frame for later rendering
+    var animate = function() {
+      this.frame = SmoothieChart.AnimateCompatibility.requestAnimationFrame(function() {
+        if(this.options.nonRealtimeData){
+           var dateZero = new Date(0);
+           // find the data point with the latest timestamp
+           var maxTimeStamp = this.seriesSet.reduce(function(max, series){
+             var dataSet = series.timeSeries.data;
+             var indexToCheck = Math.round(this.options.displayDataFromPercentile * dataSet.length) - 1;
+             indexToCheck = indexToCheck >= 0 ? indexToCheck : 0;
+             indexToCheck = indexToCheck <= dataSet.length -1 ? indexToCheck : dataSet.length -1;
+             if(dataSet && dataSet.length > 0)
+             {
+              // timestamp corresponds to element 0 of the data point
+              var lastDataTimeStamp = dataSet[indexToCheck][0];
+              max = max > lastDataTimeStamp ? max : lastDataTimeStamp;
+             }
+             return max;
+          }.bind(this), dateZero);
+          // use the max timestamp as current time
+          this.render(this.canvas, maxTimeStamp > dateZero ? maxTimeStamp : null);
+        } else {
+          this.render();
+        }
+        animate();
+      }.bind(this));
+    }.bind(this);
+
+    animate();
+  };
+
+  /**
+   * Stops the animation of this chart.
+   */
+  SmoothieChart.prototype.stop = function() {
+    if (this.frame) {
+      SmoothieChart.AnimateCompatibility.cancelAnimationFrame(this.frame);
+      delete this.frame;
+      this.canvas.removeEventListener('mousemove', this.mousemove);
+      this.canvas.removeEventListener('mouseout', this.mouseout);
+    }
+  };
+
+  SmoothieChart.prototype.updateValueRange = function() {
+    // Calculate the current scale of the chart, from all time series.
+    var chartOptions = this.options,
+        chartMaxValue = Number.NaN,
+        chartMinValue = Number.NaN;
+
+    for (var d = 0; d < this.seriesSet.length; d++) {
+      // TODO(ndunn): We could calculate / track these values as they stream in.
+      var timeSeries = this.seriesSet[d].timeSeries;
+      if (timeSeries.disabled) {
+          continue;
+      }
+
+      if (!isNaN(timeSeries.maxValue)) {
+        chartMaxValue = !isNaN(chartMaxValue) ? Math.max(chartMaxValue, timeSeries.maxValue) : timeSeries.maxValue;
+      }
+
+      if (!isNaN(timeSeries.minValue)) {
+        chartMinValue = !isNaN(chartMinValue) ? Math.min(chartMinValue, timeSeries.minValue) : timeSeries.minValue;
+      }
+    }
+
+    // Scale the chartMaxValue to add padding at the top if required
+    if (chartOptions.maxValue != null) {
+      chartMaxValue = chartOptions.maxValue;
+    } else {
+      chartMaxValue *= chartOptions.maxValueScale;
+    }
+
+    // Set the minimum if we've specified one
+    if (chartOptions.minValue != null) {
+      chartMinValue = chartOptions.minValue;
+    } else {
+      chartMinValue -= Math.abs(chartMinValue * chartOptions.minValueScale - chartMinValue);
+    }
+
+    // If a custom range function is set, call it
+    if (this.options.yRangeFunction) {
+      var range = this.options.yRangeFunction({min: chartMinValue, max: chartMaxValue});
+      chartMinValue = range.min;
+      chartMaxValue = range.max;
+    }
+
+    if (!isNaN(chartMaxValue) && !isNaN(chartMinValue)) {
+      var targetValueRange = chartMaxValue - chartMinValue;
+      var valueRangeDiff = (targetValueRange - this.currentValueRange);
+      var minValueDiff = (chartMinValue - this.currentVisMinValue);
+      this.isAnimatingScale = Math.abs(valueRangeDiff) > 0.1 || Math.abs(minValueDiff) > 0.1;
+      this.currentValueRange += chartOptions.scaleSmoothing * valueRangeDiff;
+      this.currentVisMinValue += chartOptions.scaleSmoothing * minValueDiff;
+    }
+
+    this.valueRange = { min: chartMinValue, max: chartMaxValue };
+  };
+
+  SmoothieChart.prototype.render = function(canvas, time) {
+    var nowMillis = Date.now();
+
+    // Respect any frame rate limit.
+    if (this.options.limitFPS > 0 && nowMillis - this.lastRenderTimeMillis < (1000/this.options.limitFPS))
+      return;
+
+    if (!this.isAnimatingScale) {
+      // We're not animating. We can use the last render time and the scroll speed to work out whether
+      // we actually need to paint anything yet. If not, we can return immediately.
+
+      // Render at least every 1/6th of a second. The canvas may be resized, which there is
+      // no reliable way to detect.
+      var maxIdleMillis = Math.min(1000/6, this.options.millisPerPixel);
+
+      if (nowMillis - this.lastRenderTimeMillis < maxIdleMillis) {
+        return;
+      }
+    }
+
+    this.resize();
+
+    this.lastRenderTimeMillis = nowMillis;
+
+    canvas = canvas || this.canvas;
+    time = time || nowMillis - (this.delay || 0);
+
+    // Round time down to pixel granularity, so motion appears smoother.
+    time -= time % this.options.millisPerPixel;
+
+    this.lastChartTimestamp = time;
+
+    var context = canvas.getContext('2d'),
+        chartOptions = this.options,
+        dimensions = { top: 0, left: 0, width: canvas.clientWidth, height: canvas.clientHeight },
+        // Calculate the threshold time for the oldest data points.
+        oldestValidTime = time - (dimensions.width * chartOptions.millisPerPixel),
+        valueToYPixel = function(value) {
+          var offset = value - this.currentVisMinValue;
+          return this.currentValueRange === 0
+            ? dimensions.height
+            : dimensions.height - (Math.round((offset / this.currentValueRange) * dimensions.height));
+        }.bind(this),
+        timeToXPixel = function(t) {
+          if(chartOptions.scrollBackwards) {
+            return Math.round((time - t) / chartOptions.millisPerPixel);
+          }
+          return Math.round(dimensions.width - ((time - t) / chartOptions.millisPerPixel));
+        };
+
+    this.updateValueRange();
+
+    context.font = chartOptions.labels.fontSize + 'px ' + chartOptions.labels.fontFamily;
+
+    // Save the state of the canvas context, any transformations applied in this method
+    // will get removed from the stack at the end of this method when .restore() is called.
+    context.save();
+
+    // Move the origin.
+    context.translate(dimensions.left, dimensions.top);
+
+    // Create a clipped rectangle - anything we draw will be constrained to this rectangle.
+    // This prevents the occasional pixels from curves near the edges overrunning and creating
+    // screen cheese (that phrase should need no explanation).
+    context.beginPath();
+    context.rect(0, 0, dimensions.width, dimensions.height);
+    context.clip();
+
+    // Clear the working area.
+    context.save();
+    context.fillStyle = chartOptions.grid.fillStyle;
+    context.clearRect(0, 0, dimensions.width, dimensions.height);
+    context.fillRect(0, 0, dimensions.width, dimensions.height);
+    context.restore();
+
+    // Grid lines...
+    context.save();
+    context.lineWidth = chartOptions.grid.lineWidth;
+    context.strokeStyle = chartOptions.grid.strokeStyle;
+    // Vertical (time) dividers.
+    if (chartOptions.grid.millisPerLine > 0) {
+      context.beginPath();
+      for (var t = time - (time % chartOptions.grid.millisPerLine);
+           t >= oldestValidTime;
+           t -= chartOptions.grid.millisPerLine) {
+        var gx = timeToXPixel(t);
+        if (chartOptions.grid.sharpLines) {
+          gx -= 0.5;
+        }
+        context.moveTo(gx, 0);
+        context.lineTo(gx, dimensions.height);
+      }
+      context.stroke();
+      context.closePath();
+    }
+
+    // Horizontal (value) dividers.
+    for (var v = 1; v < chartOptions.grid.verticalSections; v++) {
+      var gy = Math.round(v * dimensions.height / chartOptions.grid.verticalSections);
+      if (chartOptions.grid.sharpLines) {
+        gy -= 0.5;
+      }
+      context.beginPath();
+      context.moveTo(0, gy);
+      context.lineTo(dimensions.width, gy);
+      context.stroke();
+      context.closePath();
+    }
+    // Bounding rectangle.
+    if (chartOptions.grid.borderVisible) {
+      context.beginPath();
+      context.strokeRect(0, 0, dimensions.width, dimensions.height);
+      context.closePath();
+    }
+    context.restore();
+
+    // Draw any horizontal lines...
+    if (chartOptions.horizontalLines && chartOptions.horizontalLines.length) {
+      for (var hl = 0; hl < chartOptions.horizontalLines.length; hl++) {
+        var line = chartOptions.horizontalLines[hl],
+            hly = Math.round(valueToYPixel(line.value)) - 0.5;
+        context.strokeStyle = line.color || '#ffffff';
+        context.lineWidth = line.lineWidth || 1;
+        context.beginPath();
+        context.moveTo(0, hly);
+        context.lineTo(dimensions.width, hly);
+        context.stroke();
+        context.closePath();
+      }
+    }
+
+    // For each data set...
+    for (var d = 0; d < this.seriesSet.length; d++) {
+      context.save();
+      var timeSeries = this.seriesSet[d].timeSeries;
+      if (timeSeries.disabled) {
+          continue;
+      }
+
+      var dataSet = timeSeries.data,
+          seriesOptions = this.seriesSet[d].options;
+
+      // Delete old data that's moved off the left of the chart.
+      timeSeries.dropOldData(oldestValidTime, chartOptions.maxDataSetLength);
+
+      // Set style for this dataSet.
+      context.lineWidth = seriesOptions.lineWidth;
+      context.strokeStyle = seriesOptions.strokeStyle;
+      // Draw the line...
+      context.beginPath();
+      // Retain lastX, lastY for calculating the control points of bezier curves.
+      var firstX = 0, firstY = 0, lastX = 0, lastY = 0;
+      for (var i = 0; i < dataSet.length && dataSet.length !== 1; i++) {
+        var x = timeToXPixel(dataSet[i][0]),
+            y = valueToYPixel(dataSet[i][1]);
+
+        if (i === 0) {
+          firstX = x;
+          firstY = y;
+          context.moveTo(x, y);
+        } else {
+          switch (seriesOptions.interpolation || chartOptions.interpolation) {
+            case "linear":
+            case "line": {
+              context.lineTo(x,y);
+              break;
+            }
+            case "bezier":
+            default: {
+              // Great explanation of Bezier curves: http://en.wikipedia.org/wiki/Bezier_curve#Quadratic_curves
+              //
+              // Assuming A was the last point in the line plotted and B is the new point,
+              // we draw a curve with control points P and Q as below.
+              //
+              // A---P
+              //     |
+              //     |
+              //     |
+              //     Q---B
+              //
+              // Importantly, A and P are at the same y coordinate, as are B and Q. This is
+              // so adjacent curves appear to flow as one.
+              //
+              context.bezierCurveTo( // startPoint (A) is implicit from last iteration of loop
+                Math.round((lastX + x) / 2), lastY, // controlPoint1 (P)
+                Math.round((lastX + x)) / 2, y, // controlPoint2 (Q)
+                x, y); // endPoint (B)
+              break;
+            }
+            case "step": {
+              context.lineTo(x,lastY);
+              context.lineTo(x,y);
+              break;
+            }
+          }
+        }
+
+        lastX = x; lastY = y;
+      }
+
+      if (dataSet.length > 1) {
+        if (seriesOptions.fillStyle) {
+          // Close up the fill region.
+          if (chartOptions.scrollBackwards) {
+            context.lineTo(lastX, dimensions.height + seriesOptions.lineWidth);
+            context.lineTo(firstX, dimensions.height + seriesOptions.lineWidth);
+            context.lineTo(firstX, firstY);
+          } else {
+            context.lineTo(dimensions.width + seriesOptions.lineWidth + 1, lastY);
+            context.lineTo(dimensions.width + seriesOptions.lineWidth + 1, dimensions.height + seriesOptions.lineWidth + 1);
+            context.lineTo(firstX, dimensions.height + seriesOptions.lineWidth);
+          }
+          context.fillStyle = seriesOptions.fillStyle;
+          context.fill();
+        }
+
+        if (seriesOptions.strokeStyle && seriesOptions.strokeStyle !== 'none') {
+          context.stroke();
+        }
+        context.closePath();
+      }
+      context.restore();
+    }
+
+    if (chartOptions.tooltip && this.mouseX >= 0) {
+      // Draw vertical bar to show tooltip position
+      context.lineWidth = chartOptions.tooltipLine.lineWidth;
+      context.strokeStyle = chartOptions.tooltipLine.strokeStyle;
+      context.beginPath();
+      context.moveTo(this.mouseX, 0);
+      context.lineTo(this.mouseX, dimensions.height);
+      context.closePath();
+      context.stroke();
+    }
+    this.updateTooltip();
+
+    // Draw the axis values on the chart.
+    if (!chartOptions.labels.disabled && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max)) {
+      var maxValueString = chartOptions.yMaxFormatter(this.valueRange.max, chartOptions.labels.precision),
+          minValueString = chartOptions.yMinFormatter(this.valueRange.min, chartOptions.labels.precision),
+          maxLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(maxValueString).width - 2,
+          minLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(minValueString).width - 2;
+      context.fillStyle = chartOptions.labels.fillStyle;
+      context.fillText(maxValueString, maxLabelPos, chartOptions.labels.fontSize);
+      context.fillText(minValueString, minLabelPos, dimensions.height - 2);
+    }
+
+    // Display intermediate y axis labels along y-axis to the left of the chart
+    if ( chartOptions.labels.showIntermediateLabels
+          && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max)
+          && chartOptions.grid.verticalSections > 0) {
+      // show a label above every vertical section divider
+      var step = (this.valueRange.max - this.valueRange.min) / chartOptions.grid.verticalSections;
+      var stepPixels = dimensions.height / chartOptions.grid.verticalSections;
+      for (var v = 1; v < chartOptions.grid.verticalSections; v++) {
+        var gy = dimensions.height - Math.round(v * stepPixels);
+        if (chartOptions.grid.sharpLines) {
+          gy -= 0.5;
+        }
+        var yValue = chartOptions.yIntermediateFormatter(this.valueRange.min + (v * step), chartOptions.labels.precision);
+        //left of right axis?
+        intermediateLabelPos =
+          chartOptions.labels.intermediateLabelSameAxis
+          ? (chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(yValue).width - 2)
+          : (chartOptions.scrollBackwards ? dimensions.width - context.measureText(yValue).width - 2 : 0);
+
+        context.fillText(yValue, intermediateLabelPos, gy - chartOptions.grid.lineWidth);
+      }
+    }
+
+    // Display timestamps along x-axis at the bottom of the chart.
+    if (chartOptions.timestampFormatter && chartOptions.grid.millisPerLine > 0) {
+      var textUntilX = chartOptions.scrollBackwards
+        ? context.measureText(minValueString).width
+        : dimensions.width - context.measureText(minValueString).width + 4;
+      for (var t = time - (time % chartOptions.grid.millisPerLine);
+           t >= oldestValidTime;
+           t -= chartOptions.grid.millisPerLine) {
+        var gx = timeToXPixel(t);
+        // Only draw the timestamp if it won't overlap with the previously drawn one.
+        if ((!chartOptions.scrollBackwards && gx < textUntilX) || (chartOptions.scrollBackwards && gx > textUntilX))  {
+          // Formats the timestamp based on user specified formatting function
+          // SmoothieChart.timeFormatter function above is one such formatting option
+          var tx = new Date(t),
+            ts = chartOptions.timestampFormatter(tx),
+            tsWidth = context.measureText(ts).width;
+
+          textUntilX = chartOptions.scrollBackwards
+            ? gx + tsWidth + 2
+            : gx - tsWidth - 2;
+
+          context.fillStyle = chartOptions.labels.fillStyle;
+          if(chartOptions.scrollBackwards) {
+            context.fillText(ts, gx, dimensions.height - 2);
+          } else {
+            context.fillText(ts, gx - tsWidth, dimensions.height - 2);
+          }
+        }
+      }
+    }
+
+    // Display title.
+    if (chartOptions.title.text !== '') {
+      context.font = chartOptions.title.fontSize + 'px ' + chartOptions.title.fontFamily;
+      var titleXPos = chartOptions.scrollBackwards ? dimensions.width - context.measureText(chartOptions.title.text).width - 2 : 2;
+      if (chartOptions.title.verticalAlign == 'bottom') {
+        context.textBaseline = 'bottom';
+        var titleYPos = dimensions.height;
+      } else if (chartOptions.title.verticalAlign == 'middle') {
+        context.textBaseline = 'middle';
+        var titleYPos = dimensions.height / 2;
+      } else {
+        context.textBaseline = 'top';
+        var titleYPos = 0;
+      }
+      context.fillStyle = chartOptions.title.fillStyle;
+      context.fillText(chartOptions.title.text, titleXPos, titleYPos);
+    }
+
+    context.restore(); // See .save() above.
+  };
+
+  // Sample timestamp formatting function
+  SmoothieChart.timeFormatter = function(date) {
+    function pad2(number) { return (number < 10 ? '0' : '') + number }
+    return pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds());
+  };
+
+  exports.TimeSeries = TimeSeries;
+  exports.SmoothieChart = SmoothieChart;
+
+})(typeof exports === 'undefined' ? this : exports);
+
diff --git a/data/smoothie.js.gz b/data/smoothie.js.gz
new file mode 100644
index 0000000000000000000000000000000000000000..78f2b22d1c88af45e39a0cdfb6bfd9a75a1fa8ce
GIT binary patch
literal 12011
zcmb2|=HN)JnViVLT%4PqUy_lTs+U#FuqHaYSlWEkUzxvLIYE&V4P9+>)Hmi-I=>by
zUK~ELs3utNy6yxP&oyn$JDVj>Zn^&d@4P&Q&q|urv8#(BAMsqc`{MXZwhMReii-c0
zE?jq9Yul@W>~it{M>cv+TRHEn@kOcaM-SfncaQI1^&P?gCSH4{2CmWJUH$(8ht#)h
z_c=>m#l=Wfo_?J1Vyl<<t*G3jg==T54(XkiH0_i{@Z`Q3%e**^MIZ0<NnfRGq@1!x
zq|-}RZAPG4>6*tgCnWKl?2uziFAmt0HtX)`ki<VO9@%FjyeEhV8=n)`I+x<6v)n2u
zY|EydLihRq_PoD+CVf$g3rnfai_!}1cco_Lm1;q$n?w3Leb}3i1==Zk9(wJeza`!2
za%AQj=?Uj_r#Uf|A35anWlEiPT>PYdrQGMTh8JCGr=(Y8yi}aM(0TPu)0G>vk{&v^
zBs1=L5#%Q4)*}>@_v~uw%aeca=)8J;QRk|Of3iWCO>WWxyJx@kP0zjHJUe5ShSB#e
z$2NAV&zhFj9DnD*UYVKJM^6{Y3C-pSvJO0<<e8cI|J1ouk<N*qUNINT3Y%6*g-x3z
zVb*M)A=!Ck$(ijtALUJW{B@;7+}yi+-tzjn)@!U|53m2`@8^Hy`(8!6`ul?Uzs=XL
zzjueDN8*cK{y(Ac^hM=Ar~g=7UwKJz`Tt^D3t7ISR~uG*)n9l1_wtH6`)%y%i!bf}
z6T9comDAcsT;(q^?lKQQb2<3+e7??mM_)epVQTj+?(6yA=Jll=*CPKvo1`&6me<8U
z<~y7D-^b?9_crdRGv2XJYMxVvaKOGd@$+~Oy!i6y%co`B$12qP{vTmCthsA#ub$_!
z?mOFQi3tlH{do0jz2pDy*UcY&z54T{rrbWO)|$QH&zJW}hn3gddmXRvr%HV9egDd9
zq8uNUGM5Mk@2Rcz^6$*%|KFGYD}4Hvd5>40p3P`iw&Bh}U&G(I^%b`S_d3>l%(9e@
ziT&W^xqC**(Gr8NpRNchoxRT6qWI<K*$-P5X!`k8KUZMSjy2=Gb@$e-zb}{Tew-|!
z*VE{)S;!M}V$BV~&pO_G<>hPZ4WB)aoqVn=^H17_L#NplybQMQ{`>CU!W|4(4ERz-
zm?p|CHFABxwD#2#gR1`lUy59MWlwjo9gcj;o?H20>7|JlCm-MFx3v(t%pzdo`_!p$
zg2E2X1Gf&T@WmLsc1jAp#H72qr}^gjx*CmhU)WpHxQ^_&EbQ8Bzv{||HQ6)W9|hfi
zb*AU}b}OH%B8@gJVQz=#O7Q|WRIOE333bj9=qP2#<K}wi<E%DSNq|vva_4l8LJ$2Q
zCbk6%9=$$t{&#-;V=!8|RANPH&LYnXCxVX`Em|g^DXiVC8sz_d*WGmmVj-F4Gu%4#
z4%wfLYDoQ)Iw?##gLA^%h&c0Q&PS{-eg61WdfG#l1=ID8&i7WRa6Ec4Z@1J;RbQVc
z8-9A^^j~pbdnvbRR_+Y%-1?cvoa%lAyX{okxcq{BcK%%xoxjhWFPZS)*L`+3+hI=6
zyQi1BBt@EC@2oT_{$SnPb7qd&`pf-U3-eC?JFPKk-V^=yR+Y^m{<}^jT@+xR5^+NH
z;YtxnqZPglJ%)n)K2cNGT^BvPbIU>1JN@07Mk_u?rTa49jV@gH@W(_?AGw(cm*V$|
zpX65x`p6Tk_{*ZbRqWSdp~8I<2lo{G^*B6Ph<Wer2|nsVA%-(2rG~Clui#ts+~+*!
z1SRdGK`~xW)X)FCxzOUGS?7kZvI{<X0*({-^&GCat2GPg-TLxln(+mL;BxC*Q%>g!
zCPg?L(=n}Na+Naf4k^zO+x=lx&BovTnOS1*p7Pf>-+o!xsAtU=S62RS*Y;P}H$Q&T
z|F7Ra{_T4io4+4ldhUK=*s#0bc=Dk&XO|XqCVp-airUFl^rH2QMB=i_Ge=zyh&Wsj
z_$75d_Qv1%K22K}gWn$|SwlE(b7m`=eb<<<rc-2RXn@g%Hqp=4SEVloeGy+di(TwD
z?|!E`zsbcdEnm-bvaHZyZoIE{;H1TY6pgP{!ZzE)c;07Djc5ziQrkTLWr*)tb`gai
zZw#JxWrpn$S6H+xVf!>@PL2&SRdWMhocCR2FLkA8qVOm0OQFy8*p23`G+v*=IKSU*
z(wtOb-be2rOcUAT)06S{!;b}T+nD}xrU|MgYB+prb1ah4^prcm-M{4QkDn7v10>db
zJ$x|PKTE0cvGIe>9_@Fpe)?^hW5^&J!1-8s(uA1_J$FJ2N?T-(iqBi(rDvx5G&6Q;
z;U<P({rOLQ9B2HEZ#HP#bVOpyWzk5tl{=44x+A2O&DH1KU3IB0!&lsjEjxv=?6-wT
zchx4IADi6@9=u##b8G{*hv!)p`QnE!w(u2l+~nl!IUkg~qVdViRBwaXt9D(t^x8RV
zosUqEmaFhvjy1>E*xWX}EIZjxgI7%93D^6LJ+Zd>YL!i=WPkICm+Y^dlI(Ln;wZc5
z0s-}{v7CY%(-yj<JedB-Z&_5IfV@NT&V-x=d&D0WZo9KY?Ma8+)z?#^7A*BR;GwCe
zBp-1o-DAUdRZ$C{Q&-p(wsvkeUM6@lMfiMtc!i&){TrvWihmvp>-?`q+zz;!b3{O*
zr25FIzru?*e#w3RV(O%f!zs_~J)iDnmjC8qCV8;uKpWSxB^MgrcZs=Pj8eUjH6g94
z+|8fCd!rQd)qwX0Svr^aJkzm#>M-NbvF!nRVV#rOA2DSr9!(1?3f<&zbg}S^^QpX2
zVrC0B9GaZKn(1zovV*z7_DaB&oiWeK0yU=ey*zMv*OM@5Gp~bRkIh+{@>-y$?64O9
zH`(R6vsb1>U+GrP@w>XLl09$U+60cWO~FS+-I?~fD5+{~<DMN6tS>)3uUh7k?3AZB
z5-zJB=Q5Dm@#9xnOG7;8w`S&``;&dYSyz0zaOe0Go${ES+z-6hS}k7H9gw`!`Dm49
z^oppxJz?k5PXFI}`M|TxlWoPakN2qS$O;vIbY7yZQEBhKX@N5T4xYplF}p5h%vrw1
zY1RvgxJ5p*ZLL=<V!bmv@VM*Lr7jIzt%r0&Ui;1F4yg<4+p?xTT=c$#2)FsoC8y_y
zukE#2Z~y6?npknpkA=&uZ(Rw`>EC71>SZSReR@03m(AYZZ6aM#YF8%roizO@VbE>8
zYB}>BJ-3~_l0P1VzrVTgmt4@qeX-3GT4$ZCwp_Q|><yF0>g&hy%<s%<6!y~dOcTkz
zUGads`u~BzcI73fljeV4<Fg|mwrRqWT{m79OujY$V%VnleC{ir1@y?-`CD^DCD<ff
zRp?GTt#nA{p!B3)rd?&dtM=S5dS|)y^@hhCXMFl4r_S2-_1U+&{w-}QLL1N2?VNW;
zqRAq^HF3+G`_>hQF0EbnvT$qL;#E^)TrF)rWO;m2-DB3n&a!8t*PII)-ke2mw$}?t
zE>PY1V4=O{W^1lO8EH||HC}ngIu$%#D{bvOH>Gaj!<p(|*QXd>lCQtXeaWWK&-CIn
zqoRf_RXNp9SAYETJ!V<R9oyUa=1Ky6whoq#jy|*J%?dwdF-`ut)}k*T-YihAtN6CV
z^6t5_wevSrEmSL6SNX0mL3paVFO%^0Mx}cmBHh_j`yOgbRo(D-oLN2B&9lAOwop`D
zJ8XqVU{SG3kfiG8>Z!FX8Vx7ThbkT9FG*EptdrejDW$wLKk(YoHK)?wCUE!fxpnJ~
z?CCJ(?jrH4M|phJum4T!C}i9cf8i6u^XUg(sVx5Hy*lo4VEg(DHJ{eb3GDc{#cn2_
z&D|cqzpdA8TsJLcmj1uVNo!e?_U8-F?g}W{FI+sM=c!#pIv0zz5vSy#OqcfBY1*|n
zf_6ULl+p8u=cqtO((Gsad3DAUx2nxcT0iO4?Y7-1R@pm_HJ#&>3M$w%o1vR!?&Sc1
zix)yJY8rjqJ7qdg!n$R7KhN|^%vj=W&1kh=q$q6X*@C@#o;4R^PnYGN{qi=VDD~{a
zO}n>#-6Q%b%DYy<e{=4~%xv$DyRpY6>xF(@uuFyYVd2L&EB<`G|6gxXGWX_<vvmBE
zZ@idye9NtcI@w$2cb`AsY4xh~Bilw6qxsu1bT_D9tBz3K@^qT!Wy$cSZ+Gui_pGmF
zdF{X81zV6>mSAdc;_vexU%OiFdhM6+;mgIv+vW>cge<$aN_F-AghltacLzSLQ2Y|I
zW!2(c#hL5hJ<gt=7Nu|{FZ@{e^IbQmtU0sA?TKvK-u&*xi<LL#{zyyQeRsyrQ!~{?
zKkzMizjS5Hx^t_uFP*g#(w;KUd|549gyPN`S<_9K*Kcmx`f1<hQ)~7fTKlb%`F(zb
z`_#Oj<v+Cle47`&^!<sNcOTYI?Av%)a*>S8dF_3#xB`zY3VQp*SCIWw-10+n4u0z>
zRM@ekzPreL5wG~OJr5E?g@v}Vnnk(i9Z<G^@YLI)%CN(xc_{;9(g!}{d&#_SL;qC-
zpHo&1Z?jn0d3*7sDrZ-T$EMByV|_j!ZDz3fn(S*Hc<WN7@Zkg9hCydf8wKvU@s$1Q
zVc(oewbW_L_b9e34}bGGNK|iY#&gA}#Onul+U3cIJ=^1uR{8SZpC@&k<pTe^&OhXL
zofh=ygFS<_Xz<pg^4gnBd6j8mG2Wr?G&;7Q-k04`w3YF%eAn^n>S~uMZOnWoS{o;x
zdYAcLxZ~k<i}HlrmqjAK`W88S+Z=x+ZzI?D1+x;Gmr3l_+O2kJlJAuW|E-I%V?}1R
zItQOB*|y?+`$q9-L%*v``!Dh`nYgKM{gWd8U%Wly>6K~IH7oRDv&x$1l$QMXvNQN@
za%FH;aQUG}Hg*Pc7cH<63g+xzyXTQ?R5yFQTKFB09Vg7C&tA*iSGI1`tEb10T>j*$
zW34yeBQ<2+#g#jDpI<xU=)ubE7vfx9a(s=PZytDW(jT^YK`_HcOQHAo!o%WXrpt7C
z-}_e@=x8JUZ=YoT%YUaFCkK?*%zVN(Tl!O}w#wggxy+vBs^6xnNLxv-c<`+&nfb_z
zL&x6-+?+h~gxa1JS{DQNEPR+eQ}~*|(MT;}yMN`|ORY9*&1$n272g@Z!SU#q*E$Dh
zT1OmKuHd{9a4A~yZc}tt5ku}n*>^?Do2`t?q#K{TvTa-z$Q?H2)wk{?*G#UoCBD{L
zwCuw=rK}~hc8l)X<+XiQR@}AJb=PIjKK(Fd(y8aBU*fKaZ|{rS`HuI?o%e2E8E(f&
zrSr0yD`ZDFKP^t=*ufi9+pMa!al=85vx3<{%4S@O>eu@Bo{%@Is6GC6(w)C=CHh3A
z?fM;#J^6fJDnQ+6YWJyJ$#jL}(iaCGhR<EZx<X`;+5O0-XS??AGr04tyU|O;lz-!w
zBR~DcFKt*I9i|x6@pPxR@2h5+%c7chZ~ZjjjTTF@?0upmGwt`Aj$EnOdgUFN+%vV_
zh#oIdJo;iG(+r0dtg+Ifu1vAg!S`NllV@Y7pBvcu*Xf(zje@*5!=JCq3$`-mzq~Pj
zpYro-V)fIe+}cuFcs}OtjbmHh#}sb;dN*Bh+n&>WS8NrWpOk!3cqjU5d(V|;{VvQq
z*9z>|CAG!w&{02)$2FZRk9vnJee}M5{c-be!TPSQhXgFD($y_W7#nAqYg+#{eUWUS
zcK_PUs0q0zWK>p`wr{<<Bk`xs-M=fY#j%@is<<9fd_(q!MO^zHfr__Q9y?668+-C6
zai+?<F>q<T*S+^uC3o(jO$Xm6TC?9^yqWkecptZfOGo$1rt{x4<nOP_**)V*L)ga*
z*0cK<4r$L;{wBu9CjM>ZuFGoMxBl65YWu-&#lcTs7qL}__^x&~OWFBmqi1l@e+FBT
zn(bbySMykQaV!0~^+a^mHu?CclA?}xIHv~h-TfkW*48JJpOoFZ<x`!VJN4<~9rHf@
zdzf2#BG%)}B0HrVzSXVKGtF12bg#Ltvhwk-4lU2=Dc71LWldvUCkuyLiFG_{5^6E|
zUFvRP?cG$ma)IO0Ny<&N`h2r59(i)Bb6NCnFD2$B%!`ti*4QhXdVO*|6T_NSqdotu
z>eo9u79W~Ue|Woe{oJV?SEls2e?C*XdDp!eEYT~k+?>wA!0xVP!coG_Q^MaDcqnzt
z?{$v%rTo|NvFn+c2DVRsm9Rs-`7HOvfOJWzsSo?*riL~$PuE(zpK-&tQ<bk3HW}^N
zyYaw{E#KF@{eJDuhpg&1kJ?(kn-ovJJT<rWy=~CMo$tHqCnfK=`)t#!n(1ogB{#)m
z>Q_IiOSpcWHOsj6c&?t|{Wx*+jwjB0=KjBwXUB3VajSaTTmC)k1TF>yH>~g8!1N@b
zeuiJ}<+x=``ZIg^ALge|RzG=t^F!$!&WHB;+OPY0{Z910OGRh*UHSI<eel7CUAK2Q
z9@r`^e(>tkzX$%^>rWL<7O2?2`+lz0xBXgNzfafec<p6=sVH1fzm0vnf5gFs2|m^$
z^R?A4eHA}{*s!}`wyV6-$2S3QO26~ZTNi6?K4o{(m7OM{!UvSf!}s1PT=4fI!_kx%
zHo|E(=YpeOP3Nmx7kcV?NBg6M{K;)67l%BPaI{UicwnW=85gA-_tOq{=O)ipsd~XT
zOG-VK_tnwd>>c8a&7X|EKiSp4V@cMH_@v@wrT9iMv0WEFMrJUtkm+=~Ja^O7)*Odf
z+_EW0FA6^1k*&NT(O+kp-PezQ!xBQ~*i4(tab(HevpnCUwtA(VKP*$8_4>#Z`D<O8
z_BFF-+|aGtJLA*g>s^t1H^ivtf0g<$n`Qld<*@Js`6#E-TjA@L&UAnH;^^*Mu~!ps
z#_zf2x$@eAJ|_DWkE152{&oF(;?7iI4)LQgu9byyuB-p~w;VoLyi)J+)Ti&y#Vqs+
zcq)~4_<PQ#pz!$L%yk}JcWUP>PFXvDztDE4q&Tr-%bGSNyq)G4dLXBIH(NqU>`$#b
zVRF0XzH3|I|MJSYT}K|gw^W~Xs`S{Z%ue0Ls>PjO0`^qzw`AYf+)=KV{H`=6*;MDx
z;VJbA#zyy|Z57*ZxHJB@Ggw%5>+pw%!7Cpu`aX5Vn~&$ewY3(e8<#lfp1hL6aj$Y$
znd>68W6yZPCzR@1e>Z95n6$xXk&x-S<aGjH#s5VrYb~FkaMQ}kwMik@*~V(>Jq4*+
z_O9EehrcqJuK95BLKJ6r+EaEJmn)NFEOIno7WVF#UsLepM)b?sDO{O5Q$(LgSZ&`V
z@>e=yYVL>D57*8+?O3w?ic?eWfsDwHhqs<yz14Zwp6!{HiJ|XK?1+e*)^>LD;?U2s
z2hWDx)Vg%_;pRuX9v*v?n7e*|-KX5_5U%yM`rD+HmUL|9O-nugxVbEA;ez*aJM<=T
zS!PtMu%GnYHrK{(@`tmkHzGu2txNpa{hm%xk`LJ!m-FICWGI)YO7SYSY|m5LN){HP
z9P3uD-eFb{pB>TlYU`X8f{A_3I|FQaK8jvkCA+gZxmVINIO22ET(d9UX{T!PyWS^p
zmI~))ux{VHhJ8u?_DA)HOBBCUO`I3#7Wd+0OBCbv0{)*C#+<6j_Lo$Xr>kwh{^Hu!
z4;72+9lM{fx~`s*?Pxs3WTohrbMCpm)eEaTK7P6^zT&r^!(071DV)|dW{xNCo6d}S
zwdXr~gtX9cw=eZa?PrSyuJo5$>hQR>eY=_K;@rSh8$RysTIF})#jNTbLJCWZmL9g4
z&{`aItGQk6cF%7~*|)v2h1!-^ma3K2JETqBW_>wP?`UNIZGLsuo%yTQZJ3t%u6oC&
zl9%QS!aj0t`M;}uPw{<YF5BQsTb*}k%`my86Sd!S(x)RXRhJm|e3RJqjrr;(@%P$B
zE~cqFYo7c((dpA=z2rju>BT8E>fA0*zFpk>TJh8-eSO6knebcw-ur5r9&hXE5Zf2Z
z6nk-zerQ+gqQ4nAGWO+HH*K8%@Zr9usDwZ5YgxZ8_|)$G=ix#z=JL-j_itv$Z2q0`
zpjgmu@`K-3u71B2H|6=R>Zy!h)-GdQ-NVPBzvED}af_<X{_sQ0rv!VN{;a*c_*nBh
zkK~76)|4IdS--N(YE^w+&%ZbR0!JfL7ytP6?9m*Dv-LA=m32PUJxzT6|Mc!#&&>Li
z|Hkf5TD)3>L72t!>RkJVMG=$Ubccu@-?BDl$&Q$nsv8u-nKoLyX<|uwxj+3^YTB;!
zv_@6c`K$F-h3F(c-@1JH^5wr@&J)zVp7&9;=iJOMix1XaiCFpLRa$$lqGP9!y#2d9
zp0=NSe7Ww;{GhrcskS}ji{9dc`m2my@b3|74F5Z0&#O}l>k0zAz6%`vD)5YZLer7d
zx+tMvS!!24+bL}j3eMfY|GkR2w`DW$<$C^2ao4<mUMqa{<n0UFwbOqdTC>`6**Uq(
zPuHYpZR7Ek$-408ar&|x1Kt1mTf<I0xU~K3=I<d>RsQCw<VY@CzTDm5T>9R|a_^7v
zI{$c{f7z!cp5`}QThH^@hrQMJ7d8kjv-q?3Xh++9tLQyn8mwon)w#4`^TAbnr^|+^
zUq5~Lt?$~uWk#Ym8C}xy<EFVjx-Pa@u+}HKAXnbgIb`-lmi=!-N;k0F(w32(6vp-M
zT9QP?lk(-4)Hh#ooom0uhdHcGE$_}B%|gRz-3HHpM-=IOQ#*A3koH>P&UcRrb?S?^
z_sz}<yee?#NmQ3!_sxB?mBe2aZQLAuSbOgLnY&Mx&d4m8e#?I8H18=g0jH+8DTj)y
ze3g#RF8k>H`src8NBgext+`q>v%<yNJ|wPl=AKzESvCb~#6J=E$X8e}rFBWnmD5F`
zW{X#E+8Gz06x8_r>zWhNpXV!oUcdC_v*}Ow<ZN5~y;S9j=BoV_nx`4fteRd=w6~bZ
z=}>ie?cJJ;$3-SDa@?g~S+FheD>e5Le`+spziibV(dqJDi{;g{m#w}N@;^uO{w?pT
zaSOLjuX3Nez{};F1xuqpcix_5D+Cr#IQClY|BY|I%pNVV?|97CcJt)*gX`x%vETXn
z$)lbH?RLFcGmhR-n4+~_)bGGMtNip;MN-9qyZpL7oSc-)!oT~_L&hmLCS4b~;Xf;6
z`YfYWGyT`_N8eu+%pt@gw>{-?#TCC(g`cjw=Y5#Z*6=;=^0%nR7w5bZ@4o2J@u~2G
zMpJ}A+h*RyJ>rE7s)sAu96JshF+P}X(4%s|zW2b6{4dUz@}|z){9EUo`}?Zx^31ho
zL<{ygRBjZ!|FSqos6&4;(_~F%5vPo0yqdm|9Cv!>y|dr{>(gVM!16n1CtNrcS0^yP
zVzolnMdi(JW_eGsWaufIwn31qp-e<VG2VU3(H^b!jQg_cHi`buyS|O%R!Q{VEl*yX
zMd;n*3;iXiE6#WK0Hb#9?NgH6oc5chCoh_#?YT_QVEcbP$)zb9yX`iMFBNY3#vycc
z<^EI0R!Ve#?R~BGz*_Rj)J-xwRxt|PKNuSuT+p0%q($QKs`N<@YPy%3xG#yga5{8i
zJx9R&X=3ZsC&+ULAHMTcz&Lh|Mlz3U#*<#J>SZSd4QkHJlCt3Pl-%GGyz5TL6wR|H
zCE+vvuhl6N?Ke4oY4`7pr@QPK7qs<GH)>iXEbbNfcTX7KUz_F~4=c61<em34IaJw5
z+uU(W*=E$*T~m}B)n-ur>O#*3y)`dqJ>H{HEY>{T`dv>--<0~rcMo!T$IQN|cu$y-
zdvC-;rjp6gIn$rtQD$QJe#=?0Q&#c3OI&$`!_>Xc?q@t)(7r3<(Z(gLF|YoZUsaSV
zTQAZRu=UB(6|QsqxB|Q7)K3_x36!2;Nc*Yq^U0LGMr$Oh{>>EoTr->F>B2`xoF_ck
zzH$GDr!Jz$+-~e-NStrpq06?9SzZ3gwzH>?70ij;;FNw(GqP9aX2&kpWBd!)1Cn2M
zF1n&CcS=Bi@_GqHkC!<WWeMTi7uL3IU2o~@IQ8zd)}4MEmT$eGqgbfF?AgJdxM$P0
z&&c`yp83?hZ_6%)S1ww&pyd+#%!+g$SIIao(*s`bv%Ss>JiVS{Y?S@dtLpvs;-`NE
zsz0eNiPoN@uPQ9*c;?Oj$aCA@sr+UBcJGE5b4!JC)h5o(ZZpoT{W0y<>+j2+t~Fo$
zI%;ue<kVmKC53NaUg>R*S#>=~G?qg-WnR)(T?4Lwt=m_K9o?rTBb4Ozd_qm(-8U-}
zZBHhC&O0{!YRT!uX#Mr4Eq88Xxv26X&Z|B=>Hns6hB+PKW;@qC?Ge}h@H%2XZ)we}
z&W#NY&g^{GCeJxs9(|Je$)4S+%fol6AD205cj;q7g-)J<hT6GXcP84X96lg@>~JBI
zW4wLIO;)wCcYo(tS&77*+`i&L$E;hXheeNXON!5V7HB$Ka%W$8^xbC(<%^fsJyO~-
zYgg0c%X6|;v<Q|RkWpMb(eB-{k~_Y+Eg>aVX~C6WBDuE9thD!kYMBwP6|j8m-`M#3
z2OOjIQUs=7-=O$NV!1<8ozeQN14$n)rP_*5YGc#-@vCarM!C|NADnoZC(7J2Vq08N
zYN>Yq@9#{pD?NvnZ9c=E^7oYb84ERj2i|2z)85RIi&~O-;o$jg3~oDWrCQkc+>SbV
zamL+XJ+Y%)u@4sVTWy#;xsj(!qG&_(?~>zsd**~s=HHw0<<>mk*H<T*Nvb|%jI$Hn
z-fP9USH{x0-m^ozBD>DKv?6>jul@eu`S)Ar>z}DTx{cvx@1kD;59VroI5*34--a(C
zBCKzJ_b|N_kh;Tvtl94Q(`Diz&L{5mRppo3{#n7-rN`CGka?utYfDn%Gw$svRjqvf
zEnhw5+rHaoGKc=K_E;9aKD@O-B2mZu*sobfcb{aGX?ecu@XDPVCdTPbmM`1*NMV7d
ztCYwA?**M{b1szkA6JdsaQ0wv(m4UMjqN-C>}ljqym$D`t_F80CMT|L@!k8U*gn>0
zJu)R$Y{HE#QR_7f=P#c!)84Nm|4rMqw5OKm)@9VR96NOLyukYRD`xtiG0l6mPSjF8
zW(#9k=buS`A7#BU+H>=$y3Fs$YeijAjBYF*)2fed5&Se?igiKPW%&(TPZ&MjH)GoS
zlzK0B_U{)yx5fW>@qI%={*14M=I5S;Zha)TpUF&u;Yvf0x7Pa!ITt!QrZ=SAl34rW
z<Q8)Y#f$~{S^OJ0YrQTknZc43X7c^Xmbl0+&wDp*4YefC^z8oIyG(7-?nL&3Pf9Gl
zf1eTSQ1H>}Ns^7i=9QLZ;-#6B-vpf#-E;Zk+vb~h&HuFi6i-xNe^yt0f$I_Td3k-y
zt*<;?D#;@<fAfyWso`#xvxJqUC6%Y^2R2O;%ItNPa{06P^#A*Lyh8uWH>H<uv7K{2
zit~7Ms`%5mld%m;7K>&szx#gc|BkxryT0qYIR5^B(D6@jvssEy{sU#-^g};y9BpFS
zwkkHVPk>>~XK7Je1G%%|u6B3RR)0-$e7HOGn_SaD%T`WN{}^Lc-cK(@blL0mvN`0V
zzE=CSzU5_ZS$$;dyLEYfA)&YbR6kh1j^z+5`@0^w7KWRj_rFd$J5S?`j@O3tfY<{P
zTR&a1Szak{vRmYB#ROwEmN)F{mIx|m`m|h}JXK0ap~S>tO8&N^_Zer@J`*mI%Qk7)
zt8y#d{f3Ii%!c*c-S@7aOmh)8|9MQ+y|Z`oYNknH-%Xn)+OZdel?$sgm?Z}`ufCfZ
z!>Zq!ziRsRqfeZjVz)2JI{*Cl)VEh-S@~0=T$T2`bNMU0`Q@C~_m7F)zPqw@dHO+t
z9ya;y5~7^dGrsEnJoDFDgLhwYtW8Ug!=Fjb^A_)B2)U#Ai*rroryH{xOSM)cxH^b3
zS$S`J@bHh$^Do`r8(#i*NDxnZXrSD8+w!>n;~(A^dd?|lt*u+OPUpKP!^8Hx<txr6
zHm6uTyM0+*Q0Z{}gqI#k9(R*gd_QTt-}wAj@vewz>%LoXuX^J=t)xEr=M(!=JENLc
zbTL<mgm&}I$@+Qox$o9J=L;`8mMsf#+N7JVqxWE{yyC2>CujPLd}_G+-RIRUw_gdH
zUSB)<Z_OirC9S4($ED$0c)Y51me}*%EH$2gTIZr&>p7RjPnXJwa#(15iPdtBi%^@Z
z%+GCmWd_&FWBdL;`~5E|%VfeqiA^HD0%}i<edH2t-m{m@xt$Q0@=(|QZ?ejp4(UI#
zYc{VC`F*6|PKUR)YBXo&*{fI1PcS+4bbe5LTjZ+0IdgvPe65kYY*odSx~*3=T(h*N
zU-GKGxVYr#@s;x@i^U$8yhD4%vIyt%t8ZK}c~iwwn6ixhm-v!RMVmY}?f3upwcU8q
znY=>}7pbgP&E31}S-5U!q0#b(JU9JPgf}YrEGyUE$Z>U1#>}qRS<_bjnCcyN+fX$6
z^MtSK|7^O}oy|8PW%ibY_FXd_EizeDE}pKu$@O%;F-Oz$T^StPRvUO|$#|*?UDBH=
zdit`gsKwHN18Zlvd8}RX-K6Eq5x;~>%XRl0W?j>yR3q|oZJx-UC-1oK_;N@F9B+GP
zq3~zrit5){wZZ4o8rJnkCFe(r$XZT#dt>UhwJbb~XMbRv@~c2M+wPHg_z}aV^Isg;
z7rgBAJFww_*9pZB<+Ua559hKU6ua=zwQXkU@{`wG3|`!O_Jrf{?@w!E4#aQHJ9EO~
z<=iY*=7&NIU2ab{I%@=!K3^1O%NKudwd22lZTmi4oE2CVd@DfkwZWHInX2wA5tH90
zubppP4&0>q{YleSn;)@LQWm$QGC#Un9G%fp<+`t6Iz!Fe2`k%XO*V}!Qj+z$%<3^a
z>nr2uX$5z-y?=HtWR^*`^15wvv^JjlaMWah?8n2~7#eQy95ZV9WWcoV&=hl-Uncka
zTBd(@m+qT;mRmw;$&Z_9>hAHu`)19NpD3Him;de72k#Y6l4tVOrWsa!y0vvL!;Bkq
z?p@z~di$48<;lW@>E}2;R9StWc=lb#@uR=2IDTE}tH_Oidx~w<aft`huL%6O$0A&I
z=JvhgGt)iwqz|+^oZz@S-#3J3k%nQCb75(`LhqU7$vf)}QU$|ig_Y0f>N@k_!-e0s
z>{24aZ~D5<Q*epB`pGci_IJhdf5-VAw2Q4^?~hkdQA=4<65P)-qw(DS_n#DE*cC4I
zsI~oEbl_=M{m#V!tnYG+^m|?Y&i-<m_5O`d+6k{Nmaf=ord`i_Q(=P5HDgU7k>Yt5
zCuS>qyvdw?b<Gm_|0izUV*cw^xI<p-)F<<+)_-EX?%lSyYbh}APp+A7{_jf7wPHd~
zrg+3M>~x!2`y@t(J$mES?}dM4GZb`kYvrB=&URyP+qQu>(>HvZ#CPq0-@aeX7KI8d
z3_thl;Kx-7?ujKLlim65GfiSkW7~Z#aAto}SGSrnn<2~VJHATiycTfVM9vj>{MK@o
zpjyP%vx48u7EHY(B-!$JyXazxaQ!)Tr!4z5Yzr2C>b|kAz2eK`#kW{e)VDt<veegg
zxqHH)YWlft-3%EAw%l^Bi=EQ`k|R!v@51tRj9#CaCdmkV`0Lm<<Lb{l%NVykY3R`p
z^8R^F%|*3+qFzrjd#2*`zE1+(@;yt0c#Wo9j`4f8bm9qyMe|H7*_dR0eZC{}a0dHL
zrhS@kJ^3yM=*680mASd?e#~X=OqO*=0{zu0&rVD^pl&L7=8YN0lEx(+F6&>0I<A~_
z!`3<f-6Q4wZw@z@m~vb-wFzjbT+)B<#?!A_?F!6Y7S3;v%=vdT&GNkXjz!GAn@%4J
z_;|=*uc^r`ego-qujTSu5}gEIoO1Bqnf2oe*EyaoT66kmdRHyInpSW=josSGo>RUo
z&+7Nb;x>NX`Y`6cwYHr9o=mzDDe`Dt=kAF8PmbITkM&gh?GY+`-~a7~f5(@ddv&=s
zF^cI#ZD@6`eV+SMgA0neYjafJwDelEM4r8Qx&2#N;-vQvV)(gamMvR9o9B?wIx`!s
z>WHtYR|;nF*_~{*o9`2y#&CS`f3KTgbz2Q~tbG-q+cxvdGf%b$=X1|Ii@wV9!1G<X
z3u}e>eyMwUpHHQyZ28rl#<)Fa`wYXAf1YPHZA*N|6|Bc{E-rU#_ItCX0WHh3HWl(l
zz1^X+ta`78;zuDJ=`~XYS;KF?P+lLRqau|0ext)4I|&hXCS!}(OTpolPF8QdY&WrU
z?%jL&hN9@lsp$dME@=U(DvK-Gewqs`-}AV-g}3Zajjeip>(BR>^?V&p{+O$J#-d`|
z=6~m}7f<chGUjcqc8t^faP*s?y;gF|5+Rk$$bM4;Ns(vsHzhvE+?aG;bj_1`15y7+
zT%6N9-*Ou-E>jfS9JH%qhjp1j*c<a7j}rc0`S@qI#Y~eGN=H6)i@j1%Fp-<ZJ>ko?
zh50Aez6#Q561!;}WtH{$ncs|^SC(u2x;V@GXvQCB)sL@L7M8o%hbkRs-t1y9t={L{
zBL$(R>z_8ws#+nx^{rH_=B2(p4NgJ_@+4c6J#zTBJT-lm)v)Z?cIQ9mt>^Ss8%<(f
zYU#eYR_3U{jG&~CwGL6w)1IC(VUw|0vVZ^L{mJtG*8j*}QplS5j%AsO+f0RNCO?<N
zEK#&{;IIo^<$CAZE$cZU!b|<@K6>lLFSfd7edugtqTQW0+m|1FC#ZCCg>TRy6$Xi~
zv7GwTzM2ZZFrU%u5z@1IUz=Gc=bhbh!D+vbKc42_f824cM?_v*^xAi6jy>AvH7+t5
z`d=_t-_!B?sCDPgfK#UxH$31z*_1r{-O31wn|~L^G`*|}S-Y<9LA-m&H~ENf_H_}@
zgB$+%y|`a`CEljtdFkGLjdwrDTo$qTZPUI*@6Nf?(>|}t{GmEa{9CHgfy0Rx6Yp%<
zyYab+Fw<dH>32-Ce={nuev$E;t|%`j7k9+|@cjPxN6+Pbe(PKHJpRgS^Jk*|te<5W
zC)kB|9+<+EQ?&A6^JY_Hw;g6_|4(Z^Kfpc9l$~q(N2|t(R`d6rXN*nv6;4@u?7Qd#
zS&0*x8#TUNI^FI0VDj_}QxXf`Fg2_e-*M*e^Nhcm`3f9+o`^4*z<Y}Aa+pB;xz$;c
zCi6A96*><!=P+;Esm?6c;XIK?{<rnXU*gSuyoug!Q(Ss&PX4k#(mUbG!kiDm^X_zS
zh&X<hXMIx1L@|q#9!WL(0w(6??B0Lta$4~#zuCD<*5BA*!y0RIQQ}6xJFDQnX{$1<
zj8?^OTQ+@0+LRqXPF<O}M?6;O+l+qZS05~8<K6gged%hL;aFXg_m_?L(#7SzH&(J=
z`~Kqo+rtake+TJ&o#}h8StQ`$_ND7nK2KS?vDGyA6<f`fA7^EK^tSphuGm_CB<7&R
ze(`w0OX5p!?7LEx>UHn<(Ho1;rcAzaJN=7A(#M&XuCH13Cg@}QrObtKOcQqg*t|t)
z{;QwoUoF}dy*z*I{DAknqw9|BS-Mm2fp_RD#%q(du`Vn1W?!!5%;i*V{*CwYExQ=S
zyc~`t9+tg#ZoiS6Cc+TTz-@5jtL1%`8>;P1i&dT+eDs3la_6-+&-c^LJL^ofV|UAb
z>6KFBclLE$>{Dr<j`F~(iS`FKPM({7{MP5Jza4#hyJVCK?>F$r1!q4vWyksK=k1bp
zFVwC0EjM3PPM+JpaLW`?;m<N&Rm-Ly+-18s_R;K+#qwTSOZ_;F*Ph_}$II@=etz}C
zyhrRgKc-53=SjI_@Te@ZNbBX76G^r9+23E5eY<^e>+557w<i_HRa8|^O7M8N+wYpY
zf|>P;)f|RB>}iVp4|8_i_~qZMr*|T|>YKxL#}uibGV9wuWG|Va^P%*|Mf*D&MSdAP
zImn-UQ|;pZmnt7M`HZ=yNWV^)AMmBw!fMm+gx2DA*`jyPC5&vi^jBU}*_C-#Q18{;
z_t$>CS7+=jar$)ft;xOHmz}E{4U98+x>IgXd$>?ji*KTGnPo@x*{5OmI-l?H+?VUI
zZ=>kJ{j+8SsBBpm>Xu)v(t3gKx#6vAXXpIqa;(k&J}18~>G@s$E$(%;_OD}XPW=1)
zzd2m`6N}EX=<kQNbMzUXjyAg)yY+=Bhx5$m-tvA~_m@6j#huH=bdICsOu=r6V>=G5
zamw4sF5z}>;U(k9%n7a6n0_+!%|E;JY;1nW=cyc<1%6uT8u*%Tf1D{Lzu$p9wNq^4
znmGS;sk@&P3a(XJpOa#cFFfPiY>%zpdnzvHEQ)%^HTA*cWoh?B7pYYrNL$RCe(m9b
z(uxVTf%A`d)ECSTY`;_d@ZAy*p?upD=N`9YGAkLgt`x9R{ChJqob%S_&2O(1m6UBi
z{cPFQyZ-kDE}YiqcI`VYGHJr9x6DGng2dc4D#Z8geOvPXg+_Apv!(o=<${?DTdH;{
zGk1OPZqwRz_7Th3{|BqZ-t5`#>Ob|(k+S<2T^5Iy9$)2u<Wgq9p9LP%9Sbj3^KkWC
zJ$T?-Idi|mw(o^&zP9t-o?e;$-gHCi)%JM@+v-$)?Gn4a{zPF+%9}On6M3wU+r}qd
zaX)eU&H0usZ|<vpV6B=NUv%u^j^c!w{`0@Q)Q$Og%J_eh-)><K!wc5$Qj_njWB+zH
zX6iK|>BeV_^Zu`jTL0=F!{jLswc}sCE`OSISLE&e`a=y9X9dp_Q0?Na=zTTOYxTST
zg<4%2K`ogEKFRahYC9Z^Rw-V05YIcmSLi~s&cp|aQn#5FJpadS7Qq--yq<vp0QSI)
A_5c6?

literal 0
HcmV?d00001

diff --git a/src/main.cpp b/src/main.cpp
index 92aad42..7ccd1d0 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -330,12 +330,12 @@ void readPms()
   if (pms)
   { // successfull read
     sensorData->put("pm1", pms.pm01);
-    sensorData->put("pm2.5", pms.pm25);
+    sensorData->put("pm2p5", pms.pm25);
     sensorData->put("pm10", pms.pm10);
-    sensorData->put("nc0.3", pms.n0p3);
-    sensorData->put("nc0.5", pms.n0p5);
+    sensorData->put("nc0p3", pms.n0p3);
+    sensorData->put("nc0p5", pms.n0p5);
     sensorData->put("nc1", pms.n1p0);
-    sensorData->put("nc2.5", pms.n2p5);
+    sensorData->put("nc2p5", pms.n2p5);
     sensorData->put("nc5", pms.n5p0);
     sensorData->put("nc10", pms.n10p0);
   }
@@ -376,12 +376,12 @@ void readPms()
 void logPms()
 {
   Serial.printf("PM <=  1µm: %5d µg/m³\r\n", (int)sensorData->get("pm1"));
-  Serial.printf("PM <=2.5µm: %5d µg/m³\r\n", (int)sensorData->get("pm2.5"));
+  Serial.printf("PM <=2.5µm: %5d µg/m³\r\n", (int)sensorData->get("pm2p5"));
   Serial.printf("PM <= 10µm: %5d µg/m³\r\n", (int)sensorData->get("pm10"));
-  Serial.printf("NC >=0.3µm: %5d #/100cm³\r\n", (int)sensorData->get("nc0.3"));
-  Serial.printf("NC >=0.5µm: %5d #/100cm³\r\n", (int)sensorData->get("nc0.5"));
+  Serial.printf("NC >=0.3µm: %5d #/100cm³\r\n", (int)sensorData->get("nc0p3"));
+  Serial.printf("NC >=0.5µm: %5d #/100cm³\r\n", (int)sensorData->get("nc0p5"));
   Serial.printf("NC >=  1µm: %5d #/100cm³\r\n", (int)sensorData->get("nc1"));
-  Serial.printf("NC >=2.5µm: %5d #/100cm³\r\n", (int)sensorData->get("nc2.5"));
+  Serial.printf("NC >=2.5µm: %5d #/100cm³\r\n", (int)sensorData->get("nc2p5"));
   Serial.printf("NC >=  5µm: %5d #/100cm³\r\n", (int)sensorData->get("nc5"));
   Serial.printf("NC >= 10µm: %5d #/100cm³\r\n", (int)sensorData->get("nc10"));
 }
@@ -392,15 +392,15 @@ void readBme()
   { // If new data is available
     sensorData->put("temperature", iaqSensor.temperature);
     sensorData->put("humidity", iaqSensor.humidity);
-    sensorData->put("pressure", iaqSensor.pressure);
+    sensorData->put("pressure", iaqSensor.pressure / 100); // Pa -> hPa
     sensorData->put("iaq", iaqSensor.iaq);
-    sensorData->put("iaq_acc", iaqSensor.iaqAccuracy);
+    sensorData->put("iaq_acc", (int)iaqSensor.iaqAccuracy);
     sensorData->put("siaq", iaqSensor.staticIaq);
-    sensorData->put("siaq_acc", iaqSensor.staticIaqAccuracy);
+    sensorData->put("siaq_acc", (int)iaqSensor.staticIaqAccuracy);
     sensorData->put("eco2", iaqSensor.co2Equivalent);
-    sensorData->put("eco2_acc", iaqSensor.co2Accuracy);
+    sensorData->put("eco2_acc", (int)iaqSensor.co2Accuracy);
     sensorData->put("bvoc", iaqSensor.breathVocEquivalent);
-    sensorData->put("bvoc_acc", iaqSensor.breathVocAccuracy);
+    sensorData->put("bvoc_acc", (int)iaqSensor.breathVocAccuracy);
   }
   else
   {
@@ -410,16 +410,16 @@ void readBme()
 
 void logBme()
 {
-  Serial.printf("Temperature  : %.2f °C\r\n", sensorData->get("temperature"));
-  Serial.printf("Humidity     : %.2f %%\r\n", sensorData->get("humidity"));
-  Serial.printf("Pressure     : %d hPa\r\n", (int)sensorData->get("pressure"));
-  Serial.printf("IAQ          : %.2f\r\n", sensorData->get("iaq"));
+  Serial.printf("Temperature  : %f °C\r\n", sensorData->get("temperature"));
+  Serial.printf("Humidity     : %f %%\r\n", sensorData->get("humidity"));
+  Serial.printf("Pressure     : %f hPa\r\n", sensorData->get("pressure"));
+  Serial.printf("IAQ          : %f\r\n", sensorData->get("iaq"));
   Serial.printf("IAQ Accuracy : %d\r\n", (int)sensorData->get("iaq_acc"));
-  Serial.printf("Static IAQ   : %.2f\r\n", sensorData->get("siaq"));
+  Serial.printf("Static IAQ   : %f\r\n", sensorData->get("siaq"));
   Serial.printf("Stat IAQ Acc : %d\r\n", (int)sensorData->get("siaq_acc"));
-  Serial.printf("CO2 Equiv    : %.2f ppm\r\n", sensorData->get("eco2"));
+  Serial.printf("CO2 Equiv    : %f ppm\r\n", sensorData->get("eco2"));
   Serial.printf("CO2 Accuracy : %d\r\n", (int)sensorData->get("eco2_acc"));
-  Serial.printf("bVOC Equiv   : %.2f ppm\r\n", sensorData->get("bvoc"));
+  Serial.printf("bVOC Equiv   : %f ppm\r\n", sensorData->get("bvoc"));
   Serial.printf("bVOC Accuracy: %d\r\n", (int)sensorData->get("bvoc_acc"));
   //Serial.printf("Gas Percent  : %f %%\r\n", iaqSensor.gasPercentage);
   //Serial.printf("Gas Per Accur: %d\r\n", iaqSensor.gasPercentageAcccuracy);
-- 
GitLab