Všechny články

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ě.

17. června 20258 min čtení62 přečtení
V.O.D.N.I.K. v2.0

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&nbsp;(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í!