/* ESP8266 plus WEMOS DHT11 Sensor with a Temperature and Humidity Web Server Automous display of sensor results on a line-chart, gauge view and the ability to export the data via copy/paste for direct input to MS-Excel The 'MIT License (MIT) Copyright (c) 2016 by David Bird'. 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 ('as annotated') notice and this permission notice shall be included in all copies or substantial portions of the Software and where the software use is visible to an end-user. 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 AUTHOR OR COPYRIGHT HOLDER 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. See more at http://dsbird.org.uk */ #include #include #include #include // https://github.com/tzapu/WiFiManager #include #include // Needs to be version 1.0.9 to work with ESP8266 #include #include #include #include #include "credentials.h" #define DHTPIN D4 // Pin which is connected to the DHT sensor. // Uncomment the type of sensor in use: #define DHTTYPE DHT11 // DHT 11 //#define DHTTYPE DHT22 // DHT 22 (AM2302) //#define DHTTYPE DHT21 // DHT 21 (AM2301) DHT_Unified dht(DHTPIN, DHTTYPE,16); // Use 16 for the ESP8266 String version = "v1.0"; // Version of this program WiFiClient client; ESP8266WebServer server(80); // Start server on port 80 (default for a web-browser, change to your requirements, e.g. 8080 if your Router uses port 80 // To access server from the outsid of a WiFi network e.g. ESP8266WebServer server(8266); and then add a rule on your Router that forwards a // connection request to http://your_network_ip_address:8266 to port 8266 and view your ESP server from anywhere. // Example http://g6ejd.uk.to:8266 will be directed to http://192.168.0.40:8266 or whatever IP address your router gives to this server int log_time_unit = 15; // default is 1-minute between readings, 10=15secs 40=1min 200=5mins 400=10mins 2400=1hr int time_reference= 60; // Time reference for calculating /log-time (nearly in secs) to convert to minutes int const table_size = 288; // 300 is about the maximum for the available memory, so use 12 samples/hour * 24 * 1-day = 288 int index_ptr, timer_cnt, log_interval, log_count, max_temp, min_temp; String webpage,time_now,log_time,lastcall; bool SD_present, batch_not_written, AScale, auto_smooth, AUpdate, log_delete_approved; float dht_temp,dht_humi; typedef struct { int lcnt; // Sequential log count String ltime; // Time reading taken sint16_t temp; // Temperature values, short unsigned 16-bit integer to reduce memory requirement, saved as x10 more to preserve 0.1 resolution sint16_t humi; // Humidity values, short unsigned 16-bit integer to reduce memory requirement, saved as x10 more to preserve 0.1 resolution } record_type; record_type sensor_data[table_size+1]; // Define the data array void setup() { //WiFiManager intialisation. Once completed there is no need to repeat the process on the current board WiFiManager wifiManager; // New OOB ESP8266 has no Wi-Fi credentials so will connect and not need the next command to be uncommented and compiled in, a used one with incorrect credentials will // so restart the ESP8266 and connect your PC to the wireless access point called 'ESP8266_AP' or whatever you call it below in "" wifiManager.resetSettings(); // Command to be included if needed, then connect to http://192.168.4.1/ and follow instructions to make the WiFi connection // Set a timeout until configuration is turned off, useful to retry or go to sleep in n-seconds wifiManager.setTimeout(180); //fetches ssid and password and tries to connect, if connections succeeds it starts an access point with the name called "ESP8266_AP" and waits in a blocking loop for configuration if(!wifiManager.autoConnect("ESP8266_AP")) { Serial.println(F("failed to connect and timeout occurred")); delay(3000); ESP.reset(); //reset and try again delay(5000); } // At this stage the WiFi manager will have successfully connected to a network, or if not will try again in 180-seconds //---------------------------------------------------------------------- Serial.println(F("WiFi connected..")); configTime(0 * 3600, 0, "pool.ntp.org", "time.nist.gov"); server.begin(); Serial.println(F("Webserver started...")); // Start the webserver Serial.println("Use this URL to connect: http://"+WiFi.localIP().toString()+"/");// Print the IP address //---------------------------------------------------------------------- Serial.print(F("Initializing SD card...")); if (!SD.begin(D8)) { // see if the card is present and can be initialised. Wemos SD-Card CS uses D8 Serial.println(F("Card failed, or not present")); SD_present = false; } else SD_present = true; if (SD_present) Serial.println(F("Card initialised.")); else Serial.println(F("Card not present, no SD Card data logging possible")); dht.begin(); //---------------------------------------------------------------------- server.on("/", systemSetup); // The client connected with no arguments e.g. http:192.160.0.40/ server.on("/TempHumi", display_temp_and_humidity); server.on("/TempDewp", display_temp_and_dewpoint); server.on("/Dialview", display_dial); server.on("/AScale", auto_scale); server.on("/AUpdate", auto_update); server.on("/Setup", systemSetup); server.on("/Help", help); server.on("/MaxT_U", max_temp_up); server.on("/MaxT_D", max_temp_down); server.on("/MinT_U", min_temp_up); server.on("/MinT_D", min_temp_down); server.on("/LogT_U", logtime_up); server.on("/LogT_D", logtime_down); if (SD_present) { server.on("/SDview", SD_view); server.on("/SDerase", SD_erase); server.on("/SDstats", SD_stats); } configTime(0 * 3600, 0, "pool.ntp.org", "time.nist.gov"); // Start time server index_ptr = 0; // The array pointer that varies from 0 to table_size log_count = 0; // Keeps a count of readings taken AScale = false; // Google charts can AScale axis, this switches the function on/off max_temp = 30; // Maximum displayed temperature as default min_temp = -10; // Minimum displayed temperature as default auto_smooth = false; // If true, transitions of more than 10% between readings are smoothed out, so a reading followed by another that is 10% higher or lower is averaged AUpdate = true; // Used to prevent a command from continually auto-updating, for example increase temp-scale would increase every 30-secs if not prevented from doing so. lastcall = "temp_humi"; // To determine what requested the AScale change log_interval = log_time_unit*20; // inter-log time interval, default is 5-minutes between readings, 10=15secs 40=1min 200=5mins 400=10mins 2400=1hr timer_cnt = log_interval + 1; // To trigger first table update, essential update_log_time(); // Update the log_time log_delete_approved = false; // Used to prevent accidental deletion of card contents, requires two approvals reset_array(); // Clear storage array before use prefill_array(); // Load old data from SD-Card back into display and readings array //Serial.println(system_get_free_heap_size()); // diagnostic print to check for available RAM time_t now = time(nullptr); delay(2000); // Wait for time to start //Serial.println(time(&now)); // Unix time epoch Serial.print(F("Logging started at: ")); Serial.println(calcDateTime(time(&now))); /*you can also obtain time and date like this struct tm *now_tm; int hour,min,second,day,month,year; now = time(NULL); now_tm = localtime(&now); hour = now_tm->tm_hour; min = now_tm->tm_min; second = now_tm->tm_sec; day = now_tm->tm_mday; month = now_tm->tm_mon; year = now_tm->tm_year + 1900; Serial.print(hour);Serial.print(":");Serial.print(min);Serial.print(":");Serial.println(second); Serial.print(day);Serial.print("/");Serial.print(month);Serial.print("/");Serial.println(year); */ } void loop() { server.handleClient(); sensor_t sensor; sensors_event_t event; dht.temperature().getEvent(&event); if (isnan(event.temperature)) Serial.println("Error reading temperature!"); else dht_temp = event.temperature*10; dht.humidity().getEvent(&event); if (isnan(event.relative_humidity)) Serial.println("Error reading humidity!"); else dht_humi = event.relative_humidity*10; time_t now = time(nullptr); time_now = String(ctime(&now)).substring(0,24); // Remove unwanted characters if (time_now != "Thu Jan 01 00:00:00 1970" and timer_cnt >= log_interval) { // If time is not yet set, returns 'Thu Jan 01 00:00:00 1970') so wait. timer_cnt = 0; // log_interval values are 10=15secs 40=1min 200=5mins 400=10mins 2400=1hr log_count += 1; // Increase logging event count sensor_data[index_ptr].lcnt = log_count; // Record current log number, time, temp and humidity readings sensor_data[index_ptr].temp = dht_temp; sensor_data[index_ptr].humi = dht_humi; sensor_data[index_ptr].ltime = calcDateTime(time(&now)); // time stamp of reading 'dd/mm/yy hh:mm:ss' if (SD_present){ // If the SD-Card is present and board fitted then append the next reading to the log file called 'datalog.txt' File dataFile = SD.open("datalog.txt", FILE_WRITE); if (dataFile) { // if the file is available, write to it dataFile.println(((log_count<10)?"0":"")+String(log_count)+char(9)+String(dht_temp/10,2)+char(9)+String(dht_humi/10,2)+char(9)+calcDateTime(time(&now))); // TAB delimited } dataFile.close(); } index_ptr += 1; // Increment data record pointer if (index_ptr > table_size) { // if number of readings exceeds max_readings (e.g. 100) then shift all array data to the left to effectively scroll the display left index_ptr = table_size; for (int i = 0; i < table_size; i++) { // If data table is full, scroll all readings to the left in graphical terms, then add new reading to the end sensor_data[i].lcnt = sensor_data[i+1].lcnt; sensor_data[i].temp = sensor_data[i+1].temp; sensor_data[i].humi = sensor_data[i+1].humi; sensor_data[i].ltime = sensor_data[i+1].ltime; } sensor_data[table_size].lcnt = log_count; sensor_data[table_size].temp = dht_temp; sensor_data[table_size].humi = dht_humi; sensor_data[table_size].ltime = calcDateTime(time(&now)); } } timer_cnt += 1; // Readings set by value of log_interval each 40 = 1min delay(498); // Delay before next check for a client, adjust for 1-sec repeat interval. Temperature readings take some time to complete. //Serial.println(millis()); } void prefill_array(){ // After power-down or restart and if the SD-Card has readings, load them back in if (SD_present){ File dataFile = SD.open("datalog.txt", FILE_READ); while (dataFile.available()) { // if the file is available, read from it int read_ahead = dataFile.parseInt(); // Sometimes at the end of file, NULL data is returned, this tests for that if (read_ahead != 0) { // Probably wasn't null data to use it, but first data element could have been zero and there is never a record 0! sensor_data[index_ptr].lcnt = read_ahead ; sensor_data[index_ptr].temp = dataFile.parseFloat()*10; sensor_data[index_ptr].humi = dataFile.parseFloat()*10; sensor_data[index_ptr].ltime = dataFile.readStringUntil('\n'); index_ptr += 1; log_count += 1; } if (index_ptr > table_size) { for (int i = 0; i < table_size; i++) { sensor_data[i].lcnt = sensor_data[i+1].lcnt; sensor_data[i].temp = sensor_data[i+1].temp; sensor_data[i].humi = sensor_data[i+1].humi; sensor_data[i].ltime = sensor_data[i+1].ltime; } index_ptr = table_size; } } dataFile.close(); if (auto_smooth) { // During restarts there can be a difference in readings, giving a spike in the graph, this smooths that out, off by default though // At this point the array holds data from the SD-Card, but sometimes during outage and resume, reading discontinuitie occur, so try to correct those. float last_temp,last_humi; for (int i = 1; i < table_size; i++) { last_temp = sensor_data[i].temp; last_humi = sensor_data[i].humi; // Correct next reading if it is more than 10% different from last values if ((sensor_data[i+1].temp > (last_temp * 1.1)) || (sensor_data[i+1].temp < (last_temp * 1.1))) sensor_data[i+1].temp = (sensor_data[i+1].temp+last_temp)/2; // +/-1% different then use last value if ((sensor_data[i+1].humi > (last_humi * 1.1)) || (sensor_data[i+1].humi < (last_humi * 1.1))) sensor_data[i+1].humi = (sensor_data[i+1].humi+last_humi)/2; } } } } void display_temp_and_humidity() { // Processes a clients request for a graph of the data // See google charts api for more details. To load the APIs, include the following script in the header of your web page. // // To autoload APIs manually, you need to specify the list of APIs to load in the initial "; webpage += ""; //webpage += ""; webpage += "
"; //----------------------------------- append_page_footer(); server.send(200, "text/html", webpage); webpage = ""; lastcall = "temp_humi"; } void display_temp_and_dewpoint() { // Processes a clients request for a graph of the data float dew_point; // See google charts api for more details. To load the APIs, include the following script in the header of your web page. // // To autoload APIs manually, you need to specify the list of APIs to load in the initial "; webpage += ""; //webpage += ""; webpage += "
"; //----------------------------------- append_page_footer(); server.send(200, "text/html", webpage); webpage = ""; lastcall = "temp_dewp"; } void display_dial (){ // Processes a clients request for a dial-view of the data log_delete_approved = false; // PRevent accidental SD-Card deletion webpage = ""; // don't delete this command, it ensures the server works reliably! append_page_header(); webpage += ""; webpage += ""; webpage += ""; webpage += "
"; webpage += "
"; append_page_footer(); server.send(200, "text/html", webpage); webpage = ""; lastcall = "dial"; } float Calc_DewPoint(float temp, float humi) { return 243.04*(log(humi/100)+((17.625*temp)/(243.04+temp)))/(17.625-log(humi/100)-((17.625*temp)/(243.04+temp))); } void reset_array() { for (int i = 0; i <= table_size; i++) { sensor_data[i].lcnt = 0; sensor_data[i].temp = 0; sensor_data[i].humi = 0; sensor_data[i].ltime = ""; } } // After the data has been displayed, select and copy it, then open Excel and Paste-Special and choose Text, then select and insert graph to view void SD_view() { if (SD_present) { File dataFile = SD.open("datalog.txt", FILE_READ); // Now read data from SD Card if (dataFile) { if (dataFile.available()) { // If data is available and present String dataType = "application/octet-stream"; if (server.streamFile(dataFile, dataType) != dataFile.size()) {Serial.print(F("Sent less data than expected!")); } } } dataFile.close(); // close the file: } webpage = ""; } void SD_erase() { // Erase the datalog file webpage = ""; // don't delete this command, it ensures the server works reliably! append_page_header(); if (AUpdate) webpage += ""; // 30-sec refresh time and test is needed to stop auto updates repeating some commands if (log_delete_approved) { if (SD_present) { File dataFile = SD.open("datalog.txt", FILE_READ); // Now read data from SD Card if (dataFile) if (SD.remove("datalog.txt")) Serial.println(F("File deleted successfully")); webpage += "

Log file 'datalog.txt' has been erased

"; log_count = 0; index_ptr = 0; timer_cnt = 2000; // To trigger first table update, essential log_delete_approved = false; // Re-enable sd card deletion } } else { log_delete_approved = true; webpage += "

Log file erasing is enabled, repeat this option to erase the log. Graph or Dial Views disable erasing again

"; } append_page_footer(); server.send(200, "text/html", webpage); webpage = ""; } void SD_stats(){ // Display file size of the datalog file webpage = ""; // don't delete this command, it ensures the server works reliably! append_page_header(); File dataFile = SD.open("datalog.txt", FILE_READ); // Now read data from SD Card webpage += "

Data Log file size = "+String(dataFile.size())+"-Bytes

"; dataFile.close(); append_page_footer(); server.send(200, "text/html", webpage); webpage = ""; } void auto_scale () { // Google Charts can auto-scale graph axis, this turns it on/off if (AScale) AScale = false; else AScale = true; if (lastcall == "temp_humi") display_temp_and_humidity(); if (lastcall == "temp_dewp") display_temp_and_dewpoint(); if (lastcall == "dial") display_dial(); } void auto_update () { // Google Charts can auto-scale graph axis, this turns it on/off if (AUpdate) AUpdate = false; else AUpdate = true; if (lastcall == "temp_humi") display_temp_and_humidity(); if (lastcall == "temp_dewp") display_temp_and_dewpoint(); if (lastcall == "dial") display_dial(); } void max_temp_up () { // Google Charts can auto-scale graph axis, this turns it on/off max_temp += 1; if (max_temp >60) max_temp = 60; if (lastcall == "temp_humi") display_temp_and_humidity(); if (lastcall == "temp_dewp") display_temp_and_dewpoint(); if (lastcall == "dial") display_dial(); } void max_temp_down () { // Google Charts can auto-scale graph axis, this turns it on/off max_temp -= 1; if (max_temp <0) max_temp = 0; if (lastcall == "temp_humi") display_temp_and_humidity(); if (lastcall == "temp_dewp") display_temp_and_dewpoint(); if (lastcall == "dial") display_dial(); } void min_temp_up () { // Google Charts can auto-scale graph axis, this turns it on/off min_temp += 1; if (lastcall == "temp_humi") display_temp_and_humidity(); if (lastcall == "temp_dewp") display_temp_and_dewpoint(); if (lastcall == "dial") display_dial(); } void min_temp_down () { // Google Charts can auto-scale graph axis, this turns it on/off min_temp -= 1; if (min_temp < -60) min_temp = -60; if (lastcall == "temp_humi") display_temp_and_humidity(); if (lastcall == "temp_dewp") display_temp_and_dewpoint(); if (lastcall == "dial") display_dial(); } void logtime_down () { // Timer_cnt delay values 10=15secs 40=1min 200=5mins 400=10mins 2400=1hr, increase the values with this function log_interval -= log_time_unit; if (log_interval < log_time_unit) log_interval = log_time_unit; update_log_time(); if (lastcall == "temp_humi") display_temp_and_humidity(); if (lastcall == "temp_dewp") display_temp_and_dewpoint(); if (lastcall == "dial") display_dial(); } void logtime_up () { // Timer_cnt delay values 10=15secs 40=1min 200=5mins 400=10mins 2400=1hr, increase the values with this function log_interval += log_time_unit; update_log_time(); if (lastcall == "temp_humi") display_temp_and_humidity(); if (lastcall == "temp_dewp") display_temp_and_dewpoint(); if (lastcall == "dial") display_dial(); } void update_log_time() { float log_hrs; log_hrs = table_size*log_interval/time_reference; log_hrs = log_hrs / 60; // Should not be needed, but compiler cant' calcuate the result in-line! float log_mins = (log_hrs - int(log_hrs))*60; log_time = String(int(log_hrs))+":"+((log_mins<10)?"0"+String(int(log_mins)):String(int(log_mins)))+" Hrs ("+String(log_interval)+")-secs between log entries"; log_time += ", Free-mem:("+String(system_get_free_heap_size())+")"; } void systemSetup() { webpage = ""; // don't delete this command, it ensures the server works reliably! append_page_header(); String IPaddress = WiFi.localIP().toString(); webpage += "

System Setup, if required enter values then choose Graph or Dial

"; webpage += ""; webpage += "
"; webpage += "Maximum Temperature on Graph axis (currently = "+String(max_temp)+char(176)+"C
"; webpage += "
"; webpage += "Minimum Temperature on Graph axis (currently = "+String(min_temp)+char(176)+"C
"; webpage += "
"; webpage += "Logging Interval (currently = "+String(log_interval)+"-Secs)
"; webpage += "
"; webpage += "Auto-scale Graph (currently = "+String(AScale?"ON":"OFF")+"
"; webpage += "
"; webpage += "Auto-update Graph (currently = "+String(AUpdate?"ON":"OFF")+"
"; webpage += "
"; webpage += "

"; webpage += "
"; append_page_footer(); server.send(200, "text/html", webpage); // Send a response to the client asking for input if (server.args() > 0 ) { // Arguments were received for ( uint8_t i = 0; i < server.args(); i++ ) { String Argument_Name = server.argName(i); String client_response = server.arg(i); if (Argument_Name == "max_temp_in") { if (client_response.toInt()) max_temp = client_response.toInt(); else max_temp = 30; } if (Argument_Name == "min_temp_in") { if (client_response.toInt() == 0) min_temp = 0; else min_temp = client_response.toInt(); } if (Argument_Name == "log_interval_in") { if (client_response.toInt()) log_interval = client_response.toInt(); else log_interval = 300; log_interval = client_response.toInt()*log_time_unit; } if (Argument_Name == "auto_scale") { if (client_response == "ON") AScale = true; else AScale = false; } if (Argument_Name == "auto_update") { if (client_response == "ON") AUpdate = true; else AUpdate = false; } } } webpage = ""; update_log_time(); } void append_page_header() { webpage = ""; if (AUpdate) webpage += ""; // 30-sec refresh time, test needed to prevent auto updates repeating some commands webpage += "DHT11 Sensor Readings

Autonomous Graphing Data Logger " + version + "

"; } void append_page_footer(){ // Saves repeating many lines of code for HTML page footers webpage += ""; webpage += ""; webpage += "

©"+String(char(byte(0x40>>1)))+String(char(byte(0x88>>1)))+String(char(byte(0x5c>>1)))+String(char(byte(0x98>>1)))+String(char(byte(0x5c>>1))); webpage += String(char((0x84>>1)))+String(char(byte(0xd2>>1)))+String(char(0xe4>>1))+String(char(0xc8>>1))+String(char(byte(0x40>>1))); webpage += String(char(byte(0x64/2)))+String(char(byte(0x60>>1)))+String(char(byte(0x62>>1)))+String(char(0x6c>>1))+"

"; webpage += ""; } String calcDateTime(int epoch){ int seconds, minutes, hours, dayOfWeek, current_day, current_month, current_year; seconds = epoch; minutes = seconds / 60; // calculate minutes seconds -= minutes * 60; // calculate seconds hours = minutes / 60; // calculate hours minutes -= hours * 60; current_day = hours / 24; // calculate days hours -= current_day * 24; current_year = 1970; // Unix time starts in 1970 dayOfWeek = 4; // on a Thursday while(1){ bool leapYear = (current_year % 4 == 0 && (current_year % 100 != 0 || current_year % 400 == 0)); uint16_t daysInYear = leapYear ? 366 : 365; if (current_day >= daysInYear) { dayOfWeek += leapYear ? 2 : 1; current_day -= daysInYear; if (dayOfWeek >= 7) dayOfWeek -= 7; ++current_year; } else { dayOfWeek += current_day; dayOfWeek %= 7; /* calculate the month and day */ static const uint8_t daysInMonth[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; for(current_month = 0; current_month < 12; ++current_month) { uint8_t dim = daysInMonth[current_month]; if (current_month == 1 && leapYear) ++dim; // add a day to February if a leap year if (current_day >= dim) current_day -= dim; else break; } break; } } current_month += 1; // Months are 0..11 and returned format is dd/mm/ccyy hh:mm:ss current_day += 1; String date_time = String(current_day) + "/" + String(current_month) + "/" + String(current_year) + " "; date_time += ((hours < 10) ? "0" + String(hours): String(hours)) + ":"; date_time += ((minutes < 10) ? "0" + String(minutes): String(minutes)) + ":"; date_time += ((seconds < 10) ? "0" + String(seconds): String(seconds)); return date_time; } void help() { webpage = ""; // don't delete this command, it ensures the server works reliably! append_page_header(); webpage += "
"; webpage += "Temperature&Humidity - a graph of temperature and humidity
"; webpage += "Temperature&Dewpoint - a graph of temperature and dewpoint
"; webpage += "Dial - displays current temperature and humidity values
"; webpage += "Max°C⇑ - increase maximum y-axis by 1°C;
"; webpage += "Max°C⇓ - decrease maximum y-axis by 1°C;
"; webpage += "Min°C⇑ - increase minimum y-axis by 1°C;
"; webpage += "Min°C⇓ - decrease minimum y-axis by 1°C;
"; webpage += "Logging⇓ - reduce logging speed with more time between log entries
"; webpage += "Logging⇑ - increase logging speed with less time between log entries
"; webpage += "Auto-scale(ON/OFF) - toggle the graph Auto-scale ON/OFF
"; webpage += "Auto-update(ON/OFF) - toggle screen Auto-refresh ON/OFF
"; webpage += "Setup - allows some settings to be adjusted

"; webpage += "The following functions are enabled when an SD-Card reader is fitted:
"; webpage += "Log Size - display log file size in bytes
"; webpage += "View Log - stream log file contents to the screen, copy and paste into a spreadsheet using paste special, text
"; webpage += "Erase Log - erase log file, needs two approvals using this function. Any data display function resets the initial erase approval

"; webpage += "
"; append_page_footer(); server.send(200, "text/html", webpage); webpage = ""; }