Init
This commit is contained in:
commit
a4df54a80b
9
.clang-format
Normal file
9
.clang-format
Normal 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
6
.gitignore
vendored
Normal 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
10
.vscode/extensions.json
vendored
Normal 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
5
data/wifi-example.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"ap": true,
|
||||
"ssid": "bojler",
|
||||
"password": "bojler123"
|
||||
}
|
||||
1
data/www/index.html
Normal file
1
data/www/index.html
Normal file
File diff suppressed because one or more lines are too long
37
include/README
Normal file
37
include/README
Normal 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
46
lib/README
Normal 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
25
platformio.ini
Normal 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
486
res/index.html
Normal 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
490
src/main.cpp
Normal 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
11
test/README
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user