web_server.cpp 12.7 KB
Newer Older
1
2
#include "web_server.h"

Eric Duminil's avatar
Eric Duminil committed
3
#include "web_config.h"
Eric Duminil's avatar
Eric Duminil committed
4
#include "util.h"
Eric Duminil's avatar
Eric Duminil committed
5
#include "ntp.h"
Eric Duminil's avatar
Eric Duminil committed
6
7
8
#include "wifi_util.h"
#include "co2_sensor.h"
#include "sensor_console.h"
9
#include "csv_writer.h"
Eric Duminil's avatar
Eric Duminil committed
10
#include "mqtt.h"
Eric Duminil's avatar
Eric Duminil committed
11
#include "lorawan.h"
Eric Duminil's avatar
Eric Duminil committed
12
13
14
15
16
17

#if defined(ESP8266)
#  include <ESP8266WebServer.h>
#elif defined(ESP32)
#  include <WebServer.h>
#endif
Eric Duminil's avatar
Eric Duminil committed
18

19
20
21
namespace web_server {

  const char *header_template;
Eric Duminil's avatar
Eric Duminil committed
22
23
  const char *body1_template;
  const char *body2_template;
24
25
26
  const char *script_template;
  void handleWebServerRoot();
  void handlePageNotFound();
Eric Duminil's avatar
Eric Duminil committed
27
  void handleWebServerCommand();
28

29
  void handleDeleteCSV();
30
  void handleWebServerCSV();
31

Eric Duminil's avatar
Eric Duminil committed
32
  const __FlashStringHelper* showHTMLIf(bool is_active) {
Eric Duminil's avatar
Eric Duminil committed
33
34
35
    return is_active ? F("") : F("hidden");
  }

Eric Duminil's avatar
Eric Duminil committed
36
  const __FlashStringHelper* yesOrNo(bool is_active) {
Eric Duminil's avatar
Eric Duminil committed
37
38
39
    return is_active ? F("Yes") : F("No");
  }

Eric Duminil's avatar
Eric Duminil committed
40
  void definePages() {
41
42
    header_template =
        PSTR("<!doctype html><html lang=en>"
Eric Duminil's avatar
Eric Duminil committed
43
44
45
            "<head>"
            "<title>%d ppm - CO2 SENSOR - %s - %s</title>"
            "<meta charset='UTF-8'>"
46
            // HfT Favicon
Eric Duminil's avatar
Eric Duminil committed
47
            "<link rel='icon' type='image/png' sizes='16x16' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAHtJREFUOE9jvMnA+5+BAsBIFQMkl85h+P3kGcOb8jqwW+TPH2H4de0GA29UGNxtfx49YWCRk0HwHz5iuKegwwB2AS4DkA2F6VR6cAWsEQbgBqDY9vARw/ejJ+Au+LxsFcPz6BSwHpwGYPMCSS6gyAAKYhESiKMGjPgwAADopHVhn5ynEwAAAABJRU5ErkJggg=='/>"
48
            // Responsive grid:
Eric Duminil's avatar
Eric Duminil committed
49
50
            "<link rel='stylesheet' href='https://unpkg.com/purecss@2.0.6/build/pure-min.css'>"
            "<link rel='stylesheet' href='https://unpkg.com/purecss@2.0.6/build/grids-responsive-min.css'>"
51
            // JS Graphs:
Eric Duminil's avatar
Eric Duminil committed
52
            "<script src='https://cdn.plot.ly/plotly-basic-2.9.0.min.js'></script>"
53
            // Fullscreen
Eric Duminil's avatar
Eric Duminil committed
54
            "<meta name='viewport' content='width=device-width, initial-scale=1'>"
55
            // Refresh after every measurement.
Eric Duminil's avatar
Eric Duminil committed
56
57
58
            // "<meta http-equiv='refresh' content='%d'>"
            "</head>"
            "<body>"
Eric Duminil's avatar
H2    
Eric Duminil committed
59
            "<div class='pure-g'><div class='pure-u-1'><div class='pure-menu'><h2 class='pure-menu-heading'>HfT-Stuttgart CO<sub>2</sub> Ampel</h2></div></div>"
Eric Duminil's avatar
Eric Duminil committed
60
61
62
            "<div class='pure-u-1'><ul class='pure-menu pure-menu-horizontal pure-menu-list'>"
            "<li class='pure-menu-item'><a href='/config' class='pure-menu-link'>Config</a></li>"
            "<li class='pure-menu-item'><a href='#table' class='pure-menu-link'>Info</a></li>"
Eric Duminil's avatar
Eric Duminil committed
63
64
            "<li class='pure-menu-item'><a href='#graph' class='pure-menu-link'>Graph</a></li>"
            "<li class='pure-menu-item'><a href='#log' class='pure-menu-link'>Log</a></li>");
Eric Duminil's avatar
Eric Duminil committed
65

Eric Duminil's avatar
Eric Duminil committed
66
67
68
69
70
71
72
    body1_template =
        PSTR(
            "<li class='pure-menu-item'><a href='%s' class='pure-menu-link'>Download CSV</a></li>" "<li class='pure-menu-item' id='led'>&#11044;</li>" // LED
            "</ul></div></div>" "<script>"
            // Show a colored dot on the webpage, with a similar color than on LED Ring.
            "hue=(1-(Math.min(Math.max(parseInt(document.title),500),1600)-500)/1100)*120;" "document.getElementById('led').style.color=['hsl(',hue,',100%%,50%%)'].join('');" "</script>" "<div class='pure-g'>" "<div class='pure-u-1' id='graph'></div>"// Graph placeholder
            "</div>" "<div class='pure-g'>" "<table id='table' class='pure-table-striped pure-u-1 pure-u-md-1-2'>" "<tr><th colspan='2'>%s</th></tr>" "<tr><td>CO<sub>2</sub></td><td>%5d ppm</td></tr>" "<tr><td>Temperature</td><td>%.1f&#8451;</td></tr>" "<tr><td>Humidity</td><td>%.1f%%</td></tr>" "<tr><td>Last measurement</td><td>%s</td></tr>" "<tr><td>Timestep</td><td>%5d s</td></tr>" "<tbody %s>" "<tr><th colspan='2'>CSV</th></tr>" "<tr><td>Last write</td><td>%s</td></tr>" "<tr><td>Interval</td><td>%5d s</td></tr>" "<tr><td>Available space</td><td>%d kB</td></tr>" "</tbody>" "<tbody %s>" "<tr><th colspan='2'>MQTT</th></tr>" "<tr><td>Connected?</td><td>%s</td></tr>" "<tr><td>Last publish</td><td>%s</td></tr>" "<tr><td>Interval</td><td>%5d s</td></tr>" "</tbody>"
Eric Duminil's avatar
Eric Duminil committed
73
74
75
76
77
78
79
80
81
#if defined(ESP32)
        "<tbody %s>"
        "<tr><th colspan='2'>LoRaWAN</th></tr>"
        "<tr><td>Connected?</td><td>%s</td></tr>"
        "<tr><td>Frequency</td><td>%s MHz</td></tr>"
        "<tr><td>Last transmission</td><td>%s</td></tr>"
        "<tr><td>Interval</td><td>%5d s</td></tr>"
        "</tbody>"
#endif
Eric Duminil's avatar
Eric Duminil committed
82
            );
Eric Duminil's avatar
Eric Duminil committed
83

Eric Duminil's avatar
Eric Duminil committed
84
85
    body2_template =
        PSTR(
Eric Duminil's avatar
Eric Duminil committed
86
            "<tr><th colspan='2'>Sensor</th></tr>"
Eric Duminil's avatar
Eric Duminil committed
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
                "<tr><td>Temperature offset</td><td>%.1fK</td></tr>"
                "<tr><td>Auto-calibration?</td><td>%s</td></tr>"
                "<tr><td>Local address</td><td><a href='http://%s.local/'>%s.local</a></td></tr>"
                "<tr><td>Local IP</td><td><a href='http://%s'>%s</a></td></tr>"
                "<tr><td>MAC</td><td>%s</td></tr>"
                "<tr><td>Free heap space</td><td>%6d bytes</td></tr>"
                "<tr><td>Largest heap block</td><td>%6d bytes</td></tr>"
                "<tr><td>Frag</td><td>%3d%%</td></tr>"
                "<tr><td>Max loop duration</td><td>%5d ms</td></tr>"
                "<tr><td>Board</td><td>%s</td></tr>"
                "<tr><td>ID</td><td>%s</td></tr>"
                "<tr><td>Ampel firmware</td><td>%s</td></tr>"
                "<tr><td>Uptime</td><td>%2d d %4d h %02d min %02d s</td></tr>"
                "</table>"
                "<div id='log' class='pure-u-1 pure-u-md-1-2'></div>"
                "<form action='/command'><input type='text' id='send' name='send'><input type='submit' value='Send'></form>"
                "<form action='/delete_csv' method='POST' onsubmit=\"return confirm('Are you really sure you want to delete all data?') && (document.body.style.cursor = 'wait');\">"
                "<input type='submit' value='Delete CSV'/>"
                "</form>"
                "<button onclick=\"fetch('/command?send=set_time '+Math.floor(Date.now()/1000))\" %s>Set time!</button>" // Can be useful in AP mode
                "</div>"
                "<a href='https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-firmware' target='_blank'>Source code</a>&nbsp;"
109
110
111
                "<a href='https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-documentation' target='_blank'>Documentation</a>"
                "<script>"
                "document.body.style.cursor='default';");
Eric Duminil's avatar
Eric Duminil committed
112

113
    script_template = PSTR("fetch('%s',{credentials:'include'})"
Eric Duminil's avatar
Eric Duminil committed
114
115
        ".then(r=>r.text())"
        ".then(c2t)"
116
        ".then(t=>document.getElementById('log').appendChild(t))"
Eric Duminil's avatar
Eric Duminil committed
117
        ".then(_=>Plotly.newPlot('graph',data,layout,{displaylogo:false}))"
Eric Duminil's avatar
Eric Duminil committed
118
        ".catch(console.error);"
Eric Duminil's avatar
Eric Duminil committed
119
        "xs=[];y1=[];y2=[];y3=[];"
Eric Duminil's avatar
Eric Duminil committed
120
        "d={x:xs,type:'scatter',mode:'lines+markers',marker:{size:3}};" // circle marker (symbol 0), from https://plotly.com/python/marker-style/
121
        "data=["
Eric Duminil's avatar
Eric Duminil committed
122
123
124
        "{...d,...{y:y1,name:'CO<sub>2</sub>',line:{color:'#2ca02c'}}},"
        "{...d,...{y:y2,name:'Temperature',yaxis:'y2',line:{color:'#ff7f0e',dash:'dot'}}},"
        "{...d,...{y:y3,name:'Humidity',yaxis:'y3',line:{color:'#1f77b4',dash:'dot'}}}];"
Eric Duminil's avatar
Eric Duminil committed
125
126
127
128
129
        "layout={height:600,title:'%s',legend:{xanchor:'right',x:0.2,y:1.0},"
        "xaxis:{domain:[0.0,0.85]},yaxis:{ticksuffix:'ppm',range:[0,2000],dtick:200},"
        "yaxis2:{overlaying:'y',side:'right',ticksuffix:'°C',position:0.9,anchor:'free',range:[0,30],dtick:3},"
        "yaxis3:{overlaying:'y',side:'right',ticksuffix:'%%',position:0.95,anchor:'free',range:[0,100],dtick:10}"
        "};"
Eric Duminil's avatar
Eric Duminil committed
130
131
132
133
134
135
        "function c2t(t){"
        "t=t.trim();"
        "ls=t.split('\\n');"
        "tb=document.createElement('table');"
        "tb.className='pure-table-striped';"
        "n=ls.length;"
136
        "ld=NaN;"
Eric Duminil's avatar
Eric Duminil committed
137
138
        "ls.forEach((l,i)=>{"
        "fs=l.split(';');"
Eric Duminil's avatar
Eric Duminil committed
139
        //Don't display points without time
140
141
142
143
144
145
146
147
148
149
        "if(fs[0].includes('1970-')){return};"
        "d=Date.parse(fs[0]);"
        //Split curves when points are more than 1h apart
        "if(d-ld>36e5){"
        "xs.push(NaN);"
        "y1.push(NaN);"
        "y2.push(NaN);"
        "y3.push(NaN);"
        "}"
        "ld=d;"
Eric Duminil's avatar
Eric Duminil committed
150
        "xs.push(fs[0]);"
Eric Duminil's avatar
Eric Duminil committed
151
152
153
        "y1.push(fs[1]);"
        "y2.push(fs[2]);"
        "y3.push(fs[3]);"
154
        "if(i>4&&i<n-12){if(i==5){fs=['...','...','...','...']}else{return;}}"
Eric Duminil's avatar
Eric Duminil committed
155
156
157
158
159
160
161
        "r=document.createElement('tr');"
        "fs.forEach((f,_)=>{"
        "c=document.createElement(i<2?'th':'td');"
        "c.appendChild(document.createTextNode(f));"
        "r.appendChild(c);});"
        "tb.appendChild(r);});"
        "return tb;}"
Eric Duminil's avatar
Eric Duminil committed
162
163
164
        "</script>"
        "</body>"
        "</html>");
165
166

    // Web-server
Eric Duminil's avatar
Eric Duminil committed
167
168
    web_config::http.on("/", handleWebServerRoot);
    web_config::http.on("/command", handleWebServerCommand);
169
    web_config::http.on(csv_writer::filename, handleWebServerCSV);
Eric Duminil's avatar
Eric Duminil committed
170
    web_config::http.on("/delete_csv", HTTP_POST, handleDeleteCSV);
171
172
  }

173
174
175
176
177
  /*
   * Allow access if Ampel is in access point mode,
   * if http_user or http_password are empty,
   * or if provided credentials match
   */
178
  bool shouldBeAllowed() {
Eric Duminil's avatar
Eric Duminil committed
179
180
    return wifi::isAccessPoint() || strcmp(config::http_user, "") == 0 || strcmp(config::ampel_password(), "") == 0
        || web_config::http.authenticate(config::http_user, config::ampel_password());
181
182
183
184
  }

  void handleWebServerRoot() {
    unsigned long ss = seconds();
Eric Duminil's avatar
Eric Duminil committed
185
186
    uint8_t dd = ss / 86400;
    ss -= dd * 86400;
187
188
189
190
191
    unsigned int hh = ss / 3600;
    ss -= hh * 3600;
    uint8_t mm = ss / 60;
    ss -= mm * 60;

Eric Duminil's avatar
Eric Duminil committed
192
    //NOTE: Splitting in multiple parts in order to use less RAM. Higher than 2000 apparently crashes the ESP8266
Eric Duminil's avatar
Eric Duminil committed
193
    char content[1600];
Eric Duminil's avatar
Eric Duminil committed
194
    // Current size (with Lorawan, timesteps and long thing name):
Eric Duminil's avatar
Eric Duminil committed
195
    //    INFO - Header size : 1347 - Body1 size : 1448 - Body2 size : 1475 - Script size : 1507
196

Eric Duminil's avatar
Eric Duminil committed
197
    snprintf_P(content, sizeof(content), header_template, sensor::co2, config::ampel_name(), wifi::local_ip);
198

199
200
    Serial.print(F("INFO - Header size : "));
    Serial.print(strlen(content));
Eric Duminil's avatar
Eric Duminil committed
201
202
    web_config::http.setContentLength(CONTENT_LENGTH_UNKNOWN);
    web_config::http.send_P(200, PSTR("text/html"), content);
203
204

    // Body
Eric Duminil's avatar
Eric Duminil committed
205
206
    snprintf_P(content, sizeof(content), body1_template, csv_writer::filename, config::ampel_name(), sensor::co2,
        sensor::temperature, sensor::humidity, sensor::timestamp, config::measurement_timestep,
Eric Duminil's avatar
Eric Duminil committed
207
        showHTMLIf(config::is_csv_active()), csv_writer::last_successful_write, config::csv_interval,
Eric Duminil's avatar
Eric Duminil committed
208
209
        csv_writer::getAvailableSpace() / 1024, showHTMLIf(config::is_mqtt_active()), yesOrNo(mqtt::connected),
        mqtt::last_successful_publish, config::mqtt_sending_interval
Eric Duminil's avatar
Eric Duminil committed
210
#if defined(ESP32)
Eric Duminil's avatar
Eric Duminil committed
211
        , showHTMLIf(config::is_lorawan_active()), yesOrNo(lorawan::connected),
Eric Duminil's avatar
Eric Duminil committed
212
213
214
        config::lorawan_frequency_plan, lorawan::last_transmission, config::lorawan_sending_interval
#endif
        );
Eric Duminil's avatar
Eric Duminil committed
215
216
217
218
219

    Serial.print(F(" - Body1 size : "));
    Serial.print(strlen(content));
    web_config::http.sendContent(content);

Eric Duminil's avatar
Eric Duminil committed
220
    snprintf_P(content, sizeof(content), body2_template, sensor::getTemperatureOffset(),
Eric Duminil's avatar
Eric Duminil committed
221
        yesOrNo(config::auto_calibrate_sensor), config::ampel_name(), config::ampel_name(), wifi::local_ip,
Eric Duminil's avatar
Eric Duminil committed
222
223
        wifi::local_ip, ampel.macAddress, ESP.getFreeHeap(), esp_get_max_free_block_size(),
        esp_get_heap_fragmentation(), ampel.max_loop_duration, ampel.board, ampel.sensorId, ampel.version, dd, hh, mm,
Eric Duminil's avatar
Eric Duminil committed
224
        ss, showHTMLIf(!ntp::connected_at_least_once));
225

Eric Duminil's avatar
Eric Duminil committed
226
    Serial.print(F(" - Body2 size : "));
227
    Serial.print(strlen(content));
Eric Duminil's avatar
Eric Duminil committed
228
    web_config::http.sendContent(content);
229
230

    // Script
Eric Duminil's avatar
Eric Duminil committed
231
    snprintf_P(content, sizeof(content), script_template, csv_writer::filename, config::ampel_name());
232

233
234
    Serial.print(F(" - Script size : "));
    Serial.println(strlen(content));
Eric Duminil's avatar
Eric Duminil committed
235
    web_config::http.sendContent(content);
236
237
238
239
240
  }

  void handleWebServerCSV() {
    if (FS_LIB.exists(csv_writer::filename)) {
      fs::File csv_file = FS_LIB.open(csv_writer::filename, "r");
Eric Duminil's avatar
Eric Duminil committed
241
242
      char csv_size[10];
      snprintf(csv_size, sizeof(csv_size), "%d", csv_file.size());
Eric Duminil's avatar
Eric Duminil committed
243
244
      web_config::http.sendHeader("Content-Length", csv_size);
      web_config::http.streamFile(csv_file, F("text/csv"));
245
246
      csv_file.close();
    } else {
Eric Duminil's avatar
Eric Duminil committed
247
      web_config::http.send(204, F("text/html"), F("No data available."));
248
249
250
251
252
    }
  }

  void handleDeleteCSV() {
    if (!shouldBeAllowed()) {
Eric Duminil's avatar
Eric Duminil committed
253
      return web_config::http.requestAuthentication(DIGEST_AUTH);
254
    }
Eric Duminil's avatar
Eric Duminil committed
255
    Serial.print(F("Removing CSV file..."));
256
    FS_LIB.remove(csv_writer::filename);
Eric Duminil's avatar
Eric Duminil committed
257
    Serial.println(F(" Done!"));
Eric Duminil's avatar
Eric Duminil committed
258
259
    web_config::http.sendHeader("Location", "/");
    web_config::http.send(303);
260
261
  }

Eric Duminil's avatar
Eric Duminil committed
262
263
  void handleWebServerCommand() {
    if (!shouldBeAllowed()) {
Eric Duminil's avatar
Eric Duminil committed
264
      return web_config::http.requestAuthentication(DIGEST_AUTH);
Eric Duminil's avatar
Eric Duminil committed
265
    }
Eric Duminil's avatar
Eric Duminil committed
266
267
268
    web_config::http.sendHeader("Location", "/");
    web_config::http.send(303);
    sensor_console::execute(web_config::http.arg("send").c_str());
Eric Duminil's avatar
Eric Duminil committed
269
270
  }

271
  void handlePageNotFound() {
Eric Duminil's avatar
Eric Duminil committed
272
    web_config::http.send(404, F("text/plain"), F("404: Not found"));
273
274
  }
}