V.O.D.N.I.K. v2.0
V.O.D.N.I.K. se vrací v nové verzi 2.0 s přívětivějším rozhraním a vylepšeným zabezpečením. Minimalistické UI nyní doplňují praktické odpočty času a pojistka aby se nespustily ruční a automatické smyčky současně.

Tohle zařízení se vyvinulo z nedávného rychlobastlu. Poučila jsem se a vylepšila web UI.
Vylepšení se týká hlavně uživatelského komfortu rozhraní, funkce jsou stejné i když trochu vylepšené. Taky jsem přidala heslo pro připojení, nechceme aby se sousedi vrtali v nastavení, že.
Předchozí článek si můžeš přečíst tady.

Jak je vidět z obrázku, časy jsou rozdělené na minuty a vteřiny. U každého řádku s časem teď běží jeho odpočet. UI je stále minimalistické, k čemu složitosti a grafika, to je jen marketing. Ale ikonky z emotikonů miluju. Dají se použít třeba tyhle z EmojiCopy. Výhoda je, že je podporují skoro všechny systémy. Dokonce se dají použít i přímo do výpisu do konzole v Arduino IDE! Není nutné tam mít jen text. Díky emojis se dá výpis i zpřehlednit, ale o tom třeba někdy jindy.
/**
* HydroTimer V.O.D.N.I.K. v2.0 – ESP32 + Soft-AP + captive portal
* -------------------------------------------------------
* • aktivní-LOW relé
* • ruční zalévání paralelně s hlavním cyklem
* • minutové i sekundové vstupy, živé odpočty
* • bezpečné při prázdné konfiguraci
* ChatGPT • 06/2025
*/
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <ArduinoJson.h>
#include <FS.h>
#include <SPIFFS.h>
const char* version = "v2.0";
/* ---------- AP ---------- */
const char* AP_SSID = "VODNIK";
const char* AP_PASS = "vodnik12345";
DNSServer dns;
const uint8_t DNS_PORT = 53;
/* ---------- relé logika ---------- */
#define RELAY_ON(P) digitalWrite((P), LOW)
#define RELAY_OFF(P) digitalWrite((P), HIGH)
/* ---------- datové struktury ---------- */
struct Zone {
uint8_t pin;
uint32_t waterMs, pauseMs;
};
std::vector<Zone> zones;
uint32_t cyclePauseMs = 60000; // mezismyčková pauza
/* ---------- stav automatu ---------- */
enum Phase { WATER, ZPAUSE, CYCLE };
Phase phase = WATER;
size_t cur = 0;
uint32_t phaseStart = 0;
/* ---------- ruční zálivka ---------- */
struct Manual {
bool run;
size_t z;
uint32_t start;
} man{false, 0, 0};
bool manual;
/* ---------- pomocné funkce ---------- */
uint32_t autoPeriod() {
if (zones.empty()) return 0;
if (phase == WATER) return zones[cur].waterMs;
if (phase == ZPAUSE) return zones[cur].pauseMs;
return cyclePauseMs;
}
uint32_t autoLeft() {
uint32_t el = millis() - phaseStart, dur = autoPeriod();
return (el < dur) ? (dur - el) : 0;
}
uint32_t manLeft() {
if (!man.run) return 0;
uint32_t el = millis() - man.start, dur = zones[man.z].waterMs;
return (el < dur) ? (dur - el) : 0;
}
/* ---------- HTML/JS UI ---------- */
const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HydroTimer</title>
<style>
body {font-family:sans-serif; margin:1em}
.dot {font-size:1.4em; vertical-align:middle; margin-right:.3em}
input.time {width:4ch}
hr {margin:1em 0}
.del {position: absolute; right: 15px;}
.play {width:90px}
.save {position: absolute; right: 15px;}
</style>
</head>
<body>
<h2>🌱 Hydroponický časovač 🌱</h2>
<label>Pauza mezi smyčkami (m:s):
<input class="time" id="cycMin"> : <input class="time" id="cycSec">
<span id="cntCycle"></span>
</label><br>
<button onclick="restart()">↻ Restart cyklu</button>
<hr>
<div id="zones"></div>
<button onclick="add()">Přidat zónu</button>
<button class="save" onclick="save()">💾 Uložit</button>
<hr>
<small>V.O.D.N.I.K. %%VERSION%%<br>Vyladěné Ovládání Domácího Nutričního a Irigačního <del>Krumplu</del> Kultivátoru</small>
<script>
let cfg = {cycle: 60, zones: []};
const $ = id => document.getElementById(id);
const mmss = t => Math.floor(t/60) + ':' + ('0' + Math.floor(t%60)).slice(-2);
/* ---------- vykreslení ---------- */
function draw() {
$('cycMin').value = Math.floor(cfg.cycle/60);
$('cycSec').value = cfg.cycle%60;
let h = '';
cfg.zones.forEach((z,i) => {
h += `
<div>
<span class="dot" id="led${i}">●</span><b>Zóna ${i+1}</b><br>
Pin:<input class="time" value="${z.pin}" onchange="cfg.zones[${i}].pin=parseInt(this.value)"> <br>
Zalévání (m:s):
<input class="time" value="${Math.floor(z.water/60)}" onchange="setT(${i},'water',this.value,true)"> :
<input class="time" value="${z.water%60}" onchange="setT(${i},'water',this.value,false)">
<span id="cntW${i}"></span><br>
Pauza (m:s):
<input class="time" value="${Math.floor(z.pause/60)}" onchange="setT(${i},'pause',this.value,true)"> :
<input class="time" value="${z.pause%60}" onchange="setT(${i},'pause',this.value,false)">
<span id="cntP${i}"></span><br>
<button class="play" id="btn${i}" onclick="toggle(${i})">▶ Spustit</button>
<button class="del" onclick="del(${i})">🗑️ Odebrat</button>
</div><hr>`;
});
$('zones').innerHTML = h || '<em>Žádné zóny.</em>';
}
function setT(i, k, v, min) {
v = parseInt(v) || 0;
const base = cfg.zones[i][k];
cfg.zones[i][k] = min ? 60*v + base%60 : 60*Math.floor(base/60) + v;
}
/* ---------- CRUD ---------- */
function load() {
fetch('/config').then(r => r.json()).then(j => { cfg = j; draw(); });
}
function save() {
cfg.cycle = (parseInt($('cycMin').value) || 0) * 60 + (parseInt($('cycSec').value) || 0);
fetch('/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(cfg)
}).then(() => alert('Uloženo'));
}
function add() { cfg.zones.push({pin:0, water:10, pause:5}); draw(); }
function del(i) {
if (!confirm('Opravdu smazat zónu ' + (i+1) + '?')) return;
cfg.zones.splice(i,1);
draw();
}
/* ---------- ovládání ---------- */
function restart() { fetch('/restart', {method: 'POST'}); }
function toggle(i) {
fetch('/state').then(r => r.json()).then(s => {
if(s.manual && s.mzone === i) fetch('/stop', {method:'POST'});
else fetch('/force?id='+i, {method:'POST'});
});
}
/* ---------- odpočty ---------- */
function poll() {
fetch('/state').then(r => r.json()).then(s => {
cfg.zones.forEach((_, i) => {
const d = $('led'+i), b = $('btn'+i), w = $('cntW'+i), p = $('cntP'+i);
const aw = (s.auto && s.phase === 0 && s.cur === i);
const ap = (s.auto && s.phase === 1 && s.cur === i);
const man = (s.manual && s.mzone === i);
d.style.color = (aw || man) ? 'green' : 'red';
b.innerText = man ? '■ Zastavit' : '▶ Spustit';
w.textContent = man ? ' ' + mmss(s.mleft/1000) : (aw ? ' ' + mmss(s.left/1000) : '');
p.textContent = ap ? ' ' + mmss(s.left/1000) : '';
});
$('cntCycle').textContent = (s.auto && s.phase === 2) ? ' ' + mmss(s.left/1000) : '';
});
}
setInterval(poll, 1000);
load();
</script>
</body>
</html>
)rawliteral";
/* ---------- SPIFFS ---------- */
const char* CFG = "/config.json";
void saveCfg() {
StaticJsonDocument<4096> d;
d["cycle"] = cyclePauseMs / 1000;
JsonArray ar = d.createNestedArray("zones");
for (auto& z : zones) {
JsonObject o = ar.createNestedObject();
o["pin"] = z.pin;
o["water"] = z.waterMs / 1000;
o["pause"] = z.pauseMs / 1000;
}
if (File f = SPIFFS.open(CFG, FILE_WRITE)) {
serializeJsonPretty(d, f);
f.close();
}
}
void loadCfg() {
zones.clear();
cyclePauseMs = 60000;
if (!SPIFFS.exists(CFG)) return;
File f = SPIFFS.open(CFG, FILE_READ);
if (!f) return;
StaticJsonDocument<4096> d;
if (deserializeJson(d, f)) {
f.close();
return;
}
f.close();
cyclePauseMs = (uint32_t)(d["cycle"] | 60) * 1000UL;
for (JsonObject o : d["zones"].as<JsonArray>()) {
Zone z;
z.pin = o["pin"] | 0;
z.waterMs = (uint32_t)(o["water"] | 10) * 1000UL;
z.pauseMs = (uint32_t)(o["pause"] | 5) * 1000UL;
zones.push_back(z);
}
}
/* ---------- GPIO ---------- */
void cfgPins() {
for (auto& z : zones) {
pinMode(z.pin, OUTPUT);
RELAY_OFF(z.pin);
}
}
/* ---------- automat ---------- */
void startZone(size_t idx) {
cur = idx % zones.size();
phase = WATER;
phaseStart = millis();
RELAY_ON(zones[cur].pin);
}
void restartCycle() {
if (!zones.empty()) startZone(0);
}
/* ---------- hlavní smyčka ---------- */
void irrigationLoop() {
uint32_t now = millis();
/* ruční režim */
if (manual && now - man.start >= zones[man.z].waterMs) {
if (!(phase == WATER && cur == man.z)) {
RELAY_OFF(zones[man.z].pin);
}
manual = false;
}
/* automat */
if (zones.empty()) return;
if (now - phaseStart < autoPeriod()) return;
switch (phase) {
case WATER:
RELAY_OFF(zones[cur].pin);
phase = ZPAUSE;
break;
case ZPAUSE:
if (cur + 1 < zones.size()) {
++cur;
RELAY_ON(zones[cur].pin);
phase = WATER;
} else {
phase = CYCLE;
}
break;
case CYCLE:
startZone(0);
return;
}
phaseStart = now;
}
/* ---------- HTTP ---------- */
WebServer srv(80);
void sendCfg() {
StaticJsonDocument<4096> d;
d["cycle"] = cyclePauseMs / 1000;
JsonArray a = d.createNestedArray("zones");
for (auto& z : zones) {
JsonObject o = a.createNestedObject();
o["pin"] = z.pin;
o["water"] = z.waterMs / 1000;
o["pause"] = z.pauseMs / 1000;
}
String o_str;
serializeJson(d, o_str);
srv.send(200, "application/json", o_str);
}
void saveCfgHandler() {
if (!srv.hasArg("plain")) { srv.send(400); return; }
StaticJsonDocument<4096> d;
if (deserializeJson(d, srv.arg("plain"))) { srv.send(400); return; }
cyclePauseMs = (uint32_t)(d["cycle"] | 60) * 1000UL;
std::vector<Zone> nz;
for (JsonObject o : d["zones"].as<JsonArray>()) {
Zone z;
z.pin = o["pin"] | 0;
z.waterMs = (uint32_t)(o["water"] | 10) * 1000UL;
z.pauseMs = (uint32_t)(o["pause"] | 5) * 1000UL;
nz.push_back(z);
}
zones.swap(nz);
cfgPins();
saveCfg();
srv.send(204);
}
void sendState() {
StaticJsonDocument<128> d;
d["auto"] = 1;
d["phase"] = phase;
d["cur"] = cur;
d["left"] = autoLeft();
d["manual"] = manual ? 1 : 0;
d["mzone"] = man.z;
d["mleft"] = manLeft();
String o;
serializeJson(d, o);
srv.send(200, "application/json", o);
}
void force() {
if (!srv.hasArg("id")) { srv.send(400); return; }
int idx = srv.arg("id").toInt();
if (idx < 0 || idx >= (int)zones.size()) { srv.send(400); return; }
manual = true;
man = {true, (size_t)idx, millis()};
RELAY_ON(zones[idx].pin);
srv.send(204);
}
void stopManual() {
if (manual) {
RELAY_OFF(zones[man.z].pin);
manual = false;
}
srv.send(204);
}
void restart() {
manual = false;
restartCycle();
srv.send(204);
}
void handleRoot() {
String page = FPSTR(INDEX_HTML); // z PROGMEM do String-u v RAM
page.replace("%%VERSION%%", version);
srv.send(200, "text/html", page);
}
/* ---------- setup / loop ---------- */
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS);
dns.start(DNS_PORT, "*", WiFi.softAPIP());
SPIFFS.begin(true);
loadCfg();
cfgPins();
delay(2000);
restartCycle();
srv.on("/", handleRoot);
srv.on("/config", HTTP_GET, sendCfg);
srv.on("/config", HTTP_POST, saveCfgHandler);
srv.on("/state", HTTP_GET, sendState);
srv.on("/force", HTTP_POST, force);
srv.on("/stop", HTTP_POST, stopManual);
srv.on("/restart", HTTP_POST, restart);
srv.onNotFound([](){ srv.sendHeader("Location", "/", true); srv.send(302); });
srv.begin();
Serial.println("🌐 AP běží – otevři libovolnou URL");
}
void loop() {
dns.processNextRequest();
srv.handleClient();
irrigationLoop();
}Tak teď už přesně víš co je V.O.D.N.I.K. – Vyladěné Ovládání Domácího Nutričního a Irigačního Krumplu Kultivátoru.
Tahle verze je open source. Další verze bez připojení na internet budou taky zdarma. Placené funkce budou až s připojením k internetu a napojením na web.
ToDo:
- Odesílání dat na TMEP a zobrazení na Živý Obraz.
- Denní a noční režim.
- Čidla teploty a vlhkosti ovlivňující délku pauzy.
- Web pro ovládání na dálku.
- Měření EC/PH
- Měření hladiny, teploty a vlhkosti vody v nádrži.
- Dávkování hnojiva.
Komentáře (0)
Pro přidání komentáře se musíte přihlásit.
Zatím žádné komentáře. Buďte první!