diff --git a/Part2_Central_board/Firmware/CentralUnit/CentralUnit.ino b/Part2_Central_board/Firmware/CentralUnit/CentralUnit.ino index 1a51330..1e51b29 100644 --- a/Part2_Central_board/Firmware/CentralUnit/CentralUnit.ino +++ b/Part2_Central_board/Firmware/CentralUnit/CentralUnit.ino @@ -1,3 +1,4 @@ +#include #include "WiFi.h" #include "ESPAsyncWebServer.h" #include @@ -7,12 +8,17 @@ #include "Wire.h" #define WDT_TIMEOUT 20000 +#define EEPROM_SIZE 4 AsyncWebServer server(80); int WiFi_status = WL_IDLE_STATUS; volatile uint32_t nFlowSensorCount = 0; volatile uint32_t nFlowSensorCount_last = 0; +char dbgBuff[4024] = {0}; +uint32_t dbgBuffPos = 0; +float fImpulsePerML = 0; + void IRAM_ATTR ISR_flowSensor() { nFlowSensorCount++; } @@ -37,6 +43,21 @@ void setup() Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN); + // EEPROM begin + EEPROM.begin(EEPROM_SIZE); + + // Debug EEPROM before read + Serial.println("Before read: fImpulsePerML"); + Serial.println((float)(fImpulsePerML),4); + + // Read flow value from EEPROM + fImpulsePerML = EEPROM.readFloat(0); + + // Debug EEPROM after read + Serial.println("After read: fImpulsePerML"); + Serial.println((float)(fImpulsePerML),4); + + // Configure WDT Serial.println("Configuring WDT..."); esp_task_wdt_init(WDT_TIMEOUT, true); //enable panic so ESP32 restarts @@ -61,7 +82,20 @@ void setup() setupWebServer(); server.begin(); - attachInterrupt(FLOW_SENSOR_PIN, ISR_flowSensor, FALLING); + flowInterruptEnabled(true); +} + + +void flowInterruptEnabled(bool state) +{ + if(state) + { + attachInterrupt(FLOW_SENSOR_PIN, ISR_flowSensor, FALLING); + } + else + { + detachInterrupt(FLOW_SENSOR_PIN); + } } @@ -257,5 +291,48 @@ void setupWebServer(void) request->send(200, "text/plain", "OK"); return; }); + + // Calibrate flow sensor + server.on("/calibrateFlowSensor", HTTP_GET, [] (AsyncWebServerRequest *request) { + Serial.println("Calibrate edges per mL"); + + if ( request->hasParam("edges") ) + { + String xEdges; + float fEdges = 0; + + xEdges = request->getParam("edges")->value(); + fEdges = xEdges.toFloat(); + + // Update value + fImpulsePerML = fEdges; + EEPROM.writeFloat(0, fEdges);//EEPROM.put(address, param); + EEPROM.commit(); + + Serial.print("Changing fImpulsePerML to "); + Serial.println((float)(fImpulsePerML),4); + + request->send(200, "text/plain", "OK"); + return; + } + else + { + request->send(404, "text/plain", "MISSING_ARGUMENT"); + return; + } + }); +} + + +void LED_Blink(uint8_t nTimes, uint16_t n_delayPeriod) +{ + while(nTimes--) + { + digitalWrite(LED_PIN, HIGH); + delay(n_delayPeriod); + + digitalWrite(LED_PIN, LOW); + delay(n_delayPeriod); + } } diff --git a/Part2_Central_board/Firmware/CentralUnit/PlantSystem_MainLogic.ino b/Part2_Central_board/Firmware/CentralUnit/PlantSystem_MainLogic.ino index 2a6b096..7c9f2a7 100644 --- a/Part2_Central_board/Firmware/CentralUnit/PlantSystem_MainLogic.ino +++ b/Part2_Central_board/Firmware/CentralUnit/PlantSystem_MainLogic.ino @@ -42,7 +42,7 @@ uint8_t gnSolenoidsOnBusCnt = 0; // Plant watering definitions #define WATERING_VOLUME_MINIMUM 10 #define WATERING_VOLUME_MAXIMUM 550 -#define WATERING_FLOW_EDGES_PER_ML 4 +#define WATERING_FLOW_EDGES_PER_L 5880 //98 // PlantSystem_tick periods #define PERIOD_RESCAN_BUS 24*3600*1000 // Every 24h @@ -66,6 +66,7 @@ uint32_t nTimeout = 0; void PlantSystem_tick(void) { + bool bWateringError = false; esp_task_wdt_reset(); // -- Rescan I2C bus @@ -124,15 +125,23 @@ void PlantSystem_tick(void) // -- Process watering request if(bWatering_requestPending || bWatering_inProgress) { + // Watering logic + // Pump creates high pressure inside watering system. Because of this + // at the beginning of watering process we will first, open valve, then turn on pump. + // When we are done, we will first turn off pump and then solenoid. + // + // In previous version, water pump was too weak so we turned on the pump first to keep the pressure on + // and prevent water from "backing up" (i.e letting air in from plant side). + // With peristaltic pump we can open the solenoids first and still keep air out of the system. + // + Serial.println("bWatering_requestPending | bWatering_inProgress"); // Transition to in progress bWatering_inProgress = true; bWatering_requestPending = false; + nFlowSensorCount = 0; - // Note: Solenoid address and volume must have been validated - Serial.println("Turning pump ON"); - digitalWrite(WATER_PUMP_EN_PIN, HIGH); - delay(200); + // - Open solenoid Serial.println("Energizing solenoid"); if (PlantSystem_SetSolenoidState(nWatering_i2cAddress, true) != true) { @@ -141,33 +150,84 @@ void PlantSystem_tick(void) digitalWrite(WATER_PUMP_EN_PIN, LOW); Serial.println("Turning pump OFF"); Serial.println("Failed to open solenoid. Aborting..."); + LED_Blink(4, 250); return; } + + // - Reset flow counter + nFlowSensorCount = 0; + nFlowSensorCount_last = 0; + flowInterruptEnabled(true); + + // - Turn ON pump + bWateringError = true; + Serial.println("Turning pump ON"); + digitalWrite(WATER_PUMP_EN_PIN, HIGH); + esp_task_wdt_reset(); - nTimeout = millis() + 10000; - while(nFlowSensorCount < (WATERING_FLOW_EDGES_PER_ML*nWatering_volume)) + nTimeout = millis() + 10000; // 10sec time-out for watering + + // Calculate water flow + uint32_t nWaterFlow_millis_timestamp = millis(); + float flowMilliLitres = 0.0; + unsigned long totalMilliLitres = 0; + + // Wait until target mL or timeout is reached + while(millis() < nTimeout) { - if(millis() > nTimeout) + // Calculate mL every second + if( millis() >= nWaterFlow_millis_timestamp) { - break; + // Disable interrupts + flowInterruptEnabled(false); + + // Update mL + flowMilliLitres = nFlowSensorCount/fImpulsePerML; + totalMilliLitres += flowMilliLitres; + + // Check if target has been reached + if(totalMilliLitres >= nWatering_volume) + { + bWateringError = false; + break; + } + + // Update for next iteration + nWaterFlow_millis_timestamp = millis() + 1000; + nFlowSensorCount = 0; + esp_task_wdt_reset(); + flowInterruptEnabled(true); } } esp_task_wdt_reset(); - Serial.println("De-Energizing solenoid"); - PlantSystem_SetSolenoidState(nWatering_i2cAddress, false); - delay(200); + // - Turn OFF pump digitalWrite(WATER_PUMP_EN_PIN, LOW); Serial.println("Turning pump OFF"); + delay(200); + + // - Close solenoid + Serial.println("De-Energizing solenoid"); + PlantSystem_SetSolenoidState(nWatering_i2cAddress, false); + // Store last flow data and prepare for new request - nFlowSensorCount_last = nFlowSensorCount; + nFlowSensorCount_last = 0; nFlowSensorCount = 0; + nWatering_volume = 0; // Reset request bWatering_inProgress = false; bWatering_requestPending = false; + if(bWateringError) + { + LED_Blink(4, 250); + } + else + { + LED_Blink(2, 500); + } } @@ -610,10 +670,8 @@ void PlantSystem_RequestPrimeSystem(void) bool PlantSystem_PrimeSystemWithWater(void) { - // Turn ON Pump - Serial.println("Turning pump ON"); - digitalWrite(WATER_PUMP_EN_PIN, HIGH); - delay(1000); + + bool bSolenoidOpen = false; // Turn ON all solenoids Serial.println("Energizing ALL solenoids"); @@ -623,6 +681,7 @@ bool PlantSystem_PrimeSystemWithWater(void) { Serial.print("Openned solenoid 0x"); Serial.println(gSolenoidsOnBus[i].i2cAddress, HEX); + bSolenoidOpen = true; } else { @@ -630,11 +689,25 @@ bool PlantSystem_PrimeSystemWithWater(void) Serial.println(gSolenoidsOnBus[i].i2cAddress, HEX); } delay(500); + + // Only turn on pump AFTER solenoid has oppened + if(bSolenoidOpen) + { + // Turn ON Pump + Serial.println("Turning pump ON"); + digitalWrite(WATER_PUMP_EN_PIN, HIGH); + delay(1000); + } } // Let the water flow for a while delay(5000); + // Turn OFF pump + delay(1000); + Serial.println("Turning pump OFF"); + digitalWrite(WATER_PUMP_EN_PIN, LOW); + // Turn OFF all solenoids Serial.println("De-energizing ALL solenoids"); for(uint8_t i=0; i '0' ORDER BY `solenoidAddress` ASC" + }, + "name": "Select Solenoids", + "type": "n8n-nodes-base.mySql", + "typeVersion": 1, + "position": [ + 660, + 570 + ], + "credentials": { + "mySql": "NAS - MySQL" + } + }, + { + "parameters": { + "batchSize": 1, + "options": { + "reset": false + } + }, + "name": "For Each Solenoid", + "type": "n8n-nodes-base.splitInBatches", + "typeVersion": 1, + "position": [ + 850, + 570 + ], + "alwaysOutputData": true, + "continueOnFail": true + }, + { + "parameters": { + "url": "http://192.168.0.19/waterPlant", + "responseFormat": "string", + "options": {}, + "queryParametersUi": { + "parameter": [ + { + "name": "address", + "value": "={{$json[\"solenoidAddress\"]}}" + }, + { + "name": "volume", + "value": "={{$json[\"waterVolume\"]}}" + } + ] + } + }, + "name": "Water Plant", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 1, + "position": [ + 1260, + 570 + ], + "notesInFlow": false, + "notes": "Send request to water the plant with 100ml of water" + }, + { + "parameters": { + "table": "watering_data", + "columns": "solenoidAddress, waterVolume", + "options": {} + }, + "name": "Update `watering_data`", + "type": "n8n-nodes-base.mySql", + "typeVersion": 1, + "position": [ + 1450, + 770 + ], + "credentials": { + "mySql": "NAS - MySQL" + } + }, + { + "parameters": { + "functionCode": "const sleepTime = 25000; // in miliseconds\n\nfunction sleep(milliseconds) {\n return new Promise(\n resolve => setTimeout(resolve, milliseconds)\n );\n}\n// Sleep\nawait sleep(sleepTime );\n// Output data\nreturn items;\n" + }, + "name": "Delay", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [ + 1450, + 570 + ], + "notesInFlow": false, + "notes": "Sleep" + }, + { + "parameters": { + "conditions": { + "number": [], + "boolean": [ + { + "value1": "={{$node[\"For Each Solenoid\"].context[\"noItemsLeft\"]}}", + "value2": true + } + ] + } + }, + "name": "IF", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 1660, + 320 + ] + }, + { + "parameters": {}, + "name": "NoOp", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 1860, + 420 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "waterVolume", + "value": 50 + } + ] + }, + "options": {} + }, + "name": "Water Volume", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 1060, + 570 + ] + }, + { + "parameters": { + "chatId": "1254950103", + "text": "=Finished watering the plants!", + "additionalFields": {} + }, + "name": "Telegram", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1, + "position": [ + 1860, + 270 + ], + "credentials": { + "telegramApi": "Telegram N8N" + } + }, + { + "parameters": { + "triggerTimes": { + "item": [ + { + "hour": 5 + }, + { + "hour": 19 + } + ] + } + }, + "name": "Cron", + "type": "n8n-nodes-base.cron", + "typeVersion": 1, + "position": [ + 460, + 570 + ] + }, + { + "parameters": { + "path": "f7048627-a726-4344-8d49-e7ad19c4415a", + "options": {} + }, + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [ + 460, + 370 + ], + "webhookId": "f7048627-a726-4344-8d49-e7ad19c4415a" + } + ], + "connections": { + "Select Solenoids": { + "main": [ + [ + { + "node": "For Each Solenoid", + "type": "main", + "index": 0 + } + ] + ] + }, + "For Each Solenoid": { + "main": [ + [ + { + "node": "Water Volume", + "type": "main", + "index": 0 + } + ] + ] + }, + "Water Plant": { + "main": [ + [ + { + "node": "Delay", + "type": "main", + "index": 0 + }, + { + "node": "Update `watering_data`", + "type": "main", + "index": 0 + } + ] + ] + }, + "Delay": { + "main": [ + [ + { + "node": "IF", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF": { + "main": [ + [ + { + "node": "NoOp", + "type": "main", + "index": 0 + }, + { + "node": "Telegram", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "For Each Solenoid", + "type": "main", + "index": 0 + } + ] + ] + }, + "Water Volume": { + "main": [ + [ + { + "node": "Water Plant", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cron": { + "main": [ + [ + { + "node": "Select Solenoids", + "type": "main", + "index": 0 + } + ] + ] + }, + "Webhook": { + "main": [ + [ + { + "node": "Select Solenoids", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "id": "8" +} \ No newline at end of file diff --git a/Part3_Web_and_Automation/n8n/PlantSystem_-_Read_Sensor_Data.json b/Part3_Web_and_Automation/n8n/PlantSystem_-_Read_Sensor_Data.json new file mode 100644 index 0000000..ba752fc --- /dev/null +++ b/Part3_Web_and_Automation/n8n/PlantSystem_-_Read_Sensor_Data.json @@ -0,0 +1,186 @@ +{ + "name": "Plant System - Read Sensor Data", + "nodes": [ + { + "parameters": {}, + "name": "Start", + "type": "n8n-nodes-base.start", + "typeVersion": 1, + "position": [ + 250, + 350 + ] + }, + { + "parameters": { + "url": "http://192.168.0.19/sensorData", + "allowUnauthorizedCerts": true, + "options": { + "followRedirect": true, + "ignoreResponseCode": true + } + }, + "name": "HTTP Request", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 1, + "position": [ + 500, + 350 + ], + "continueOnFail": true + }, + { + "parameters": { + "chatId": "1254950103", + "text": "=HousePlantSystem: Reading {{$node[\"HTTP Request\"].json[\"count\"]}} sensors instead of 4.", + "additionalFields": {} + }, + "name": "Telegram", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1, + "position": [ + 1000, + 150 + ], + "credentials": { + "telegramApi": "Telegram N8N" + } + }, + { + "parameters": { + "table": "sensor_data", + "columns": "address, T, M, A", + "options": {} + }, + "name": "MySQL", + "type": "n8n-nodes-base.mySql", + "typeVersion": 1, + "position": [ + 1000, + 350 + ], + "credentials": { + "mySql": "NAS - MySQL" + } + }, + { + "parameters": { + "functionCode": "const sensorData = [];\n\nfor (let i=0; i setTimeout(resolve, milliseconds)\n );\n}\n// Sleep\nawait sleep(sleepTime );\n// Output data\nreturn items;\n" + }, + "name": "Delay", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [ + 1700, + 550 + ], + "notesInFlow": false, + "notes": "Sleep" + }, + { + "parameters": { + "conditions": { + "number": [], + "boolean": [ + { + "value1": "={{$node[\"For Each Solenoid\"].context[\"noItemsLeft\"]}}", + "value2": true + } + ] + } + }, + "name": "IF", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 1900, + 300 + ] + }, + { + "parameters": {}, + "name": "NoOp", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 2100, + 300 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "waterVolume", + "value": 50 + } + ] + }, + "options": {} + }, + "name": "Water Volume", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 1300, + 550 + ] + }, + { + "parameters": { + "chatId": "1254950103", + "text": "=Watering {{$json[\"plantName\"]}} with {{$json[\"waterVolume\"]}}mL of water", + "additionalFields": {} + }, + "name": "Telegram", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1, + "position": [ + 1500, + 950 + ], + "credentials": { + "telegramApi": "Telegram N8N" + } + }, + { + "parameters": { + "path": "4d08ae89-8eba-4ee4-80c2-d8f93ff51adb", + "options": {} + }, + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [ + 700, + 390 + ], + "webhookId": "4d08ae89-8eba-4ee4-80c2-d8f93ff51adb" + } + ], + "connections": { + "Start": { + "main": [ + [ + { + "node": "Select Solenoids", + "type": "main", + "index": 0 + } + ] + ] + }, + "Select Solenoids": { + "main": [ + [ + { + "node": "For Each Solenoid", + "type": "main", + "index": 0 + } + ] + ] + }, + "For Each Solenoid": { + "main": [ + [ + { + "node": "Water Volume", + "type": "main", + "index": 0 + } + ] + ] + }, + "Delay": { + "main": [ + [ + { + "node": "IF", + "type": "main", + "index": 0 + } + ] + ] + }, + "Water Plant": { + "main": [ + [ + { + "node": "Delay", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF": { + "main": [ + [ + { + "node": "NoOp", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "For Each Solenoid", + "type": "main", + "index": 0 + } + ] + ] + }, + "Water Volume": { + "main": [ + [ + { + "node": "Update `watering_data`", + "type": "main", + "index": 0 + }, + { + "node": "Telegram", + "type": "main", + "index": 0 + }, + { + "node": "Water Plant", + "type": "main", + "index": 0 + } + ] + ] + }, + "Webhook": { + "main": [ + [ + { + "node": "Select Solenoids", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": {}, + "id": "4" +} \ No newline at end of file diff --git a/Part3_Web_and_Automation/n8n/PlantSystem_-_Water_Specific_Plant.json b/Part3_Web_and_Automation/n8n/PlantSystem_-_Water_Specific_Plant.json new file mode 100644 index 0000000..2854a89 --- /dev/null +++ b/Part3_Web_and_Automation/n8n/PlantSystem_-_Water_Specific_Plant.json @@ -0,0 +1,208 @@ +{ + "name": "PlantSystem - Water Specific Plant", + "nodes": [ + { + "parameters": {}, + "name": "Start", + "type": "n8n-nodes-base.start", + "typeVersion": 1, + "position": [ + 800, + 750 + ] + }, + { + "parameters": { + "url": "http://192.168.0.19/waterPlant", + "responseFormat": "string", + "options": {}, + "queryParametersUi": { + "parameter": [ + { + "name": "address", + "value": "={{$json[\"solenoidAddress\"]}}" + }, + { + "name": "volume", + "value": "={{$node[\"Webhook\"].json[\"query\"][\"volume\"]}}" + } + ] + } + }, + "name": "Water Plant", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 1, + "position": [ + 1200, + 550 + ], + "notesInFlow": false, + "alwaysOutputData": true, + "continueOnFail": true, + "notes": "Send request to water the plant with 100ml of water" + }, + { + "parameters": { + "path": "756fac76-f3d7-4fce-b586-27e114afba46", + "options": {} + }, + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [ + 800, + 550 + ], + "webhookId": "756fac76-f3d7-4fce-b586-27e114afba46" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "=SELECT * from `sensor_links` where `solenoidAddress` = '{{$json[\"query\"][\"solenoidAddress\"]}}' LIMIT 1" + }, + "name": "MySQL", + "type": "n8n-nodes-base.mySql", + "typeVersion": 1, + "position": [ + 1000, + 550 + ], + "credentials": { + "mySql": "NAS - MySQL" + } + }, + { + "parameters": { + "table": "watering_data", + "columns": "=solenoidAddress, waterVolume", + "options": {} + }, + "name": "Insert Water Amount", + "type": "n8n-nodes-base.mySql", + "typeVersion": 1, + "position": [ + 1810, + 450 + ], + "credentials": { + "mySql": "NAS - MySQL" + } + }, + { + "parameters": { + "functionCode": "items[0].json.waterVolume = $node[\"Webhook\"].json[\"query\"][\"volume\"];\nitems[0].json.solenoidAddress= $node[\"MySQL\"].json[\"solenoidAddress\"];\nreturn items;\n" + }, + "name": "Function", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [ + 1610, + 450 + ] + }, + { + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{$json[\"data\"]}}", + "operation": "contains", + "value2": "\"result\": OK" + } + ] + } + }, + "name": "IF", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 1400, + 550 + ] + }, + { + "parameters": { + "chatId": "1254950103", + "text": "=Water specific plant failed: Plant #{{$node[\"MySQL\"].json[\"sensorAddress\"]}} - Volume:{{$node[\"Webhook\"].json[\"query\"][\"volume\"]}}ml", + "additionalFields": {} + }, + "name": "Telegram", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1, + "position": [ + 1600, + 650 + ], + "credentials": { + "telegramApi": "Telegram N8N" + } + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "MySQL", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL": { + "main": [ + [ + { + "node": "Water Plant", + "type": "main", + "index": 0 + } + ] + ] + }, + "Function": { + "main": [ + [ + { + "node": "Insert Water Amount", + "type": "main", + "index": 0 + } + ] + ] + }, + "Water Plant": { + "main": [ + [ + { + "node": "IF", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF": { + "main": [ + [ + { + "node": "Function", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Telegram", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": {}, + "id": "5" +} \ No newline at end of file diff --git a/README.md b/README.md index 622f631..2ce6991 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ Blog page: https://sasakaranovic.com/projects/diy-house-plants-watering-system-p Build watering system for house plants. In part two of this video series, we will create a central unit that takes sensor readings and also waters our plants on command. -## Part 3: Dashboard +## Part 3: Automation and Conclusion + +[![DIY House Plants Watering System - Part 3: Automation and Conclusion](http://i3.ytimg.com/vi/jLdTUMHOhRE/maxresdefault.jpg)](https://youtu.be/jLdTUMHOhRE) + +Watch: [DIY House Plants Watering System // Part 2: Automation and Conclusion](https://youtu.be/jLdTUMHOhRE) + +Blog page: https://sasakaranovic.com/projects/diy-house-plants-watering-system-part-3-automation/ + +Build watering system for house plants. In part two of this video series, we will create a central unit that takes sensor readings and also waters our plants on command. -TBD