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; serializeJson(d,o); srv.send(200,"application/json",o);
}
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("/",[]( ){srv.send_P(200,"text/html",INDEX_HTML);} );
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í!