Řídící počítač pro hydroponii
Vytvořte si vlastní řídící počítač pro hydroponii za pouhých 30 minut! S využitím ESP32-c3 a jednoduchého web rozhraní si snadno nastavíte automatizaci zalévání a další funkce.

Vzletný název pro dnešní třicetiminutový rychlobastl ze dvou modulů dohromady za pakatel, ale se spoustou možností.
Jak že se to dá stihnout za půl hoďky?

Vypadá to složitě, ale není. Kód pro použité ESP32-c3 super mini napsal ChatGPT funkční na první pokus. Umí vytvořit SoftAP pro vytvoření wifi hotspotu. Má web rozhraní, kde se dá přidat i ubrat zóna pro zalévání a nastavení základních časů. Vše se ukládá do SPIFFS.
Takhle vypadala první verze.

Zbývající čas jsme ladily detaily jako je otevření prohlížeče se stránkou nastavení hned po připojení k ESP. Vyřešily jsme lepší logiku pro časování a zpoždění. Přidaly jsme ruční spuštění zalévání mimo hlavní smyčku programu. Také jsme přidaly ukazatel, která zóna se právě zalévá. A taky asi 3 minuty pájení relátek k ESP modulu.
A aktuální verze.

Takhle vypadá rychlobastl fyzicky. Prostě relé modul a na něm sedí ESP32-c3 super mini. Žádná raketová věda.

S Ai je programování opravdu snadné a zvládne to skoro každý. Webová appka je zatím bez zbytečných pičičandiček, je minimalistická až strohá. Vzhled se dá vyřešit později, úkolem bylo vytvořit jednoduché řízení hydroponie teď hned a to se povedlo.
Co se chystá do další verze?
Dá se vymyslet tolik věcí a vychytávek, že můžou závidět i profesionální řídící systémy za mnoho tisíc korun. Namátkou co se dá přidat:
- Senzor teploty a vlhkosti okolního vzduchu ovlivňující délku čekání.
- Senzor teploty vody v nádrži.
- Senzor hladiny vody v nádrži.
- Vyměnit klasické relé za SSR.
- Připojení k internetu a nastavení konkrétních časů, cyklů, dení a noční režim.
- Server na který počítač může odesílat stav a přijímat nastavení odkudkoliv. Program stále poběží lokálně bez nutnosti připojení k netu.
- Záloha na baterii s notifikací.
- Připojení EC a PH metru.
- Ovládání peristaltických čerpadel pro automatické dávkování hnojiva a úpravu PH.
- Odesílání dat na TMEP a zobrazení statistik na Živý Obraz?
- Napojení na HA, MAKE, Zapier a dalších automatizací.
- A cokoliv dalšího tě jen napadne.
Před lety jsem už jeden časovač dělala. Program běžel na Arduino Nano, nastavení zobrazoval na 16×2 znakovém LCD displeji, ovládal se potenciometrem a několika tlačítky. Bylo to velké a neohrabané a jen s jedním relátkem. Dnešní verze je rychlá a jednoduchá. Ovládá se přes telefon. Počet kanálů je omezen jen počtem pinů ESP modulu. Ale díky modulům, které rozšiřují počty pinů, není omezení problém.
Tady je dnešní kód. ChatGPT ho napsal pro Arduino IDE s mými připomínkami. V kódu jsem nic neměnila.
/** * HydroTimer v1.1 – aktivní-LOW relé + restart a ruční start zóny * --------------------------------------------------------------- * ChatGPT • 06/2025 */ #include <WiFi.h> #include <WebServer.h> #include <DNSServer.h> #include <ArduinoJson.h> #include <FS.h> #include <SPIFFS.h> /* ---------- AP + captive portal ----------- */ const char* AP_SSID = "HydroTimer"; const char* AP_PASS = ""; const uint8_t DNS_PORT = 53; DNSServer dns; /* ---------- Relé logika (LOW = zapnuto) --- */ #define RELAY_ON(P) digitalWrite((P), LOW) #define RELAY_OFF(P) digitalWrite((P), HIGH) /* ---------- Datové struktury -------------- */ struct Zone { uint8_t pin; uint32_t waterMs; uint32_t pauseMs; }; std::vector<Zone> zones; uint32_t cyclePauseMs = 60000; bool manual = false; // je teď aktivní ruční zalévání? size_t manualZone = 0; enum Phase { WATER, ZPAUSE, CYCLE }; Phase phase = WATER; size_t curZone = 0; uint32_t lastChange = 0; /* ---------- FS + server ------------------- */ const char* CONFIG_PATH = "/config.json"; WebServer server(80); /* ---------- HTML 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} hr{margin:1em 0} button{margin-top:.3em} </style></head><body> <h2>Hydroponický časovač</h2> <label>Pauza mezi smyčkami (s): <input type="number" id="cycle" min="0"></label><br> <button onclick="restart()">↻ Restart cyklu</button><hr> <div id="zones"></div> <button onclick="add()">Přidat zónu</button> <button onclick="save()">💾 Uložit</button> <script> let cfg={cycle:60,zones:[]}; function load(){fetch('/config').then(r=>r.json()).then(j=>{cfg=j;draw();});} function draw(){ document.getElementById('cycle').value = cfg.cycle; let h=''; cfg.zones.forEach((z,i)=>{ h+=`<div> <span class="dot" id="led${i}">●</span> <strong>Zóna ${i}</strong><br> Pin GPIO: <input type="number" value="${z.pin}" onchange="cfg.zones[${i}].pin=parseInt(this.value)"><br> Zalévání (s): <input type="number" value="${z.water}" onchange="cfg.zones[${i}].water=parseInt(this.value)"><br> Pauza před další zónou (s): <input type="number" value="${z.pause}" onchange="cfg.zones[${i}].pause=parseInt(this.value)"><br> <button onclick="force(${i})">▶ Spustit zónu</button> <button onclick="del(${i})">🗑️ Odebrat zónu</button></div><hr>`; }); document.getElementById('zones').innerHTML = h||'<em>Žádné zóny.</em>'; } function add(){cfg.zones.push({pin:0,water:10,pause:5});draw();} function del(i){cfg.zones.splice(i,1);draw();} function save(){ cfg.cycle = parseInt(document.getElementById('cycle').value)||0; fetch('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(cfg)}) .then(()=>alert('Uloženo – nové časy se projeví po skončení fáze')); } function restart(){ fetch('/restart',{method:'POST'}); } function force(i){ fetch('/force/'+i,{method:'POST'}); } function poll(){ fetch('/state').then(r=>r.json()).then(s=>{ cfg.zones.forEach((_,i)=>{ const d=document.getElementById('led'+i); if(d) d.style.color=(i===s.cur && s.phase===0)?'green':'red'; }); }); } setInterval(poll,1000); load(); </script></body></html> )rawliteral"; /* ---------- Config ------------------------ */ void saveConfig() { StaticJsonDocument<4096> doc; doc["cycle"] = cyclePauseMs/1000; JsonArray arr = doc.createNestedArray("zones"); for(auto &z: zones){ JsonObject o=arr.createNestedObject(); o["pin"]=z.pin; o["water"]=z.waterMs/1000; o["pause"]=z.pauseMs/1000; } if(File f=SPIFFS.open(CONFIG_PATH,FILE_WRITE)){ serializeJsonPretty(doc,f); f.close(); } } void loadConfig() { zones.clear(); cyclePauseMs=60000; if(!SPIFFS.exists(CONFIG_PATH)) return; File f=SPIFFS.open(CONFIG_PATH,FILE_READ); if(!f) return; StaticJsonDocument<4096> doc; if(deserializeJson(doc,f)){f.close();return;} f.close(); cyclePauseMs = (uint32_t)(doc["cycle"]|60)*1000UL; for(JsonObject o: doc["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); } } void configurePins() { for(auto &z: zones){ pinMode(z.pin,OUTPUT); RELAY_OFF(z.pin); // vypnuto = HIGH } } /* ---------- Sekvenční logika ------------- */ void startZone(size_t idx) { for(auto &z: zones) RELAY_OFF(z.pin); // nejdřív vypnout vše curZone = idx % zones.size(); phase = WATER; RELAY_ON(zones[curZone].pin); lastChange = millis(); } void restartCycle(){ if(!zones.empty()) startZone(0); } void irrigationLoop() { uint32_t now = millis(); /* ---------- RUČNÍ ZÁLIVKA ---------- */ if(manual){ if(now - lastChange >= zones[manualZone].waterMs){ RELAY_OFF(zones[manualZone].pin); // vypnout manual = false; // konec ručního režimu lastChange = now; // *ne*upravujeme fázi cyklu } return; // dokud je ruční režim, cyklus stojí } /* ---------- BĚŽNÝ CYKLUS ---------- */ if(zones.empty()) return; //uint32_t now=millis(); uint32_t period = (phase==WATER )? zones[curZone].waterMs : (phase==ZPAUSE)? zones[curZone].pauseMs : cyclePauseMs; if(now-lastChange < period) return; switch(phase){ case WATER: RELAY_OFF(zones[curZone].pin); phase = ZPAUSE; break; case ZPAUSE: if(curZone+1 < zones.size()){ ++curZone; RELAY_ON(zones[curZone].pin); phase = WATER; } else { phase = CYCLE; } break; case CYCLE: startZone(0); // uvnitř přepne phase → WATER return; } lastChange = now; } /* ---------- HTTP & DNS ------------------- */ void handleRoot() { server.send_P(200,"text/html",INDEX_HTML); } void handleGetConfig(){ StaticJsonDocument<4096> doc; doc["cycle"]=cyclePauseMs/1000; JsonArray arr=doc.createNestedArray("zones"); for(auto &z: zones){ JsonObject o=arr.createNestedObject(); o["pin"]=z.pin; o["water"]=z.waterMs/1000; o["pause"]=z.pauseMs/1000; } String out; serializeJson(doc,out); server.send(200,"application/json",out); } void handlePostConfig() { if(!server.hasArg("plain")){ server.send(400,"text/plain","No JSON"); return; } StaticJsonDocument<4096> doc; if(deserializeJson(doc,server.arg("plain"))){ server.send(400,"text/plain","Bad JSON"); return; } cyclePauseMs = (uint32_t)(doc["cycle"]|60)*1000UL; std::vector<Zone> newZ; for(JsonObject o: doc["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; newZ.push_back(z); } zones.swap(newZ); if(curZone >= zones.size()) curZone=0; configurePins(); saveConfig(); server.send(200,"text/plain","OK"); } void handleState(){ StaticJsonDocument<64> doc; doc["cur"]=curZone; doc["phase"]= (phase==WATER)?0:1; String out; serializeJson(doc,out); server.send(200,"application/json",out); } void handleRestart(){ restartCycle(); server.send(204); } void handleForce() { int idx = server.uri().substring(server.uri().lastIndexOf('/')+1).toInt(); if(idx>=0 && idx<(int)zones.size()){ manual = true; manualZone = idx; lastChange = millis(); for(auto &z: zones) RELAY_OFF(z.pin); RELAY_ON(zones[idx].pin); // zapne vybranou zónu } server.send(204); } void handleNotFound() { // ručně odchytíme /force/číslo if (server.method()==HTTP_POST && server.uri().startsWith("/force/")) { handleForce(); // zavoláme vlastní logiku return; } server.sendHeader("Location","/",true); server.send(302,"text/plain",""); } /* ---------- 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); loadConfig(); configurePins(); /* zpoždění 2 s po startu AP */ delay(2000); restartCycle(); server.on("/", handleRoot); server.on("/config", HTTP_GET, handleGetConfig); server.on("/config", HTTP_POST, handlePostConfig); server.on("/state", HTTP_GET, handleState); server.on("/restart", HTTP_POST, handleRestart); //server.on("/force/", HTTP_POST, handleForce); // wildcard server.onNotFound( handleNotFound); server.begin(); Serial.println("🌐 WebServer běží – připoj se k AP a otevři libovolnou URL"); } void loop() { dns.processNextRequest(); server.handleClient(); irrigationLoop(); }
Komentáře (0)
Pro přidání komentáře se musíte přihlásit.
Zatím žádné komentáře. Buďte první!