/*
Rui Santos
Complete project details at https://RandomNerdTutorials.com/esp32-web-server-microsd-card/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include "FS.h"
#include "SD.h"
#include "SPI.h"
#include "semaphores.h"
#include "time.h"
#define SD_CS 32
#define LED_PIN 13
#define SENSOR_PIN 35
// World time getting
#define NTP_SERVER "pool.ntp.org"
#define GMT_OFFSET_SEC 0
#define DAYLIGHT_OFFSET_SEC 0
// Create AsyncWebServer object on port 80
AsyncWebServer server(80);
bool ledActive = false;
constexpr unsigned int MS_IN_DAY = 24 * 60 * 60 * 1000;
#define SERVICE_FUNCTIONS_START {
void setLed(bool state) {
Serial.print("Set state ");
Serial.println(state);
ledActive = state;
if (state) {
digitalWrite(LED_PIN, HIGH);
} else {
digitalWrite(LED_PIN, LOW);
}
}
String getDirectoryInfo(String path, int levels) {
File dir = SD.open(path);
if (!dir.isDirectory()) {
Serial.println(String(dir.name()) + " IS NOT A DIRECTORY");
dir.close();
return "";
}
String name = String(dir.name());
if (name.length() == 0) {
name = "SD";
}
String json = "{\"n\":\"" + name + "\", \"l\":1, \"c\": [";
int childrenID = 0;
bool notFirst = false;
while (true) {
File entry = dir.openNextFile();
if (!entry) {
// No more files
break;
}
if (notFirst) {
json += ',';
}
if (entry.isDirectory()) {
if (levels > 0 || levels == -1) {
json += getDirectoryInfo((path + "/" + String(entry.name())), levels != -1 ? levels - 1 : -1);
} else {
json += "{\"n\":\"" + String(entry.name()) + "\", \"l\":0, \"c\": []}";
}
} else {
json += "{\"n\":\"" + String(entry.name()) + "\"}";
}
childrenID++;
notFirst = true;
entry.close();
}
json += "]}";
dir.close();
return json;
}
bool removeFile(String path) {
if (!SD.exists(path)) {
Serial.println("Path " + path + "doesn't exist!");
return false;
}
return SD.remove(path);
}
bool removeDirectory(String path) {
bool result = false;
File dir = SD.open(path);
if (!dir.isDirectory()) {
return result;
}
dir.rewindDirectory();
File entry;
while ((entry = dir.openNextFile())) {
String entryPath = String(path) + "/" + entry.name();
if (entry.isDirectory()) {
removeDirectory(entryPath.c_str());
} else {
result = SD.remove(entryPath);
}
entry.close();
}
dir.close();
result = SD.rmdir(path);
return result;
}
void initSDCard(){
if(!SD.begin(SD_CS)){
Serial.println("Card Mount Failed");
return;
}
}
void initWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin("Wokwi-GUEST", "", 6);
Serial.print("Connecting to WiFi ..");
while (WiFi.status() != WL_CONNECTED) {
Serial.print('.');
delay(1000);
}
Serial.println(WiFi.localIP());
}
bool getIsModuleRoot(String url) {
String str = String(url);
str.replace("/modules/", "");
if (str.indexOf("/") == -1 || str.indexOf("/") + 1 == str.length()) {
return true;
} else {
return false;
}
}
#define SERVICE_FUNCTIONS_END }
class DirPath {
protected:
int nodesNumber = 0;
String** nodes = nullptr;
String fullPath = "";
void UpdateFullPath() {
fullPath = "";
for (int i = 0; i < nodesNumber; i++) {
fullPath += "/" + *(nodes[i]);
}
}
public:
DirPath() {}
String FullPath() {
return fullPath;
}
DirPath* Add(String node) {
nodesNumber++;
if (nodes == nullptr) {
nodes = (String**)malloc(sizeof(String*) * nodesNumber);
}
nodes = (String**)realloc(nodes, sizeof(String*) * nodesNumber);
nodes[nodesNumber - 1] = new String(node);
this->UpdateFullPath();
return this;
}
void EnsureExists() {
String path = "/";
for (int i = 0; i < nodesNumber; i++) {
path += "/" + *(nodes[i]);
if (!SD.exists(path)) {
SD.mkdir(path);
}
}
}
};
class Logger {
protected:
DirPath* dirPath;
public:
Logger(DirPath* dirPath) {
this->dirPath = dirPath;
dirPath->EnsureExists();
}
virtual void Log(String text, String fileName = "log.txt") {
File file;
String fullPath = GetFullFilePath(fileName);
if (!SD.exists(fullPath)) {
file = SD.open(fullPath, FILE_WRITE);
} else {
file = SD.open(fullPath, FILE_APPEND);
}
file.println(text);
file.close();
}
String GetFullFilePath(String fileName) {
if (fileName[0] != '/') {
return dirPath->FullPath() + "/" + fileName;
}
return dirPath->FullPath() + fileName;
}
void Clear() {
if (SD.exists(dirPath->FullPath())) {
removeDirectory(dirPath->FullPath());
}
dirPath->EnsureExists();
}
};
class SensorLogger: public Logger {
private:
Logger* errorLogger = nullptr;
String errorLoggerFileName = "log.txt";
int pin;
int beta;
int measurementMinDelayMs;
ulong lastMeasurement = 0;
void LogError(String text, String fileName = "") {
if (errorLogger == nullptr) {
return;
}
if (fileName == "") {
fileName = errorLoggerFileName;
}
errorLogger->Log(text, fileName);
}
String GetFilenamesWithDateBetween(int yearFrom, int monthFrom, int dayFrom, int yearTo, int monthTo, int dayTo, bool all) {
String json = "[";
File dir = SD.open(dirPath->FullPath());
bool notFirst = false;
while(true) {
File entry = dir.openNextFile();
if (!entry) {
break;
} else if (entry.isDirectory()) {
entry.close();
continue;
}
if (!all) {
String from = addZeroIfNeeded(String(yearFrom)) + "_" + addZeroIfNeeded(String(monthFrom)) + "_" + addZeroIfNeeded(String(dayFrom)) + ".txt";
String to = addZeroIfNeeded(String(yearTo)) + "_" + addZeroIfNeeded(String(monthTo)) + "_" + addZeroIfNeeded(String(dayTo)) + ".txt";
Serial.println("FROM " + from + " TO " + to + " ? " + entry.name() + " " +
String(strcmp(entry.name(), from.c_str())) + " " + String(strcmp(entry.name(), to.c_str())));
if (strcmp(entry.name(), from.c_str()) < 0 || strcmp(entry.name(), to.c_str()) > 0) {
continue;
}
}
if (notFirst) {
json += ',';
}
if (!entry.isDirectory()) {
json += "\"" + String(entry.name()) + "\"";
}
notFirst = true;
entry.close();
}
json += "]";
dir.close();
return json;
}
String addZeroIfNeeded(String number)
{
if (number.length() == 1) {
return String("0" + number);
} else {
return number;
}
}
public:
SensorLogger(
DirPath* dirPath,
int pin,
int beta,
int measurementMinDelayMs,
Logger* errorLogger = nullptr,
String errorLoggerFileName = "log.txt",
bool removePreviousLogsOnCreate = false
): Logger(dirPath) {
this->pin = pin;
this->beta = beta;
this->errorLogger = errorLogger;
this->errorLoggerFileName = errorLoggerFileName;
this->measurementMinDelayMs = measurementMinDelayMs;
if (removePreviousLogsOnCreate) {
Clear();
}
dirPath->EnsureExists();
pinMode(pin, INPUT);
}
void Log(String info) {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
LogError("Unable to get world time");
return;
}
String fileName = String(1900 + timeinfo.tm_year) + "_" + addZeroIfNeeded(String(1 + timeinfo.tm_mon)) + "_" + addZeroIfNeeded(String(timeinfo.tm_mday)) + ".txt";
String text = addZeroIfNeeded(String(timeinfo.tm_hour)) + ":" + addZeroIfNeeded(String(timeinfo.tm_min)) + ":" + addZeroIfNeeded(String(timeinfo.tm_sec)) + " " + GetCurrentTemperature();
File file;
String fullPath = dirPath->FullPath() + "/" + fileName;
if (!SD.exists(fullPath)) {
file = SD.open(fullPath, FILE_WRITE);
} else {
file = SD.open(fullPath, FILE_APPEND);
}
file.println(text);
file.close();
}
void Measure() {
ulong timer = millis();
if (timer < lastMeasurement || timer - lastMeasurement > measurementMinDelayMs) {
lastMeasurement = timer;
Log(String(GetCurrentTemperature()));
}
}
String GetFilenamesWithDateBetween(int yearFrom, int monthFrom, int dayFrom, int yearTo, int monthTo, int dayTo) {
return GetFilenamesWithDateBetween(yearFrom, monthFrom, dayFrom, yearTo, monthTo, dayTo, false);
}
String GetFilenamesWithDateBetween() {
return GetFilenamesWithDateBetween(0, 0, 0, 0, 0, 0, true);
}
float GetCurrentTemperature() {
int analogValue = analogRead(pin);
return 1 / (log(1 / (4095. / analogValue - 1)) / beta + 1.0 / 298.15) - 273.15;
}
};
Logger* errorLogger;
SensorLogger* temperatureLogger;
Logger* racerGameScoreLogger;
DirPath* errorLogsPath = (new DirPath())->Add("logs")->Add("error");
DirPath* temperatureLogsPath = (new DirPath())->Add("logs")->Add("temperature");
DirPath* racerGameDataPath = (new DirPath())->Add("data")->Add("racer_game");
void setup() {
// Common initialization
Serial.begin(115200);
initWiFi();
initSDCard();
configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER);
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
Serial.println("Base");
request->send(SD, "/modules/desktop/index.html", "text/html");
});
server.on("/modules*", HTTP_GET, [](AsyncWebServerRequest *request) {
String url = request->url();
if (getIsModuleRoot(url)) {
request->send(SD, url + "/index.html");
Serial.println("Module " + url + "/index.html");
} else {
request->send(SD, request->url());
Serial.println("Get " + url);
}
});
// LED
pinMode(LED_PIN, OUTPUT);
setLed(false);
server.on("/api/ledState", HTTP_GET, [](AsyncWebServerRequest *request) {
String answer = String("{\"ledStatus\": ") + String(ledActive) + String("}");
request->send(200, "application/json", answer);
});
server.on("/api/setLedState/*", HTTP_POST, [](AsyncWebServerRequest *request){
String url = request->url();
Serial.println("Set led state: " + url);
bool state = (url[url.length() - 1] - '0') > 0;
setLed(state);
request->send(200);
});
// FS app api
server.on("/api/dirInfo/*", HTTP_GET, [](AsyncWebServerRequest *request) {
String path = request->url().substring(12);
String directoryInfo = getDirectoryInfo(path, 1);
Serial.println("Directory " + path);
Serial.println(directoryInfo);
request->send(200, "application/json", directoryInfo);
});
server.on("/api/download/*", HTTP_GET, [](AsyncWebServerRequest *request) {
String path = request->url().substring(13);
AsyncWebServerResponse *response = request->beginResponse(SD, path, "application/octet-stream", true);
request->send(response);
Serial.println("Download file " + path + " | " + "attachment; filename=\"" + path + "\"");
});
server.on("/api/rmDir/*", HTTP_POST, [](AsyncWebServerRequest *request) {
String path = request->url().substring(10);
bool result = removeDirectory(path);
request->send(200, "application/json", ("{\"success\": " + String(result ? '1' : '0') + "}"));
if (result) {
Serial.println("Removed directory " + path);
} else {
Serial.println("Failed to remove directory " + path);
}
});
server.on("/api/rmFile/*", HTTP_POST, [](AsyncWebServerRequest *request) {
String path = request->url().substring(11);
bool result = removeFile(path);
request->send(200, "application/json", ("{\"success\": " + String(result ? '1' : '0') + "}"));
if (result) {
Serial.println("Removed file " + path);
} else {
Serial.println("Failed to remove file " + path);
}
});
// Temperature logging app
errorLogger = new Logger(errorLogsPath);
temperatureLogger = new SensorLogger(
temperatureLogsPath,
SENSOR_PIN,
3950,
5000,
errorLogger,
"temperatureLogger.txt"
);
server.on("/api/sensorData", HTTP_GET, [](AsyncWebServerRequest *request) {
int yfrom, mfrom, dfrom;
int yto, mto, dto;
String response;
if (!(request->hasParam("yfrom") && request->hasParam("mfrom") && request->hasParam("dfrom") &&
request->hasParam("yto") && request->hasParam("mto") && request->hasParam("dto")
)) {
response = temperatureLogger->GetFilenamesWithDateBetween();
} else {
yfrom = std::stoi(request->getParam("yfrom")->value().c_str());
mfrom = std::stoi(request->getParam("mfrom")->value().c_str());
dfrom = std::stoi(request->getParam("dfrom")->value().c_str());
yto = std::stoi(request->getParam("yto")->value().c_str());
mto = std::stoi(request->getParam("mto")->value().c_str());
dto = std::stoi(request->getParam("dto")->value().c_str());
response = temperatureLogger->GetFilenamesWithDateBetween(yfrom, mfrom, dfrom, yto, mto, dto);
}
request->send(200, "application/json", response);
});
// Racer game app
racerGameDataPath->EnsureExists();
racerGameScoreLogger = new Logger(racerGameDataPath);
server.on("/api/scores", HTTP_GET, [](AsyncWebServerRequest *request) {
Serial.print("Racer game, path = " + racerGameScoreLogger->GetFullFilePath("scores.txt") + " : ");
if (SD.exists(racerGameScoreLogger->GetFullFilePath("scores.txt"))) {
Serial.println("data exist");
request->send(SD, racerGameScoreLogger->GetFullFilePath("scores.txt"));
} else {
Serial.println("data doesn't exist");
request->send(200, "text/plain", "");
}
});
server.on("/api/addNewScore", HTTP_POST, [](AsyncWebServerRequest *request) {
if (!request->hasParam("name") || !request->hasParam("score")) {
request->send(400);
return;
}
String name = request->getParam("name")->value();
String score = request->getParam("score")->value();
racerGameScoreLogger->Log(name + " " + score, "scores.txt");
request->send(200);
});
server.serveStatic("/", SD, "/");
server.begin();
}
void loop() {
temperatureLogger->Measure();
delay(100);
}