diff --git a/.clang-format b/.clang-format index 681f132..f1d6945 100644 --- a/.clang-format +++ b/.clang-format @@ -1,4 +1,4 @@ BasedOnStyle: LLVM IndentWidth: 4 -ColumnLimit: 100 +ColumnLimit: 120 BreakBeforeBraces: Attach diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..dcd99cc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "files.associations": { + "*.module": "php", + "*.php": "php", + "cmath": "cpp" + } +} \ No newline at end of file diff --git a/data/app.js b/data/app.js index bd199a1..498ab7c 100644 --- a/data/app.js +++ b/data/app.js @@ -1,71 +1,83 @@ async function fetchData() { - const response = await fetch('/read'); - return await response.json() + const response = await fetch("/read"); + return await response.json(); } async function updateUI(data) { - let temp, hum, rssi, devicename; - try { - const data = await fetchData(); + let temp, hum, rssi, devicename; + try { + const data = await fetchData(); - let tempValue, tempUnit; - if (data.display === 'c') { - tempValue = data.celsius.temperature; - tempUnit = 'C'; - } else { - tempValue = data.fahrenheit.temperature; - tempUnit = 'F'; - } - temp = `${tempValue.toFixed(1)}°${tempUnit}`; - hum = `${data.humidity.relative_perc.toFixed(1)}% RH`; - rssi = `RSSI: ${data.wifi.rssi} dBm`; - devicename = data.devicename; - } catch (error) { - console.error('Error fetching sensor data:', error); - temp = hum = rssi = devicename = 'Error'; + let tempValue, tempUnit; + if (data.display === "c") { + tempValue = data.celsius.temperature; + tempUnit = "C"; + } else { + tempValue = data.fahrenheit.temperature; + tempUnit = "F"; } - document.getElementById('devicename').textContent = devicename; - document.getElementById('temperature').textContent = temp; - document.getElementById('humidity').textContent = hum; - document.getElementById('rssi').textContent = rssi; - setTimeout(updateUI, 5000); + temp = `${tempValue.toFixed(1)}°${tempUnit}`; + hum = `${data.humidity.relative_perc.toFixed(1)}% RH`; + rssi = `RSSI: ${data.wifi.rssi} dBm`; + devicename = data.devicename; + } catch (error) { + console.error("Error fetching sensor data:", error); + temp = hum = rssi = devicename = "Error"; + } + document.getElementById("devicename").textContent = devicename; + document.getElementById("temperature").textContent = temp; + document.getElementById("humidity").textContent = hum; + document.getElementById("rssi").textContent = rssi; + setTimeout(updateUI, 5000); } async function showNotification(message, type) { - const notification = document.getElementById('notification'); - notification.innerHTML = message; - notification.className = `notification ${type}`; - notification.style.display = 'block'; + const notification = document.getElementById("notification"); + notification.innerHTML = message; + notification.className = `notification ${type}`; + notification.style.display = "block"; - setTimeout(() => { notification.style.display = 'none'; }, 10000); + setTimeout(() => { + notification.style.display = "none"; + }, 10000); } async function loadSettings() { - try { - const settings = await fetchData(); + try { + const settings = await fetchData(); + const overlay = document.getElementById("loadingOverlay"); - document.getElementById('devicename').value = settings.devicename; - document.getElementById('updateinterval').value = settings.updateinterval; - document.getElementById('tempoffset').value = settings.celsius.offset; - document.getElementById('humidityoffset').value = settings.humidity.relative_perc_offset; - document.getElementById('showfahrenheit').checked = settings.display === 'f'; - } catch (error) { - console.error('Error loading settings:', error); - } + document.getElementById("devicename").value = settings.devicename; + document.getElementById("updateinterval").value = settings.updateinterval; + document.getElementById("tempoffset").value = settings.celsius.offset; + document.getElementById("humidityoffset").value = + settings.humidity.relative_perc_offset; + document.getElementById("showfahrenheit").checked = + settings.display === "f"; + overlay.classList.add("hidden"); + } catch (error) { + console.error("Error loading settings:", error); + } } async function saveSettings(formData) { - try { - const response = await fetch('/settings', { method: 'POST', body: formData }); - const data = await response.json(); + try { + const response = await fetch("/settings", { + method: "POST", + body: formData, + }); + const data = await response.json(); - if (data.status === "success") { - showNotification("Settings saved successfully!", "success"); - } else if (data.status === "error" && data.errors.length > 0) { - showNotification("Errors: ", "error"); - } else { - showNotification("Unexpected response from server", "error"); - } - } catch (error) { - showNotification("Failed to save settings: " + error.message, "error"); + if (data.status === "success") { + showNotification("Settings saved successfully!", "success"); + } else if (data.status === "error" && data.errors.length > 0) { + showNotification( + "Errors: ", + "error" + ); + } else { + showNotification("Unexpected response from server", "error"); } -} \ No newline at end of file + } catch (error) { + showNotification("Failed to save settings: " + error.message, "error"); + } +} diff --git a/data/index.html b/data/index.html index 518ae2f..1ff09bc 100644 --- a/data/index.html +++ b/data/index.html @@ -1,32 +1,42 @@ - - - + + + Temperature Display - - - - + + + + - - - - + + + +
-

Please wait…

-
--°
-
--% RH
-
RSSI: --
+

Please wait…

+
--°
+
--% RH
+
RSSI: --
- - - \ No newline at end of file + + diff --git a/data/main.css b/data/main.css index 4fc31fb..46804c8 100644 --- a/data/main.css +++ b/data/main.css @@ -1,156 +1,192 @@ -html, body { - margin: 0; - padding: 10px; - height: 100%; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - line-height: 1.6; - color: #333; - max-width: 600px; - margin: 0 auto; - background-color: #f5f5f5; - box-sizing: border-box; +html, +body { + margin: 0; + padding: 10px; + height: 100%; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: #333; + max-width: 600px; + margin: 0 auto; + background-color: #f5f5f5; + box-sizing: border-box; } .main { - display: flex; - justify-content: center; - align-items: center; + display: flex; + justify-content: center; + align-items: center; } .container { - background-color: white; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - padding: 25px; + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 25px; } .main .container { - text-align: center; - width: 300px; - margin: 0; + text-align: center; + width: 300px; + margin: 0; } h1 { - color: #2c3e50; - margin-top: 0; - margin-bottom: 25px; - font-size: 24px; + color: #2c3e50; + margin-top: 0; + margin-bottom: 25px; + font-size: 24px; } .form-group { - margin-bottom: 20px; + margin-bottom: 20px; } label { - display: block; - font-weight: 600; + display: block; + font-weight: 600; } input[type="text"], input[type="number"] { - width: 100%; - padding: 10px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 16px; - box-sizing: border-box; + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 16px; + box-sizing: border-box; } input[type="checkbox"] { - margin-right: 8px; - transform: scale(1.2); + margin-right: 8px; + transform: scale(1.2); } input[type="submit"] { - margin-left: 10px; + margin-left: 10px; } .checkbox-label { - display: flex; - align-items: center; - cursor: pointer; + display: flex; + align-items: center; + cursor: pointer; } button { - background-color: #007bff; - color: white; - border: none; - padding: 12px 20px; - border-radius: 4px; - cursor: pointer; - font-size: 16px; - font-weight: 600; - transition: background-color 0.2s; + background-color: #007bff; + color: white; + border: none; + padding: 12px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + font-weight: 600; + transition: background-color 0.2s; } button.secondary { - background-color: #6c757d; + background-color: #6c757d; } button:hover { - background-color: #0069d9; + background-color: #0069d9; } button.secondary:hover { - background-color: #5a6268; + background-color: #5a6268; } input:invalid + .error { - display: block; + display: block; } .footer { - text-align: right; - margin-top: 20px; + text-align: right; + margin-top: 20px; } .notification { - display: none; - padding: 10px; - margin-bottom: 15px; - border-radius: 5px; - font-weight: bold; - text-align: center; + display: none; + padding: 10px; + margin-bottom: 15px; + border-radius: 5px; + font-weight: bold; + text-align: center; } .notification.success { - background-color: #d4edda; - color: #155724; - border: 1px solid #c3e6cb; + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; } .notification.error { - background-color: #f8d7da; - color: #721c24; - border: 1px solid #f5c6cb; + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; } .help-text { - font-size: 12px; - color: #666; - margin-top: 5px; + font-size: 12px; + color: #666; + margin-top: 5px; } #temperature { - font-size: 48px; - font-weight: bold; - margin: 20px 0; + font-size: 48px; + font-weight: bold; + margin: 20px 0; } #humidity { - font-size: 24px; - margin: 15px 0; + font-size: 24px; + margin: 15px 0; } #rssi { - font-size: 10px; - color: #888; - margin-top: 20px; + font-size: 10px; + color: #888; + margin-top: 20px; } .settings-icon, .home-icon { - position: absolute; - top: 10px; - right: 10px; - width: 24px; - height: 24px; - cursor: pointer; -} \ No newline at end of file + position: absolute; + top: 10px; + right: 10px; + width: 24px; + height: 24px; + cursor: pointer; +} + +.overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.spinner { + width: 50px; + height: 50px; + border: 5px solid #f3f3f3; + border-top: 5px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.hidden { + display: none; +} diff --git a/data/settings.html b/data/settings.html index 858f709..83a9f7a 100644 --- a/data/settings.html +++ b/data/settings.html @@ -1,70 +1,123 @@ - - - + + + Device Configuration - - - - + + + + +
+
+
+ - - - + + +
-

Device Configuration

+

Device Configuration

+ +
+
+
+ + +
+ Note that this is not the Web UI update interval! +
+
+ +
+
+ + +
+
+ When unchecked, temperature will be shown in Celsius +
+
+ +
+ + +
In Celsius, regardless of above settings
+
+ +
+ + +
+ +
+ + +
-
- -
- - -
Note that this is not the Web UI update interval!
-
- -
-
- - -
-
When unchecked, temperature will be shown in Celsius
-
- -
- - -
In Celsius, regardless of above settings
-
- -
- - -
- -
- - -
- - -
+ +
- - \ No newline at end of file + + diff --git a/lib/Display/display.cpp b/lib/Display/display.cpp new file mode 100644 index 0000000..ae1ad85 --- /dev/null +++ b/lib/Display/display.cpp @@ -0,0 +1,45 @@ +#include "display.h" +#include "sensordata.h" +#include + +Display::Display(Logger &log, uint8_t w, uint8_t h) : _logger(log), _width(w), _height(h), _display(w, h, &Wire, -1) {} + +void Display::begin() { + Wire.begin(); + + // Initialize SSD1306 display + if (!_display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // 0x3C is the I2C address of SSD1306 + _logger.error("SSD1306 allocation failed"); + ESP.restart(); + } + + _display.clearDisplay(); + _display.setRotation(2); + _display.setTextSize(2); + _display.setTextColor(SSD1306_WHITE); + + _display.display(); +} + +void Display::showMeasurements(SensorData &data, bool showFahrenheit) { + _display.clearDisplay(); + + _display.setCursor(0, 0); + _display.print("Temp: "); + _display.setCursor(15, 15); + _display.print(data.getTemperatureDisplay(showFahrenheit)); + + _display.setCursor(0, 30); + _display.print("Humidity: "); + _display.setCursor(15, 45); + _display.print(data.getHumidityDisplay()); + + _display.display(); +} + +void Display::setStatus(const String &status) { + _display.clearDisplay(); + _display.setCursor(0, 0); + _display.print(status); + _display.display(); +} \ No newline at end of file diff --git a/lib/Display/display.h b/lib/Display/display.h new file mode 100644 index 0000000..dee8a9e --- /dev/null +++ b/lib/Display/display.h @@ -0,0 +1,22 @@ +#ifndef DISPLAY_H +#define DISPLAY_H + +#include "../Logger/logger.h" +#include "sensordata.h" +#include + +class Display { + public: + Display(Logger &log, uint8_t width, uint8_t height); + void begin(); + void setStatus(const String &status); + void showMeasurements(SensorData &data, bool showFahrenheit); + + private: + Logger &_logger; // Reference to the logger + uint8_t _width; + uint8_t _height; + Adafruit_SSD1306 _display; +}; + +#endif // DISPLAY_H diff --git a/lib/Logger/logger.cpp b/lib/Logger/logger.cpp new file mode 100644 index 0000000..f4ff150 --- /dev/null +++ b/lib/Logger/logger.cpp @@ -0,0 +1,43 @@ +#include "logger.h" + +Logger::Logger(Stream &output) : _output(output), _logLevel(INFO) {} + +void Logger::setLogLevel(Level level) { _logLevel = level; } + +void Logger::log(Level level, const char *message) { + if (level >= _logLevel) { + _output.print("["); + _output.print(levelToString(level)); + _output.print("] "); + _output.println(message); + } +} + +void Logger::log(Level level, const String &message) { + log(level, message.c_str()); // Convert String to const char* +} + +void Logger::debug(const char *message) { log(DEBUG, message); } +void Logger::info(const char *message) { log(INFO, message); } +void Logger::warn(const char *message) { log(WARN, message); } +void Logger::error(const char *message) { log(ERROR, message); } + +void Logger::debug(const String &message) { log(DEBUG, message); } +void Logger::info(const String &message) { log(INFO, message); } +void Logger::warn(const String &message) { log(WARN, message); } +void Logger::error(const String &message) { log(ERROR, message); } + +const char *Logger::levelToString(Level level) { + switch (level) { + case DEBUG: + return "DEBUG"; + case INFO: + return "INFO"; + case WARN: + return "WARN"; + case ERROR: + return "ERROR"; + default: + return "UNKNOWN"; + } +} diff --git a/lib/Logger/logger.h b/lib/Logger/logger.h new file mode 100644 index 0000000..e9dba64 --- /dev/null +++ b/lib/Logger/logger.h @@ -0,0 +1,32 @@ +#ifndef LOGGER_H +#define LOGGER_H + +#include // Required for Serial and String handling + +class Logger { + public: + enum Level { DEBUG, INFO, WARN, ERROR }; + + Logger(Stream &output); // Constructor + + void setLogLevel(Level level); + void log(Level level, const char *message); + void log(Level level, const String &message); + + void debug(const char *message); + void info(const char *message); + void warn(const char *message); + void error(const char *message); + + void debug(const String &message); + void info(const String &message); + void warn(const String &message); + void error(const String &message); + + private: + Stream &_output; + Level _logLevel; + const char *levelToString(Level level); +}; + +#endif // LOGGER_H diff --git a/lib/OTA/ota.cpp b/lib/OTA/ota.cpp new file mode 100644 index 0000000..f8cf0c7 --- /dev/null +++ b/lib/OTA/ota.cpp @@ -0,0 +1,37 @@ +#include "ota.h" +#include + +void OTA::begin(const char *const hostname, const char *const password) { + ArduinoOTA.setHostname(hostname); + ArduinoOTA.setPassword(password); + ArduinoOTA.onStart([this]() { + String type = (ArduinoOTA.getCommand() == U_FLASH) ? "sketch" : "filesystem"; + _logger.info(("Start updating " + type).c_str()); + }); + ArduinoOTA.onEnd([this]() { _logger.info("Update Complete"); }); + ArduinoOTA.onProgress([this](unsigned int progress, unsigned int total) { + String message = "Updating: " + String((progress * 100) / total) + "%"; + _statuscallback(message.c_str()); + _logger.info(message.c_str()); + }); + ArduinoOTA.onError([this](ota_error_t error) { + _statuscallback("Error updating"); + _logger.error(("Error: " + String(error)).c_str()); + if (error == OTA_AUTH_ERROR) { + _logger.error("Auth Failed"); + } else if (error == OTA_BEGIN_ERROR) { + _logger.error("Begin Failed"); + } else if (error == OTA_CONNECT_ERROR) { + _logger.error("Connect Failed"); + } else if (error == OTA_RECEIVE_ERROR) { + _logger.error("Receive Failed"); + } else if (error == OTA_END_ERROR) { + _logger.error("End Failed"); + } + }); + + _logger.info("Starting OTA server"); + ArduinoOTA.begin(); +} + +void OTA::handle() { ArduinoOTA.handle(); } \ No newline at end of file diff --git a/lib/OTA/ota.h b/lib/OTA/ota.h new file mode 100644 index 0000000..f84b26a --- /dev/null +++ b/lib/OTA/ota.h @@ -0,0 +1,20 @@ +#ifndef OTA_H +#define OTA_H + +#include "../Logger/logger.h" +#include "../statuscallback.h" +#include + +class OTA { + public: + OTA(Logger &log) : _logger(log), _statuscallback(emptyStatus) {}; + void begin(const char *const hostname, const char *const password); + void handle(); + void onStatus(StatusCallback callback) { _statuscallback = callback; } + + private: + Logger &_logger; // Reference to the logger + StatusCallback _statuscallback; +}; + +#endif // OTA_H diff --git a/lib/Sensor/sensor.cpp b/lib/Sensor/sensor.cpp new file mode 100644 index 0000000..5aa56f4 --- /dev/null +++ b/lib/Sensor/sensor.cpp @@ -0,0 +1,18 @@ +#include "sensor.h" + +Sensor::Sensor(Logger &log) : _logger(log) {} + +void Sensor::begin(uint8_t i2cAddress) { + if (!_sht31.begin(i2cAddress)) { + _logger.error("Couldn't find GXHT30 sensor!"); + ESP.restart(); + } +} + +SensorData Sensor::readData(float tempOffset, float humidityOffset) { + SensorData data(_sht31.readTemperature(), tempOffset, _sht31.readHumidity(), humidityOffset); + if (isnan(data.temperature_c) || isnan(data.humidity)) { + _logger.error("Failed to read sensor data"); + } + return data; +} \ No newline at end of file diff --git a/lib/Sensor/sensor.h b/lib/Sensor/sensor.h new file mode 100644 index 0000000..11ca69b --- /dev/null +++ b/lib/Sensor/sensor.h @@ -0,0 +1,19 @@ +#ifndef SENSOR_H +#define SENSOR_H + +#include "../Logger/logger.h" +#include "sensordata.h" +#include + +class Sensor { + public: + Sensor(Logger &log); + void begin(uint8_t i2cAddress = 0x44); // Default I2C address for SHT31 + SensorData readData(float tempOffset, float humidityOffset); + + private: + Logger &_logger; // Reference to the logger + Adafruit_SHT31 _sht31 = Adafruit_SHT31(); +}; + +#endif // SENSOR_H \ No newline at end of file diff --git a/lib/Sensor/sensordata.h b/lib/Sensor/sensordata.h new file mode 100644 index 0000000..7b60247 --- /dev/null +++ b/lib/Sensor/sensordata.h @@ -0,0 +1,50 @@ +#ifndef SENSORDATA_H +#define SENSORDATA_H + +#include + +struct SensorData { + float temperature_c; + float temperature_f; + float offset_c; + float offset_f; + float humidity; + float humidity_offset; + + static constexpr float factorCtoF = 9.0 / 5.0; + static float toFahrenheit(float celsius) { return std::isnan(celsius) ? NAN : (celsius * factorCtoF) + 32.0; } + static float offsetToFahrenheit(float celsius) { return celsius * factorCtoF; } + + SensorData(float temp_c = NAN, float offset_c = 0, float hum = NAN, float hum_offset = 0) + : temperature_c(temp_c), temperature_f(toFahrenheit(temp_c)), offset_c(offset_c), + offset_f(offsetToFahrenheit(offset_c)), humidity(hum), humidity_offset(hum_offset) {} + + float getTemperatureDisplayValue(bool showFahrenheit = false) const { + return showFahrenheit ? temperature_f + offset_f : temperature_c + offset_c; + } + float getHumidityDisplayValue() const { return humidity + humidity_offset; } + + String getTemperatureDisplay(bool showFahrenheit = false) const { + float t = getTemperatureDisplayValue(showFahrenheit); + if (std::isnan(t)) { + return "N/A"; + } + + char buffer[10]; + snprintf(buffer, sizeof(buffer), "%.2f %s", t, showFahrenheit ? "F" : "C"); + return String(buffer); + } + + String getHumidityDisplay() const { + float h = getHumidityDisplayValue(); + if (std::isnan(h)) { + return "N/A"; + } + + char buffer[10]; + snprintf(buffer, sizeof(buffer), "%.2f %%RH", h); + return String(buffer); + } +}; + +#endif // SENSORDATA_H \ No newline at end of file diff --git a/lib/Settings/appsettings.h b/lib/Settings/appsettings.h new file mode 100644 index 0000000..1386e37 --- /dev/null +++ b/lib/Settings/appsettings.h @@ -0,0 +1,12 @@ +#ifndef APPSETTINGS_H +#define APPSETTINGS_H + +struct AppSettings { + unsigned long updateInterval; + bool showFahrenheit; + float tempOffset; + float humidityOffset; + char deviceName[32]; +}; + +#endif // APPSETTINGS_H \ No newline at end of file diff --git a/lib/Settings/settings.cpp b/lib/Settings/settings.cpp new file mode 100644 index 0000000..d86e70f --- /dev/null +++ b/lib/Settings/settings.cpp @@ -0,0 +1,48 @@ +#include "settings.h" +#include "appsettings.h" + +const char *Settings::SETTINGS_FILENAME = "/settings.bin"; + +void Settings::begin() { LittleFS.begin(); } + +bool Settings::loadSettings(AppSettings &settings, std::function getDefaultSettings) { + File file = LittleFS.open(_filename, "r"); + if (!file || file.size() < sizeof(AppSettings)) { + getDefaultSettings(settings); + _statuscallback("Default settings loaded"); + _logger.warn("Using default settings"); + return true; // No settings file, used default settings + } + size_t bytesRead = file.read((uint8_t *)&settings, sizeof(AppSettings)); + file.close(); + if (bytesRead == sizeof(AppSettings)) { + _statuscallback("Settings loaded successfully"); + _logger.info("Settings loaded successfully"); + return true; + } else { + _statuscallback("Failed to load settings"); + _logger.error("Failed reading settings"); + } + return false; // Failed to read settings +} + +bool Settings::saveSettings(const AppSettings &newsettings, AppSettings &settings) { + File file = LittleFS.open(_filename, "w"); + if (!file) { + _statuscallback("Failed to save settings"); + _logger.error("Failed to open file for writing"); + return false; + } + size_t bytesWritten = file.write((uint8_t *)&newsettings, sizeof(AppSettings)); + file.close(); + settings = newsettings; + if (bytesWritten == sizeof(AppSettings)) { + _statuscallback("Settings saved successfully"); + _logger.info("Settings saved successfully"); + return true; + } else { + _statuscallback("Failed writing settings"); + _logger.error("Failed to write settings"); + } + return false; // Failed to write settings +} \ No newline at end of file diff --git a/lib/Settings/settings.h b/lib/Settings/settings.h new file mode 100644 index 0000000..eb909f5 --- /dev/null +++ b/lib/Settings/settings.h @@ -0,0 +1,26 @@ +#ifndef SETTINGS_H +#define SETTINGS_H + +#include "../Logger/logger.h" +#include "../statuscallback.h" +#include "appsettings.h" +#include + +class Settings { + public: + Settings(Logger &log, String filename = SETTINGS_FILENAME) : _logger(log), _filename(filename), _statuscallback(emptyStatus) {} + // SimpleSettings(Logger &log, const char *filename = SETTINGS_FILENAME) + // : SimpleSettings(log, String(filename)) {} + void begin(); + bool loadSettings(AppSettings &settings, std::function getDefaultSettings); + bool saveSettings(const AppSettings &newsettings, AppSettings &settings); + void onStatus(StatusCallback callback) { _statuscallback = callback; } + static const char *SETTINGS_FILENAME; + + private: + Logger &_logger; // Reference to the logger + String _filename; + StatusCallback _statuscallback; +}; + +#endif // SETTINGS_H diff --git a/lib/SimpleWiFi/simplewifi.cpp b/lib/SimpleWiFi/simplewifi.cpp new file mode 100644 index 0000000..95718f1 --- /dev/null +++ b/lib/SimpleWiFi/simplewifi.cpp @@ -0,0 +1,41 @@ +#include "simplewifi.h" + +void SimpleWiFi::begin(unsigned long portalTimeout, unsigned long wifiConnectTimeout, unsigned long wifiConnectRetries, + const char *apName, const char *apPassword) { + _statuscallback("Starting WiFi"); + _logger.info("Starting WiFi"); + _wifiManager.setConfigPortalTimeout(portalTimeout); + _wifiManager.setConnectTimeout(wifiConnectTimeout); + _wifiManager.setConnectRetries(wifiConnectRetries); + _wifiManager.setWiFiAutoReconnect(true); + if (!_wifiManager.autoConnect(apName, apPassword)) { + _statuscallback("Autoconnect failed. Restarting"); + _logger.error("Autoconnect failed. Restarting"); + delay(3000); + ESP.restart(); + } +} + +void SimpleWiFi::ensureConnected() { + if ((WiFi.status() != WL_CONNECTED)) { + _statuscallback("Reconnecting WiFi"); + _logger.warn("WiFi not connected, reconnecting"); + WiFi.reconnect(); + unsigned long start = millis(); + + while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) { + delay(100); + } + + if (WiFi.status() == WL_CONNECTED) { + _statuscallback("WiFi reconnected"); + _logger.info("WiFi reconnected"); + } else { + _statuscallback("Reconnect WiFi failed. Retrying"); + _logger.warn("Failed to reconnect to WiFi. Retrying..."); + delay(1000); + } + } +} + +String SimpleWiFi::localIP() { return WiFi.localIP().toString(); } \ No newline at end of file diff --git a/lib/SimpleWiFi/simplewifi.h b/lib/SimpleWiFi/simplewifi.h new file mode 100644 index 0000000..0a12f1b --- /dev/null +++ b/lib/SimpleWiFi/simplewifi.h @@ -0,0 +1,24 @@ +#ifndef SIMPLEWIFI_H +#define SIMPLEWIFI_H + +#include "../Logger/logger.h" +#include "../statuscallback.h" +#include + +class SimpleWiFi { + public: + SimpleWiFi(Logger &log) : _logger(log), _statuscallback(emptyStatus) {} + + void begin(unsigned long portalTimeout, unsigned long wifiConnectTimeout, unsigned long wifiConnectRetries, + const char *apName, const char *apPassword = (const char *)__null); + void ensureConnected(); + String localIP(); + void onStatus(StatusCallback callback) { _statuscallback = callback; } + + private: + Logger &_logger; // Reference to the logger + WiFiManager _wifiManager; + StatusCallback _statuscallback; +}; + +#endif // SIMPLEWIFI_H \ No newline at end of file diff --git a/lib/Webserver/webserver.cpp b/lib/Webserver/webserver.cpp new file mode 100644 index 0000000..a80f65a --- /dev/null +++ b/lib/Webserver/webserver.cpp @@ -0,0 +1,30 @@ +#include "webserver.h" + +void Webserver::useDefaultEndpoints() { + _server.on("/reset", HTTP_PUT, [this]() { + _server.send(200, "text/html", "reset"); + _statuscallback("Restarting"); + _logger.warn("Restarting"); + ESP.restart(); + }); +} + +void Webserver::serveStatic(const char *uri, const char *path, const char *cacheheader) { + _server.serveStatic(uri, _fs, path, cacheheader); +} + +void Webserver::begin() { + _fs.begin(); + _server.begin(); + _logger.info("HTTP server started"); +} + +void Webserver::handleClient() { _server.handleClient(); } + +void Webserver::sendJson(const JsonDocument &json, int httpCode) { + String response; + serializeJson(json, response); + _server.send(httpCode, "application/json", response); +} + +const String &Webserver::arg(const String &name) const { return _server.arg(name); } \ No newline at end of file diff --git a/lib/Webserver/webserver.h b/lib/Webserver/webserver.h new file mode 100644 index 0000000..c44267e --- /dev/null +++ b/lib/Webserver/webserver.h @@ -0,0 +1,41 @@ +#ifndef WEBSERVER_H +#define WEBSERVER_H + +#include "../Logger/logger.h" +#include "../statuscallback.h" +#include +#include +#include +#include + +class Webserver { + public: + Webserver(Logger &log, FS fs, int port = 80) : _logger(log), _fs(fs), _server(port), _statuscallback(emptyStatus) { + _httpUpdateServer.setup(&_server); + _server.onNotFound([this]() { _server.send(404, "text/plain", "File not found"); }); + }; + void useDefaultEndpoints(); + void begin(); + void handleClient(); + void sendJson(const JsonDocument &json, int httpCode = 200); + const String &arg(const String &name) const; + void serveStatic(const char *, const char *path, const char *cacheheader = "max-age=300"); + void on(const String &uri, ESP8266WebServer::THandlerFunction fn) { _server.on(uri, fn); } + void on(const String &uri, HTTPMethod method, ESP8266WebServer::THandlerFunction fn) { + _server.on(uri, method, fn); + } + void on(const String &uri, HTTPMethod method, ESP8266WebServer::THandlerFunction fn, + ESP8266WebServer::THandlerFunction ufn) { + _server.on(uri, method, fn, ufn); + } + void onStatus(StatusCallback callback) { _statuscallback = callback; } + + private: + Logger &_logger; // Reference to the logger + FS _fs; + ESP8266WebServer _server; + ESP8266HTTPUpdateServer _httpUpdateServer; + StatusCallback _statuscallback; +}; + +#endif // WEBSERVER_H diff --git a/lib/statuscallback.h b/lib/statuscallback.h new file mode 100644 index 0000000..9cf9e4e --- /dev/null +++ b/lib/statuscallback.h @@ -0,0 +1,8 @@ +#ifndef STATUS_CALLBACK_H +#define STATUS_CALLBACK_H + +using StatusCallback = void (*)(const char*); + +inline void emptyStatus(const char*) {} + +#endif // STATUS_CALLBACK_H \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index 1d32109..e8824e4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -28,7 +28,6 @@ board_build.filesystem = littlefs board_build.ldscript = eagle.flash.4m1m.ld lib_deps = adafruit/Adafruit SHT31 Library@^2.2.2 - adafruit/Adafruit GFX Library@^1.12.0 adafruit/Adafruit SSD1306@^2.5.13 bblanchon/ArduinoJson@^7.2.0 tzapu/WiFiManager@^2.0.17 diff --git a/src/main.cpp b/src/main.cpp index 903d9b9..c6b0360 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,299 +1,154 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include "appsettings.h" +#include "display.h" +#include "ota.h" +#include "sensor.h" +#include "settings.h" +#include "simplewifi.h" +#include "webserver.h" #include // See include/example_config.h for configuration. Make sure you copy it // to include/config.h and enter your configuration data there. -Adafruit_SHT31 sht31 = Adafruit_SHT31(); -Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1); // -1 means no reset pin -ESP8266WebServer server(HTTP_PORT); -ESP8266HTTPUpdateServer httpUpdateServer; +Logger logger(Serial); +OTA ota(logger); +SimpleWiFi wifi(logger); +Settings settings(logger); +Sensor sensor(logger); +Display display(logger, SCREEN_WIDTH, SCREEN_HEIGHT); +Webserver server(logger, LittleFS, HTTP_PORT); +SensorData lastreading; +AppSettings appsettings; -float temperature; -float humidity; unsigned long previousMillis = 0; // Store the last time a measurement was taken -const char *const SETTINGS_FILENAME = "/settings.bin"; -struct Settings { - unsigned long updateInterval; - bool showFahrenheit; - float tempOffset; - float humidityOffset; - char deviceName[32]; -}; - -Settings settings; - -void setStatus(const String &status) { - Serial.println(status); - - display.clearDisplay(); - display.setCursor(0, 0); - display.print(status); - display.display(); -} - -void restart(const String &status) { - setStatus(status); - delay(3000); - ESP.restart(); -} - -bool loadSettings() { - File file = LittleFS.open(SETTINGS_FILENAME, "r"); - if (!file || file.size() < sizeof(Settings)) { - settings.updateInterval = UPDATEINTERVAL; - settings.tempOffset = TEMPERATUREOFFSET; - settings.humidityOffset = HUMIDITYOFFSET; - settings.showFahrenheit = SHOWFAHRENHEIT ? true : false; - strncpy(settings.deviceName, DEVICENAME, sizeof(settings.deviceName) - 1); - settings.deviceName[sizeof(settings.deviceName) - 1] = '\0'; // Ensure null termination - return false; // No settings file, use default settings - } - size_t bytesRead = file.read((uint8_t *)&settings, sizeof(Settings)); - file.close(); - return bytesRead == sizeof(Settings); -} - -bool saveSettings(const Settings &newsettings) { - File file = LittleFS.open(SETTINGS_FILENAME, "w"); - if (!file) { - Serial.println("Failed to open file for writing"); - return false; - } - size_t bytesWritten = file.write((uint8_t *)&newsettings, sizeof(Settings)); - file.close(); - settings = newsettings; - return bytesWritten == sizeof(Settings); -} - -void serveFile(const char *path, const char *contentType = "text/html", int cacheTTL = 300) { - File file = LittleFS.open(path, "r"); - if (!file) { - server.send(404, "text/plain", "File not found"); - return; - } - server.sendHeader("Cache-Control", "max-age=" + String(cacheTTL)); - server.streamFile(file, contentType); - file.close(); -} - -float factorCtoF = 9.0 / 5.0; -float toFahrenheit(float celsius) { return (celsius * factorCtoF) + 32.0; } -float offsetToFahrenheit(float celsius) { return celsius * factorCtoF; } +void setStatus(const char *status); +void handleReading(); +void handleSettings(); void setup() { Serial.begin(SERIAL_BAUDRATE); - Wire.begin(); - - // Initialize SSD1306 display - if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // 0x3C is the I2C address of SSD1306 - setStatus("SSD1306 allocation failed"); - ESP.restart(); - } - - display.clearDisplay(); - display.setRotation(2); - display.setTextSize(2); - display.setTextColor(SSD1306_WHITE); - - if (!LittleFS.begin()) { - setStatus("Failed to mount filesystem"); - } - - if (loadSettings()) { - setStatus("Settings loaded"); - } else { - setStatus("Using default settings"); - } - - WiFiManager wifiManager; - wifiManager.setConfigPortalTimeout(PORTALTIMEOUT); - wifiManager.setConnectTimeout(WIFICONNECTTIMEOUT); - wifiManager.setConnectRetries(WIFICONNECTRETRIES); - wifiManager.setWiFiAutoReconnect(true); - if (!wifiManager.autoConnect(settings.deviceName)) { - restart("Autconnect failed..."); - } - - // Initialize GXHT30 - if (!sht31.begin(0x44)) { // 0x44 is the I2C address of GXHT30 - setStatus("Couldn't find GXHT30 sensor!"); - ESP.restart(); - } - httpUpdateServer.setup(&server); - server.on("/read", HTTP_GET, []() { - JsonDocument root; - JsonObject celsiusnode = root["celsius"].to(); - celsiusnode["temperature"] = temperature; - celsiusnode["offset"] = settings.tempOffset; - JsonObject fahrenheitnode = root["fahrenheit"].to(); - fahrenheitnode["temperature"] = toFahrenheit(temperature); - fahrenheitnode["offset"] = offsetToFahrenheit(settings.tempOffset); - JsonObject humiditynode = root["humidity"].to(); - humiditynode["relative_perc"] = humidity; - humiditynode["relative_perc_offset"] = settings.humidityOffset; - JsonObject wifinode = root["wifi"].to(); - wifinode["rssi"] = WiFi.RSSI(); + display.begin(); - char lastUpdateStr[32]; - snprintf(lastUpdateStr, sizeof(lastUpdateStr), "%.2f seconds ago", - (millis() - previousMillis) / 1000.0); - root["lastupdate"] = lastUpdateStr; - root["display"] = settings.showFahrenheit ? "f" : "c"; - root["devicename"] = settings.deviceName; - root["updateinterval"] = settings.updateInterval; + settings.onStatus(setStatus); + settings.begin(); - String response; - serializeJson(root, response); - - server.send(200, "application/json", response); - }); - - server.on("/reset", HTTP_PUT, []() { - server.send(200, "text/html", "reset"); - restart("Restarting"); - }); - - server.on("/", HTTP_GET, []() { serveFile("/index.html"); }); - server.on("/css", HTTP_GET, []() { serveFile("/main.css", "text/css"); }); - server.on("/js", HTTP_GET, []() { serveFile("/app.js", "text/javascript"); }); - server.on("/favicon.ico", HTTP_GET, []() { serveFile("/favicon.svg", "image/svg+xml", 60 * 60 * 24); }); - server.on("/settings", HTTP_GET, []() { serveFile("/settings.html"); }); - server.on("/settings", HTTP_POST, []() { - Settings newsettings; - JsonDocument root; - root["status"] = ""; - JsonArray errors = root["errors"].to(); - newsettings.updateInterval = server.arg("updateinterval").toInt(); - if (newsettings.updateInterval < 1000 || newsettings.updateInterval > 60000) { - errors.add("Update interval must be between 1000 and 60000 ms"); - } - newsettings.showFahrenheit = server.arg("showfahrenheit") == "f"; - - newsettings.tempOffset = server.arg("tempoffset").toFloat(); - if (newsettings.tempOffset < -50 || newsettings.tempOffset > 50) { - errors.add("Temperature offset must be between -50 and 50"); - } - newsettings.humidityOffset = server.arg("humidityoffset").toFloat(); - if (newsettings.humidityOffset < -50 || newsettings.humidityOffset > 50) { - errors.add("Humidity offset must be between -50 and 50"); - } - strncpy(newsettings.deviceName, server.arg("devicename").c_str(), - sizeof(newsettings.deviceName) - 1); - newsettings.deviceName[sizeof(newsettings.deviceName) - 1] = '\0'; // Ensure null termination - if (strlen(newsettings.deviceName) == 0 || strlen(newsettings.deviceName) > 31) { - errors.add("Device name must be between 1 and 31 characters"); - } - - if (errors.size() == 0 && saveSettings(newsettings)) { - root["status"] = "success"; - setStatus("Settings saved"); - } else { - root["status"] = "error"; - } - String response; - serializeJson(root, response); - - server.send(200, "application/json", response); + settings.loadSettings(appsettings, [](AppSettings &defaults) { + defaults.updateInterval = UPDATEINTERVAL; + defaults.tempOffset = TEMPERATUREOFFSET; + defaults.humidityOffset = HUMIDITYOFFSET; + defaults.showFahrenheit = SHOWFAHRENHEIT ? true : false; + strncpy(appsettings.deviceName, DEVICENAME, sizeof(appsettings.deviceName) - 1); + defaults.deviceName[sizeof(appsettings.deviceName) - 1] = '\0'; // Ensure null termination }); - server.onNotFound([]() { server.send(404, "text/plain", "File not found"); }); - - Serial.println("Starting HTTP server"); + wifi.onStatus(setStatus); + wifi.begin(PORTALTIMEOUT, WIFICONNECTTIMEOUT, WIFICONNECTRETRIES, appsettings.deviceName); + + sensor.begin(); + + server.serveStatic("/", "/index.html"); + server.serveStatic("/css", "/main.css"); + server.serveStatic("/js", "/app.js"); + server.serveStatic("/favicon.ico", "/favicon.svg"); + server.serveStatic("/settings", "/settings.html"); + server.on("/read", HTTP_GET, handleReading); + server.on("/settings", HTTP_POST, handleSettings); + server.useDefaultEndpoints(); + server.onStatus(setStatus); server.begin(); - ArduinoOTA.setPassword(OTAPASSWORD); - ArduinoOTA.setHostname(settings.deviceName); - ArduinoOTA.onStart([]() { - String type = (ArduinoOTA.getCommand() == U_FLASH) ? "sketch" : "filesystem"; - setStatus("Start updating " + type); - }); - ArduinoOTA.onEnd([]() { setStatus("Update Complete"); }); - ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { - setStatus("Progress: " + String((progress * 100) / total) + "%"); - }); - ArduinoOTA.onError([](ota_error_t error) { - setStatus("Error: " + String(error)); - if (error == OTA_AUTH_ERROR) - setStatus("Auth Failed"); - else if (error == OTA_BEGIN_ERROR) - setStatus("Begin Failed"); - else if (error == OTA_CONNECT_ERROR) - setStatus("Connect Failed"); - else if (error == OTA_RECEIVE_ERROR) - setStatus("Receive Failed"); - else if (error == OTA_END_ERROR) - setStatus("End Failed"); - }); - - setStatus("Starting OTA server"); - ArduinoOTA.begin(); + ota.onStatus(setStatus); + ota.begin(appsettings.deviceName, OTAPASSWORD); - setStatus(WiFi.localIP().toString()); + setStatus(wifi.localIP().c_str()); delay(2000); } void loop() { server.handleClient(); - ArduinoOTA.handle(); - if ((WiFi.status() != WL_CONNECTED)) { - setStatus("WiFi not connected, reconnecting..."); - WiFi.reconnect(); - unsigned long start = millis(); - - while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) { - delay(100); - } - - if (WiFi.status() == WL_CONNECTED) { - setStatus("WiFi reconnected"); - } else { - setStatus("Failed to reconnect to WiFi. Retrying..."); - delay(1000); - } - } + ota.handle(); + wifi.ensureConnected(); unsigned long currentMillis = millis(); // Get the current time // Check if it's time to take another measurement - if (currentMillis - previousMillis >= settings.updateInterval) { + if (currentMillis - previousMillis >= appsettings.updateInterval) { previousMillis = currentMillis; - temperature = sht31.readTemperature() + settings.tempOffset; - humidity = constrain(sht31.readHumidity() + settings.humidityOffset, 0, 100); - Serial.printf("Temperature: %.2f C, %.2f F, humidity: %.2f %%RH\n", temperature, - toFahrenheit(temperature), humidity); + SensorData reading = sensor.readData(appsettings.tempOffset, appsettings.humidityOffset); + + char buffer[64]; + snprintf(buffer, sizeof(buffer), "Temperature: %s, humidity: %s", + reading.getTemperatureDisplay(appsettings.showFahrenheit).c_str(), + reading.getHumidityDisplay().c_str()); + logger.info(String(buffer)); + + display.showMeasurements(reading, appsettings.showFahrenheit); + + lastreading = reading; // Store the last reading + } +} - display.clearDisplay(); +void handleReading() { + JsonDocument response; + JsonObject celsiusnode = response["celsius"].to(); + celsiusnode["temperature"] = lastreading.getTemperatureDisplayValue(false); + celsiusnode["offset"] = lastreading.offset_c; + JsonObject fahrenheitnode = response["fahrenheit"].to(); + fahrenheitnode["temperature"] = lastreading.getTemperatureDisplayValue(true); + fahrenheitnode["offset"] = lastreading.offset_f; + JsonObject humiditynode = response["humidity"].to(); + humiditynode["relative_perc"] = lastreading.getHumidityDisplayValue(); + humiditynode["relative_perc_offset"] = lastreading.humidity_offset; + JsonObject wifinode = response["wifi"].to(); + wifinode["rssi"] = WiFi.RSSI(); + + char lastUpdateStr[32]; + unsigned long lastupdate = millis() - previousMillis; + snprintf(lastUpdateStr, sizeof(lastUpdateStr), "%.2f seconds ago", lastupdate / 1000.0); + response["lastupdate"] = lastUpdateStr; + response["lastupdate_ms"] = lastupdate; + response["display"] = appsettings.showFahrenheit ? "f" : "c"; + response["devicename"] = appsettings.deviceName; + response["updateinterval"] = appsettings.updateInterval; + server.sendJson(response); +} - display.setCursor(0, 0); - display.print("Temp: "); - display.setCursor(15, 15); - if (settings.showFahrenheit) { - display.print(toFahrenheit(temperature)); - display.print(" F"); - } else { - display.print(temperature); - display.print(" C"); - } +void handleSettings() { + AppSettings newsettings; + JsonDocument response; + response["status"] = ""; + JsonArray errors = response["errors"].to(); + newsettings.updateInterval = server.arg("updateinterval").toInt(); + if (newsettings.updateInterval < 1000 || newsettings.updateInterval > 60000) { + errors.add("Update interval must be between 1000 and 60000 ms"); + } + newsettings.showFahrenheit = server.arg("showfahrenheit") == "f"; - display.setCursor(0, 30); - display.print("Humidity: "); - display.setCursor(15, 45); - display.print(humidity); - display.print(" %"); + newsettings.tempOffset = server.arg("tempoffset").toFloat(); + if (newsettings.tempOffset < -50 || newsettings.tempOffset > 50) { + errors.add("Temperature offset must be between -50 and 50"); + } + newsettings.humidityOffset = server.arg("humidityoffset").toFloat(); + if (newsettings.humidityOffset < -50 || newsettings.humidityOffset > 50) { + errors.add("Humidity offset must be between -50 and 50"); + } + strncpy(newsettings.deviceName, server.arg("devicename").c_str(), sizeof(newsettings.deviceName) - 1); + newsettings.deviceName[sizeof(newsettings.deviceName) - 1] = '\0'; // Ensure null termination + if (strlen(newsettings.deviceName) == 0 || strlen(newsettings.deviceName) > 31) { + errors.add("Device name must be between 1 and 31 characters"); + } - display.display(); + if (errors.size() == 0 && settings.saveSettings(newsettings, appsettings)) { + response["status"] = "success"; + } else { + response["status"] = "error"; } + server.sendJson(response); } + +void setStatus(const char *status) { + display.setStatus(status); +} \ No newline at end of file