commit a4df54a80bcc8bd261bc0ad526300e9abb9ee019 Author: tovjemam Date: Mon Sep 7 15:04:21 2026 +0200 Init diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..ea1fc60 --- /dev/null +++ b/.clang-format @@ -0,0 +1,9 @@ +BasedOnStyle: Microsoft +DerivePointerAlignment: false +PointerAlignment: Left +IndentWidth: 4 # spaces per indent level +TabWidth: 4 # width of a tab character +UseTab: Never # options: Never, ForIndentation, Alwayss +AccessModifierOffset: -4 +BreakTemplateDeclarations: Yes +AllowShortFunctionsOnASingleLine: Inline diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b91c73c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch +data/wifi.json \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/data/wifi-example.json b/data/wifi-example.json new file mode 100644 index 0000000..24f6f26 --- /dev/null +++ b/data/wifi-example.json @@ -0,0 +1,5 @@ +{ + "ap": true, + "ssid": "bojler", + "password": "bojler123" +} \ No newline at end of file diff --git a/data/www/index.html b/data/www/index.html new file mode 100644 index 0000000..f287cda --- /dev/null +++ b/data/www/index.html @@ -0,0 +1 @@ + Bojler

🌡️ bojler

Aktuální teplota
-- °C

Stav ventilu

❓ Neznámý

⚙️ Nastavení
📈 Historie teploty
📋 Záznam událostí
Načítám události...
\ No newline at end of file diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..24be255 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,25 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:nodemcuv2] +platform = espressif8266 +board = nodemcuv2 +framework = arduino +monitor_speed = 115200 +board_build.filesystem = littlefs +board_build.ldscript = eagle.flash.4m1m.ld + +lib_deps = + https://github.com/me-no-dev/ESPAsyncTCP.git + https://github.com/me-no-dev/ESPAsyncWebServer.git + paulstoffregen/OneWire @ ^2.3.8 + milesburton/DallasTemperature @ ^4.0.6 + arkhipenko/TaskScheduler @ ^4.0.8 + bblanchon/ArduinoJson @ ^6.21.3 \ No newline at end of file diff --git a/res/index.html b/res/index.html new file mode 100644 index 0000000..48db01b --- /dev/null +++ b/res/index.html @@ -0,0 +1,486 @@ + + + + + + + Bojler + + + + + + + +
+ +
+
+

🌡️ bojler

+ +
+ +
+
+
+
Aktuální teplota
+
-- °C
+ +
+ +
Stav ventilu
+

❓ Neznámý

+
+
+ +
+
+
⚙️ Nastavení
+
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+
+
+
📈 Historie teploty
+ +
+
+ +
+
+
+
+ +
+
+
+
+
📋 Záznam událostí
+ +
+
+
Načítám události...
+
+
+
+
+ +
+ + + + + \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..d333c06 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,490 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static bool s_wifi_ap_mode = true; +static String s_wifi_ssid = "bojler"; +static String s_wifi_password = "bojler123"; + +Scheduler scheduler; + +constexpr int ONE_WIRE_BUS = D2; +constexpr int VALVE_PIN = D5; +constexpr int ALARM_PIN = LED_BUILTIN; // TODO: change to separate pin + +OneWire one_wire(ONE_WIRE_BUS); +DallasTemperature sensors(&one_wire); + +static float s_temp = 0.0; +static float s_t0 = 20.0; // Lower threshold +static float s_t1 = 30.0; // Upper threshold +static bool s_valve_open = false; // false = closed, true = open + +static float s_temp_accum = 0.0f; +static int s_temp_count = 0; +// constexpr int TEMP_INTERVAL = 10000; // 10 seconds +constexpr int TEMP_INTERVAL = 1000; +// constexpr int TEMP_AVERAGING_INTERVAL = 6*15; // 15 minutes (6 readings of 10 seconds each) +constexpr int TEMP_AVERAGING_INTERVAL = 10; + +constexpr size_t TEMP_HISTORY_CAPACITY = 4*24*5; // 5 days of 15-minute intervals +static float s_temp_history[TEMP_HISTORY_CAPACITY] = {0.0}; +static size_t s_temp_history_index = 0; +static size_t s_temp_history_count = 0; + +enum LogType : uint8_t +{ + LOG_INFO = 0, + LOG_WARNING = 1, + LOG_ERROR = 2 +}; + +struct LogEntry +{ + long timestamp; + String message; + LogType type; +}; + +constexpr size_t LOG_CAPACITY = 32; +static LogEntry s_log[LOG_CAPACITY]; +static size_t s_log_index = 0; +static size_t s_log_count = 0; + +static void BlinkLed(uint32_t time) +{ + digitalWrite(LED_BUILTIN, LOW); + delay(time); + digitalWrite(LED_BUILTIN, HIGH); + delay(time); +} + +static void SetupSensorsAndPins() +{ + // pinMode(ONE_WIRE_BUS, INPUT); + sensors.begin(); + sensors.setWaitForConversion(true); // Non-blocking mode + sensors.setResolution(12); + + // alarm output + pinMode(ALARM_PIN, OUTPUT); + digitalWrite(ALARM_PIN, HIGH); + + // valve input + pinMode(VALVE_PIN, INPUT_PULLUP); +} + +static void LogTempHistory(float temp) +{ + s_temp_history[s_temp_history_index] = temp; + + s_temp_history_index = (s_temp_history_index + 1) % TEMP_HISTORY_CAPACITY; + + if (s_temp_history_count < TEMP_HISTORY_CAPACITY) + { + s_temp_history_count++; + } +} + +static inline long GetCurrentTime() +{ + return (long)time(nullptr); +} + +static void LogMessage(LogType type, String message) +{ + auto& log_entry = s_log[s_log_index]; + log_entry.timestamp = GetCurrentTime(); + log_entry.type = type; + log_entry.message = std::move(message); + + s_log_index = (s_log_index + 1) % LOG_CAPACITY; + + if (s_log_count < LOG_CAPACITY) + { + s_log_count++; + } + +// #ifdef BOJLER_DEBUG + Serial.printf("[%s] %s\n", + (type == LOG_INFO) ? "INFO" + : (type == LOG_WARNING) ? "WARNING" + : "ERROR", + log_entry.message.c_str()); + // #endif +} + +static void LogTemp(float temp) +{ + s_temp_accum += temp; + s_temp_count++; + + if (s_temp_count >= TEMP_AVERAGING_INTERVAL) + { + float avg_temp = s_temp_accum / s_temp_count; + LogTempHistory(avg_temp); + s_temp_accum = 0.0f; + s_temp_count = 0; + } +} + +static void ReadTemp() +{ +#ifdef BOJLER_DEBUG + Serial.println("Measuring temperature..."); +#endif // BOJLER_DEBUG + +sensors.requestTemperatures(); + + float temp = sensors.getTempCByIndex(0); + +#ifdef BOJLER_DEBUG + Serial.printf("Temperature: %.2f °C | %.2f °F\n", temp, DallasTemperature::toFahrenheit(temp)); +#endif // BOJLER_DEBUG + + if (temp == DEVICE_DISCONNECTED_C) + { + LogMessage(LOG_ERROR, "Nepodařilo se přečíst teplotu z čidla."); + } + else + { + s_temp = temp; + } + + LogTemp(s_temp); +} + +static void SignalAlarm() +{ + for (int i = 0; i < 3; ++i) + { + digitalWrite(ALARM_PIN, LOW); + delay(100); + digitalWrite(ALARM_PIN, HIGH); + delay(100); + } +} + +static void SignalAlarmIfNeeded() +{ + if ((s_temp < s_t0 && s_valve_open) || (s_temp > s_t1 && !s_valve_open)) + { + SignalAlarm(); + } +} + +static void ReadValve() +{ + bool valve_open = digitalRead(VALVE_PIN) == LOW; // LOW = open + if (valve_open != s_valve_open) + { + s_valve_open = valve_open; + LogMessage(LOG_INFO, valve_open ? "Ventil otevřen." : "Ventil zavřen."); + } + + SignalAlarmIfNeeded(); +} + +static void ReadSensors() +{ + BlinkLed(100); + ReadTemp(); + ReadValve(); +} + +Task temp_task(TEMP_INTERVAL, TASK_FOREVER, &ReadSensors); + +static void SetupFS() +{ + if (!LittleFS.begin()) + { + Serial.println("Error: Failed to mount LittleFS filesystem."); + } +} + +static void SetupLed() +{ + pinMode(LED_BUILTIN, OUTPUT); + digitalWrite(LED_BUILTIN, HIGH); +} + +static void LoadWiFiConfig() +{ + if (!LittleFS.exists("/wifi.json")) + { + Serial.println("WiFi config file not found. Using default values."); + return; + } + + File file = LittleFS.open("/wifi.json", "r"); + if (!file) + { + Serial.println("Failed to open WiFi config file for reading. Using default values."); + return; + } + + size_t size = file.size(); + std::unique_ptr buf(new char[size + 1]); + file.readBytes(buf.get(), size); + buf[size] = '\0'; + + DynamicJsonDocument doc(256); + DeserializationError error = deserializeJson(doc, buf.get()); + if (error) + { + Serial.print("Failed to parse WiFi config JSON: "); + Serial.println(error.c_str()); + return; + } + + if (doc.containsKey("ap") && doc.containsKey("ssid") && doc.containsKey("password")) + { + s_wifi_ap_mode = doc["ap"].as(); + s_wifi_ssid = doc["ssid"].as(); + s_wifi_password = doc["password"].as(); + Serial.printf("Loaded WiFi config: ap=%d, ssid=%s, password=%s\n", s_wifi_ap_mode, s_wifi_ssid.c_str(), s_wifi_password.c_str()); + } + else + { + Serial.println("WiFi config JSON is missing required keys. Using default values."); + } +} + +static void SetupWiFi() +{ + LoadWiFiConfig(); + + static WiFiEventHandler s_wifi_connected_handler; + static WiFiEventHandler s_wifi_got_ip_handler; + static WiFiEventHandler s_wifi_disconnected_handler; + + s_wifi_connected_handler = WiFi.onStationModeConnected([](const WiFiEventStationModeConnected& event) { + LogMessage(LOG_INFO, "Připojeno k síti " + String(s_wifi_ssid) + ", RSSI: " + String(WiFi.RSSI()) + " dBm"); + }); + + s_wifi_got_ip_handler = WiFi.onStationModeGotIP([](const WiFiEventStationModeGotIP& event) { + LogMessage(LOG_INFO, "Získána IP adresa: " + WiFi.localIP().toString()); + }); + + s_wifi_disconnected_handler = WiFi.onStationModeDisconnected([](const WiFiEventStationModeDisconnected& event) { + LogMessage(LOG_WARNING, "Připojení ztraceno. Důvod: " + String(event.reason)); + }); + + if (s_wifi_ap_mode) + { + WiFi.mode(WIFI_AP); + WiFi.softAP(s_wifi_ssid.c_str(), s_wifi_password.c_str()); + } + else + { + WiFi.mode(WIFI_STA); + WiFi.begin(s_wifi_ssid.c_str(), s_wifi_password.c_str()); + WiFi.setAutoReconnect(true); + + while (WiFi.status() != WL_CONNECTED && WiFi.status() != WL_CONNECT_FAILED) + { + BlinkLed(250); + Serial.print("."); + } + } +} + +static void LoadThresholds() +{ + if (!LittleFS.exists("/thresholds.json")) + { + Serial.println("Thresholds file not found. Using default values."); + return; + } + + File file = LittleFS.open("/thresholds.json", "r"); + if (!file) + { + Serial.println("Failed to open thresholds file for reading."); + return; + } + + size_t size = file.size(); + std::unique_ptr buf(new char[size + 1]); + file.readBytes(buf.get(), size); + buf[size] = '\0'; + + DynamicJsonDocument doc(256); + DeserializationError error = deserializeJson(doc, buf.get()); + if (error) + { + Serial.print("Failed to parse thresholds JSON: "); + Serial.println(error.c_str()); + return; + } + + if (doc.containsKey("t0") && doc.containsKey("t1")) + { + s_t0 = doc["t0"].as(); + s_t1 = doc["t1"].as(); + Serial.printf("Loaded thresholds: t0=%.2f, t1=%.2f\n", s_t0, s_t1); + } +} + +static void SaveThresholds() +{ + DynamicJsonDocument doc(256); + doc["t0"] = s_t0; + doc["t1"] = s_t1; + + File file = LittleFS.open("/thresholds.json", "w"); + if (!file) + { + Serial.println("Failed to open thresholds file for writing."); + return; + } + + if (serializeJson(doc, file) == 0) + { + Serial.println("Failed to write thresholds to file."); + } + else + { + Serial.println("Thresholds saved successfully."); + } + + file.flush(); +} + +inline bool IsValidTemperature(float temp) +{ + return temp > -50.0f && temp < 150.0f; +} + +static void SetThresholds(float t0, float t1) +{ + if (t0 == s_t0 && t1 == s_t1) + { + return; + } + + s_t0 = t0; + s_t1 = t1; + SaveThresholds(); + LogMessage(LOG_INFO, String("Mezní hodnoty byly nastaveny na: t0=") + String(t0) + ", t1=" + String(t1)); +} + +// Create AsyncWebServer object on port 80 +AsyncWebServer server(80); + +static void SetupServer() +{ + Serial.print("Web Server URL: http://"); + Serial.println(WiFi.localIP()); + + // frontend + server.on("/", HTTP_GET, + [](AsyncWebServerRequest* request) { request->send(LittleFS, "/www/index.html", "text/html"); }); + + // /api/status + server.on("/api/status", HTTP_GET, [](AsyncWebServerRequest* request) { + String valve_state_str = s_valve_open ? "1" : "0"; + String json_str = "{ \"temp\": " + String(s_temp, 2) + ", \"valve\": " + valve_state_str + + ", \"t0\": " + String(s_t0, 2) + ", \"t1\": " + String(s_t1, 2) + " }"; + request->send(200, "application/json", json_str); + }); + + // /api/history + server.on("/api/history", HTTP_GET, [](AsyncWebServerRequest* request) { + String json_str = "["; + for (size_t i = 0; i < s_temp_history_count; ++i) + { + size_t index = (s_temp_history_index + TEMP_HISTORY_CAPACITY - s_temp_history_count + i) % TEMP_HISTORY_CAPACITY; + json_str += String(s_temp_history[index], 2); + if (i < s_temp_history_count - 1) + { + json_str += ","; + } + } + json_str += "]"; + request->send(200, "application/json", json_str); + }); + + // /api/log + server.on("/api/log", HTTP_GET, [](AsyncWebServerRequest* request) { + String json_str = "["; + for (size_t i = 0; i < s_log_count; ++i) + { + size_t index = (s_log_index + LOG_CAPACITY - s_log_count + i) % LOG_CAPACITY; + const LogEntry& entry = s_log[index]; + json_str += "{\"ts\":" + String(entry.timestamp) + ",\"t\":" + String(entry.type) + + ",\"m\":\"" + entry.message + "\"}"; + if (i < s_log_count - 1) + { + json_str += ","; + } + } + json_str += "]"; + request->send(200, "application/json", json_str); + }); + + // /api/thresholds + auto thresholds_handler = new AsyncCallbackJsonWebHandler("/api/thresholds", [](AsyncWebServerRequest* request, JsonVariant& json) { + if (json.is()) + { + JsonObject obj = json.as(); + if (obj.containsKey("t0") && obj.containsKey("t1")) + { + auto t0 = obj["t0"].as(); + auto t1 = obj["t1"].as(); + + if (IsValidTemperature(t0) && IsValidTemperature(t1) && t0 < t1) + { + SetThresholds(t0, t1); + request->send(200, "application/json", "{ \"status\": \"success\" }"); + return; + } + } + } + request->send(400, "application/json", "{ \"status\": \"error\", \"message\": \"Invalid JSON payload\" }"); + }, 512); + + server.addHandler(thresholds_handler); + + server.begin(); +} + +void setup() +{ + Serial.begin(115200); + delay(500); + Serial.println("INIT..."); + + SetupFS(); + LoadThresholds(); + SetupLed(); + SetupWiFi(); + + // Setup NTP time synchronization + constexpr int timezone = 3600; // UTC+1 + constexpr int daylightOffset_sec = 3600; // 1 hour in seconds + configTime(timezone, daylightOffset_sec, "pool.ntp.org", "time.nist.gov"); + + SetupSensorsAndPins(); + SetupServer(); + + // setup tasks + scheduler.init(); + scheduler.addTask(temp_task); + temp_task.enable(); + + LogMessage(LOG_INFO, "Inicializace dokončena."); +} + +void loop() +{ + scheduler.execute(); +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html