/* * Pollice Verde — firmware per ESP32 con scheda a relè * ──────────────────────────────────────────────────── * Espone tre indirizzi HTTP sulla rete di casa: * * GET /stato → JSON con lo stato dei canali * GET /valvola?canale=0&secondi=1200 → apre il canale 0 per 20 minuti * GET /valvola?canale=0&secondi=0 → chiude subito il canale 0 * GET /stop → chiude tutto * GET /sensori → lettura delle sonde Watermark, in kPa * GET /sensori?tempC=21 → idem, con compensazione di temperatura * * Il pezzo importante è che la durata la conosce la scheda, non solo l'agente: * se l'agente si spegne, se cade la rete o se qualcuno stacca il Raspberry a * metà turno, il canale si chiude comunque allo scadere del tempo. Un allagamento * notturno è il tipo di guasto che non si vuole scoprire la mattina dopo. * * ⚠️ Le elettrovalvole da giardino sono a 24 V AC o 9 V a impulso: il relè * commuta quella tensione, NON la 230 V. Se il tuo impianto usa la rete * elettrica, falla vedere a un elettricista prima di toccarla. * * Librerie: nessuna oltre a quelle del core ESP32 (WiFi, WebServer). */ #include #include // ── Da personalizzare ─────────────────────────────────────────────────────── const char* WIFI_SSID = "NOME-DELLA-TUA-RETE"; const char* WIFI_PASSWORD = "PASSWORD-DELLA-TUA-RETE"; // Pin dei relè, uno per canale. L'ordine qui è l'ordine dei canali: il primo // elemento è il canale 0, che è quello da scrivere nella scheda dell'aiuola. const int PIN_RELE[] = {26, 27, 14, 12}; const int NUM_CANALI = sizeof(PIN_RELE) / sizeof(PIN_RELE[0]); // Quasi tutte le schede a relè cinesi sono attive a livello basso: il relè // scatta quando il pin va a 0. Se la tua fa il contrario, metti false. const bool RELE_ATTIVO_BASSO = true; // Nessun canale può restare aperto più di così, qualunque cosa chieda l'agente. const unsigned long MASSIMO_SECONDI = 3 * 60 * 60; // 3 ore // Un solo settore per volta: la pressione di un impianto domestico non basta // per due valvole aperte insieme. Metti false se il tuo impianto lo regge. const bool UNO_ALLA_VOLTA = true; // ── Sonde Watermark ───────────────────────────────────────────────────────── // Ogni sonda va letta con un partitore: la sonda in serie a una resistenza nota, // il tutto fra due pin di eccitazione, e l'ADC che legge il punto di mezzo. // // La cosa da NON sbagliare: i Watermark si leggono in **corrente alternata**. // Una tensione continua elettrolizza il gesso e in poche settimane la sonda è // da buttare. Per questo ogni misura si fa due volte a polarità invertita, per // pochi microsecondi, e si media. Fra una misura e l'altra i pin restano a zero. const int PIN_ECCITAZIONE_A[] = {32, 25}; // un pin per sonda const int PIN_ECCITAZIONE_B[] = {33, 4}; // il pin opposto della stessa sonda const int PIN_LETTURA[] = {34, 35}; // ingressi ADC (solo pin di solo-ingresso) const int NUM_SONDE = sizeof(PIN_LETTURA) / sizeof(PIN_LETTURA[0]); const float RESISTENZA_NOTA = 10000.0; // ohm, in serie alla sonda const float TENSIONE_ECCITAZIONE = 3.3; const int MICROSECONDI_ECCITAZIONE = 50; // brevissimo: più a lungo, più si consuma la sonda const float TEMPERATURA_SUOLO_PREDEFINITA = 24.0; // °C, se l'agente non ne passa una // ──────────────────────────────────────────────────────────────────────────── WebServer server(80); unsigned long chiusuraPrevista[8]; // millis() a cui chiudere, 0 = chiuso void scriviRele(int canale, bool acceso) { int livello = acceso ? (RELE_ATTIVO_BASSO ? LOW : HIGH) : (RELE_ATTIVO_BASSO ? HIGH : LOW); digitalWrite(PIN_RELE[canale], livello); } void chiudi(int canale) { scriviRele(canale, false); chiusuraPrevista[canale] = 0; } void chiudiTutto() { for (int i = 0; i < NUM_CANALI; i++) chiudi(i); } void apri(int canale, unsigned long secondi) { if (secondi > MASSIMO_SECONDI) secondi = MASSIMO_SECONDI; if (UNO_ALLA_VOLTA) { for (int i = 0; i < NUM_CANALI; i++) if (i != canale && chiusuraPrevista[i] != 0) chiudi(i); } scriviRele(canale, true); chiusuraPrevista[canale] = millis() + secondi * 1000UL; } /** * Misura la resistenza di una sonda con doppia eccitazione a polarità invertita. * Restituisce la media delle due letture, in ohm. */ float leggiResistenza(int sonda) { float somma = 0; for (int verso = 0; verso < 2; verso++) { int alto = verso == 0 ? PIN_ECCITAZIONE_A[sonda] : PIN_ECCITAZIONE_B[sonda]; int basso = verso == 0 ? PIN_ECCITAZIONE_B[sonda] : PIN_ECCITAZIONE_A[sonda]; digitalWrite(alto, HIGH); digitalWrite(basso, LOW); delayMicroseconds(MICROSECONDI_ECCITAZIONE); int grezzo = analogRead(PIN_LETTURA[sonda]); digitalWrite(alto, LOW); // subito a riposo: niente continua sulla sonda float volt = (grezzo / 4095.0) * TENSIONE_ECCITAZIONE; // Nel verso invertito il partitore si legge specchiato. float frazione = verso == 0 ? volt / TENSIONE_ECCITAZIONE : 1.0 - (volt / TENSIONE_ECCITAZIONE); if (frazione <= 0.001) frazione = 0.001; if (frazione >= 0.999) frazione = 0.999; somma += RESISTENZA_NOTA * (frazione / (1.0 - frazione)); delay(90); // riposo fra i due versi } return somma / 2.0; } /** * Da resistenza a centibar (= kPa). Equazioni di Shock, Barnum e Seddigh (1998), * le stesse usate da Irrometer: la relazione non è lineare e cambia forma su tre * intervalli, e dipende dalla temperatura del terreno. */ float resistenzaInKpa(float ohm, float tempC) { float kohm = ohm / 1000.0; float kpa; if (ohm > 550.0) { if (ohm > 8000.0) { kpa = -2.246 - 5.239 * kohm * (1.0 + 0.018 * (tempC - 24.0)) - 0.06756 * kohm * kohm * pow((1.0 + 0.018 * (tempC - 24.0)), 2.0); } else if (ohm > 1000.0) { kpa = (-3.213 * kohm - 4.093) / (1.0 - 0.009733 * kohm - 0.01205 * tempC); } else { kpa = (kohm * 23.156 - 12.736) * (1.0 + 0.018 * (tempC - 24.0)); } } else { // Sotto i 550 ohm il terreno è saturo: la sonda non distingue più niente. kpa = 0.0; } if (kpa < 0.0) kpa = 0.0; if (kpa > 239.0) kpa = 239.0; // fondo scala del sensore return kpa; } /** Diagnostica del collegamento, per non scambiare un cavo staccato per siccità. */ const char* saluteSonda(float ohm) { if (ohm > 60000.0) return "scollegata"; // circuito aperto: filo staccato if (ohm < 200.0) return "in corto"; // due fili che si toccano, o acqua nel morsetto if (ohm > 35000.0) return "fuori scala"; // oltre il campo utile del Watermark return "ok"; } String jsonSensori(float tempC) { String s = "{\"temperatura\":" + String(tempC, 1) + ",\"sonde\":["; for (int i = 0; i < NUM_SONDE; i++) { if (i) s += ","; float ohm = leggiResistenza(i); float kpa = resistenzaInKpa(ohm, tempC); s += "{\"sensore\":" + String(i) + ",\"ohm\":" + String(ohm, 0) + ",\"kpa\":" + String(kpa, 1) + ",\"salute\":\"" + String(saluteSonda(ohm)) + "\"}"; } s += "]}"; return s; } String jsonStato() { String s = "{\"firmware\":\"pollice-verde-1.0\",\"canali\":["; unsigned long ora = millis(); for (int i = 0; i < NUM_CANALI; i++) { if (i) s += ","; unsigned long restanti = 0; if (chiusuraPrevista[i] != 0 && chiusuraPrevista[i] > ora) restanti = (chiusuraPrevista[i] - ora) / 1000UL; s += "{\"canale\":" + String(i) + ",\"aperto\":" + (chiusuraPrevista[i] != 0 ? "true" : "false") + ",\"secondiRestanti\":" + String(restanti) + "}"; } s += "],\"ip\":\"" + WiFi.localIP().toString() + "\"}"; return s; } void rispondi(int codice, const String& corpo) { server.sendHeader("Access-Control-Allow-Origin", "*"); server.send(codice, "application/json", corpo); } void gestisciValvola() { if (!server.hasArg("canale") || !server.hasArg("secondi")) { rispondi(400, "{\"errore\":\"servono canale e secondi\"}"); return; } int canale = server.arg("canale").toInt(); long secondi = server.arg("secondi").toInt(); if (canale < 0 || canale >= NUM_CANALI) { rispondi(400, "{\"errore\":\"canale inesistente\"}"); return; } if (secondi <= 0) { chiudi(canale); } else { apri(canale, (unsigned long)secondi); } rispondi(200, jsonStato()); } void setup() { Serial.begin(115200); // Prima cosa all'accensione: tutti i relè chiusi, PRIMA di impostarli come // uscite. Al contrario, un ESP32 che riparte apre le valvole per un istante. for (int i = 0; i < NUM_CANALI; i++) { digitalWrite(PIN_RELE[i], RELE_ATTIVO_BASSO ? HIGH : LOW); pinMode(PIN_RELE[i], OUTPUT); scriviRele(i, false); chiusuraPrevista[i] = 0; } // Pin delle sonde: a riposo entrambi bassi, così fra una lettura e l'altra // non passa corrente e il gesso non si consuma. for (int i = 0; i < NUM_SONDE; i++) { pinMode(PIN_ECCITAZIONE_A[i], OUTPUT); pinMode(PIN_ECCITAZIONE_B[i], OUTPUT); digitalWrite(PIN_ECCITAZIONE_A[i], LOW); digitalWrite(PIN_ECCITAZIONE_B[i], LOW); pinMode(PIN_LETTURA[i], INPUT); } analogSetAttenuation(ADC_11db); // fondo scala ~3,3 V WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); Serial.print("Mi collego al Wi-Fi"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(); Serial.print("Pronto. Indirizzo da mettere in Pollice Verde: "); Serial.println(WiFi.localIP()); server.on("/stato", []() { rispondi(200, jsonStato()); }); server.on("/sensori", []() { float t = server.hasArg("tempC") ? server.arg("tempC").toFloat() : TEMPERATURA_SUOLO_PREDEFINITA; rispondi(200, jsonSensori(t)); }); server.on("/valvola", gestisciValvola); server.on("/stop", []() { chiudiTutto(); rispondi(200, jsonStato()); }); server.onNotFound([]() { rispondi(404, "{\"errore\":\"non trovato\"}"); }); server.begin(); } void loop() { server.handleClient(); // Il vero guardiano: chiude i canali scaduti anche se nessuno lo chiede. unsigned long ora = millis(); for (int i = 0; i < NUM_CANALI; i++) { if (chiusuraPrevista[i] != 0 && (long)(ora - chiusuraPrevista[i]) >= 0) { Serial.println("Tempo scaduto: chiudo il canale " + String(i)); chiudi(i); } } // Se il Wi-Fi cade non chiudiamo subito (un turno in corso deve finire), ma // proviamo a riconnetterci: senza rete l'agente non può più dare lo stop. static unsigned long ultimoControllo = 0; if (ora - ultimoControllo > 30000) { ultimoControllo = ora; if (WiFi.status() != WL_CONNECTED) { Serial.println("Wi-Fi caduto, riprovo"); WiFi.disconnect(); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); } } }