This commit is contained in:
tovjemam 2026-09-07 15:04:21 +02:00
commit a4df54a80b
11 changed files with 1126 additions and 0 deletions

9
.clang-format Normal file
View File

@ -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

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
data/wifi.json

10
.vscode/extensions.json vendored Normal file
View File

@ -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"
]
}

5
data/wifi-example.json Normal file
View File

@ -0,0 +1,5 @@
{
"ap": true,
"ssid": "bojler",
"password": "bojler123"
}

1
data/www/index.html Normal file

File diff suppressed because one or more lines are too long

37
include/README Normal file
View File

@ -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

46
lib/README Normal file
View File

@ -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 <Foo.h>
#include <Bar.h>
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

25
platformio.ini Normal file
View File

@ -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

486
res/index.html Normal file
View File

@ -0,0 +1,486 @@
<!DOCTYPE html>
<html lang="cs" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bojler</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
/* Definice barev: zelená -> žlutá -> červená */
:root {
--color-low: #198754;
/* Zelená pod t0 */
--color-ok: #ffc107;
/* Žlutá v rozmezí t0-t1 */
--color-high: #dc3545;
/* Červená nad t1 */
}
[data-bs-theme="dark"] {
--color-low: #2ecc71;
/* Jemnější sytá zelená */
--color-ok: #f1c40f;
/* Výrazná žlutá */
--color-high: #e74c3c;
/* Sytá červená */
}
.temp-low {
color: var(--color-low) !important;
}
.temp-ok {
color: var(--color-ok) !important;
}
.temp-high {
color: var(--color-high) !important;
}
.temp-display {
font-size: 4rem;
font-weight: 700;
transition: color 0.3s ease;
}
.card {
border-radius: 15px;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
[data-bs-theme="dark"] body {
background-color: #121212;
}
[data-bs-theme="dark"] .card {
background-color: #1e1e1e;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.5);
}
[data-bs-theme="dark"] .form-control,
[data-bs-theme="dark"] .form-select {
background-color: #2b2b2b;
border-color: #3a3a3a;
color: #ffffff;
}
#notification {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
display: none;
}
/* Styl pro log událostí */
#event-log-container {
max-height: 350px;
overflow-y: auto;
overflow-x: hidden;
}
#event-log-container::-webkit-scrollbar {
width: 8px;
}
#event-log-container::-webkit-scrollbar-track {
background: transparent;
}
#event-log-container::-webkit-scrollbar-thumb {
background: rgba(150, 150, 150, 0.3);
border-radius: 4px;
}
</style>
</head>
<body>
<div id="notification" class="alert rounded-pill px-4 shadow"></div>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0 fw-bold">🌡️ bojler</h2>
<button id="theme-toggle" class="btn btn-outline-light rounded-pill">
☀️ Světlý
</button>
</div>
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card h-100 text-center p-4">
<h5 class="opacity-75 mb-2">Aktuální teplota</h5>
<div id="current-temp" class="temp-display text-secondary">-- °C</div>
<hr class="my-4 border-secondary opacity-25">
<h5 class="opacity-75 mb-3">Stav ventilu</h5>
<h3 id="valve-state" class="fw-bold">❓ Neznámý</h3>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 p-4">
<h5 class="opacity-75 mb-4">⚙️ Nastavení</h5>
<form id="settings-form">
<div class="mb-3">
<label for="t0" class="form-label fw-bold">Spodní mez (t0) [°C]</label>
<input type="number" step="0.1" class="form-control form-control-lg" id="t0" required>
</div>
<div class="mb-4">
<label for="t1" class="form-label fw-bold">Horní mez (t1) [°C]</label>
<input type="number" step="0.1" class="form-control form-control-lg" id="t1" required>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100 fw-bold" id="save-btn">💾 Uložit
hodnoty</button>
</form>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap">
<h5 class="opacity-75 mb-0">📈 Historie teploty</h5>
<select id="history-range" class="form-select w-auto mt-2 mt-sm-0 shadow-sm">
<option value="24" selected>1 den (24h)</option>
<option value="48">2 dny (48h)</option>
<option value="72">3 dny (72h)</option>
<option value="96">4 dny (96h)</option>
<option value="120">5 dní (120h)</option>
</select>
</div>
<div style="height: 400px; width: 100%;">
<canvas id="historyChart"></canvas>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="opacity-75 mb-0">📋 Záznam událostí</h5>
<button id="refresh-log-btn" class="btn btn-sm btn-outline-secondary rounded-circle"
title="Aktualizovat události"
style="width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;">
🔄
</button>
</div>
<div id="event-log-container" class="pe-2">
<div class="text-center text-secondary py-3">Načítám události...</div>
</div>
</div>
</div>
</div>
</div>
<script>
let formInitialized = false;
let chartInstance = null;
let currentChartData = { data: [], hours: 24 }; // Uchováváme i informaci o vybraném čase
// --- Správa motivu (Dark/Light Mode) ---
const themeToggleBtn = document.getElementById('theme-toggle');
let currentTheme = localStorage.getItem('theme') || 'dark';
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
localStorage.setItem('theme', theme);
currentTheme = theme;
if (theme === 'dark') {
themeToggleBtn.innerHTML = '☀️ Světlý';
themeToggleBtn.classList.replace('btn-outline-secondary', 'btn-outline-light');
} else {
themeToggleBtn.innerHTML = '🌙 Tmavý';
themeToggleBtn.classList.replace('btn-outline-light', 'btn-outline-secondary');
}
if (currentChartData.data.length > 0) {
updateChart(currentChartData.data, currentChartData.hours);
}
}
themeToggleBtn.addEventListener('click', () => {
applyTheme(currentTheme === 'dark' ? 'light' : 'dark');
});
applyTheme(currentTheme);
// ---------------------------------------
function showNotification(message, isError = false) {
const notif = document.getElementById('notification');
notif.className = `alert rounded-pill px-4 shadow-lg ${isError ? 'alert-danger' : 'alert-success'}`;
notif.textContent = message;
notif.style.display = 'block';
setTimeout(() => notif.style.display = 'none', 3000);
}
async function fetchStatus() {
try {
const res = await fetch('/api/status');
if (!res.ok) throw new Error('API error');
const data = await res.json();
// Aktualizace UI - Teplota a barvy (<t0: zelená, t0-t1: žlutá, >t1: červená)
const tempEl = document.getElementById('current-temp');
tempEl.textContent = `${data.temp.toFixed(1)} °C`;
tempEl.classList.remove('temp-low', 'temp-ok', 'temp-high', 'text-secondary');
if (data.temp < data.t0) {
tempEl.classList.add('temp-low');
} else if (data.temp > data.t1) {
tempEl.classList.add('temp-high');
} else {
tempEl.classList.add('temp-ok');
}
// Aktualizace UI - Ventil + Varovný symbol
const valveEl = document.getElementById('valve-state');
const isOpen = (data.valve === 1 || data.valve === true);
let warningHtml = "";
if (!isOpen && data.temp > data.t1) {
warningHtml = ' <span title="Otevřete ventil" style="cursor: help;">⚠️</span>';
} else if (isOpen && data.temp < data.t0) {
warningHtml = ' <span title="Zavřete ventil" style="cursor: help;">⚠️</span>';
}
if (isOpen) {
valveEl.innerHTML = `🟢 <span class="text-success">Otevřen</span>${warningHtml}`;
} else {
valveEl.innerHTML = `🔴 <span class="text-danger">Zavřen</span>${warningHtml}`;
}
if (!formInitialized) {
document.getElementById('t0').value = data.t0;
document.getElementById('t1').value = data.t1;
formInitialized = true;
}
} catch (err) {
document.getElementById('current-temp').textContent = '-- °C';
document.getElementById('current-temp').className = 'temp-display text-secondary';
document.getElementById('valve-state').innerHTML = '❓ <span class="opacity-50">Nedostupné</span>';
}
}
async function fetchHistory(hours) {
try {
const res = await fetch(`/api/history?hours=${hours}`);
if (!res.ok) throw new Error('API error');
let data = await res.json();
// 15minutový interval = 4 vzorky za hodinu.
// Pokud API vrátí více dat než požadujeme, ořízneme pole zprava (nejnovější data).
const maxPoints = hours * 4;
if (data.length > maxPoints) {
data = data.slice(-maxPoints);
}
currentChartData = { data: data, hours: Number(hours) };
updateChart(data);
} catch (err) {
console.error("Nepodařilo se načíst historii:", err);
currentChartData = { data: [], hours: Number(hours) };
updateChart([]);
}
}
function updateChart(data) {
const ctx = document.getElementById('historyChart').getContext('2d');
const textColor = currentTheme === 'dark' ? '#adb5bd' : '#495057';
const gridColor = currentTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)';
const lineColor = currentTheme === 'dark' ? '#00d4ff' : '#0d6efd';
const fillColor = currentTheme === 'dark' ? 'rgba(0, 212, 255, 0.15)' : 'rgba(13, 110, 253, 0.1)';
Chart.defaults.color = textColor;
// Výpočet pro absolutní čas a vylepšený relativní čas
const now = new Date();
const labels = [];
const relativeTimes = [];
data.forEach((_, index) => {
const minsAgo = (data.length - 1 - index) * 15;
const d = new Date(now.getTime() - minsAgo * 60000);
const day = d.getDate();
const month = d.getMonth() + 1; // getMonth je 0-indexované
const hours = d.getHours();
const minutes = d.getMinutes().toString().padStart(2, '0');
labels.push(`${day}. ${month}. ${hours}:${minutes}`);
if (minsAgo === 0) {
relativeTimes.push('nyní');
} else {
const dTime = Math.floor(minsAgo / (60 * 24));
const hTime = Math.floor((minsAgo % (60 * 24)) / 60);
const mTime = minsAgo % 60;
let parts = [];
if (dTime > 0) parts.push(`${dTime} d`);
if (hTime > 0) parts.push(`${hTime} h`);
if (mTime > 0) parts.push(`${mTime} min`);
relativeTimes.push('před ' + parts.join(' '));
}
});
if (chartInstance) {
chartInstance.destroy();
}
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Teplota (°C)',
data: data,
borderColor: lineColor,
backgroundColor: fillColor,
borderWidth: 2,
fill: true,
pointRadius: 0,
pointHitRadius: 15,
tension: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
title: { display: true, text: 'Čas', color: textColor },
ticks: {
maxTicksLimit: 8, // Omezí počet štítků na ose, aby se nepřekrývaly
maxRotation: 45,
color: textColor
},
grid: { display: false }
},
y: {
title: { display: true, text: 'Teplota (°C)', color: textColor },
ticks: { color: textColor },
grid: { color: gridColor, drawBorder: false },
min: 0,
max: 100
}
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: currentTheme === 'dark' ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)',
titleColor: currentTheme === 'dark' ? '#fff' : '#000',
bodyColor: currentTheme === 'dark' ? '#fff' : '#000',
borderColor: gridColor,
borderWidth: 1,
callbacks: {
title: function (context) {
const index = context[0].dataIndex;
return `${labels[index]} (${relativeTimes[index]})`;
}
}
}
}
}
});
}
// --- Záznam událostí (Log) ---
async function fetchLog() {
const container = document.getElementById('event-log-container');
try {
const res = await fetch('/api/log');
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (!data || data.length === 0) {
container.innerHTML = '<div class="text-center text-secondary py-3">Zatím nejsou k dispozici žádné události.</div>';
return;
}
// Seřazení od nejnovější (pokud už není ze serveru)
data.sort((a, b) => b.ts - a.ts);
container.innerHTML = data.map(entry => {
// Ošetření timestampu: JS Date vyžaduje milisekundy (UNIX je typicky v sekundách)
const dateMs = entry.ts < 20000000000 ? entry.ts * 1000 : entry.ts;
const timeStr = new Date(dateMs).toLocaleString('cs-CZ');
// Barva okraje na základě typu (0 = info, 1 = warning, 2 = error)
let borderColor = 'border-info';
if (entry.t === 1) borderColor = 'border-warning';
else if (entry.t === 2) borderColor = 'border-danger';
return `
<div class="p-3 mb-3 rounded bg-body-tertiary border-start border-4 ${borderColor} shadow-sm">
<div class="d-flex justify-content-between mb-1">
<small class="text-secondary fw-semibold">${timeStr}</small>
</div>
<div class="fw-medium">${entry.m}</div>
</div>
`;
}).join('');
} catch (err) {
console.error("Nepodařilo se načíst log:", err);
container.innerHTML = '<div class="text-center text-danger py-3">Nepodařilo se načíst záznam událostí.</div>';
}
}
document.getElementById('refresh-log-btn').addEventListener('click', fetchLog);
// -----------------------------
document.getElementById('settings-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('save-btn');
btn.disabled = true;
const payload = {
t0: parseFloat(document.getElementById('t0').value),
t1: parseFloat(document.getElementById('t1').value)
};
try {
const res = await fetch('/api/thresholds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error();
showNotification('✅ Nastavení bylo úspěšně uloženo!');
fetchStatus();
fetchLog(); // Obnova logu po změně nastavení může být užitečná
} catch (err) {
showNotification('❌ Chyba při ukládání!', true);
} finally {
btn.disabled = false;
}
});
document.getElementById('history-range').addEventListener('change', (e) => {
fetchHistory(e.target.value);
});
// Inicializace
fetchStatus();
fetchHistory(document.getElementById('history-range').value);
fetchLog();
setInterval(fetchStatus, 10000);
</script>
</body>
</html>

490
src/main.cpp Normal file
View File

@ -0,0 +1,490 @@
#include <Arduino.h>
#include <DallasTemperature.h>
#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <AsyncJson.h>
#include <OneWire.h>
#include <TaskScheduler.h>
#include <LittleFS.h>
#include <time.h>
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<char[]> 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<bool>();
s_wifi_ssid = doc["ssid"].as<String>();
s_wifi_password = doc["password"].as<String>();
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<char[]> 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<float>();
s_t1 = doc["t1"].as<float>();
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>())
{
JsonObject obj = json.as<JsonObject>();
if (obj.containsKey("t0") && obj.containsKey("t1"))
{
auto t0 = obj["t0"].as<float>();
auto t1 = obj["t1"].as<float>();
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();
}

11
test/README Normal file
View File

@ -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