About this page & a note on secrets
This is the complete build-and-reference documentation for the system, mirroring the dissertation: hardware, wiring, firmware, the Raspberry Pi gateway, the irrigation logic, and the Firebase back end. Every credential shown below is a placeholder — never publish real Wi-Fi or broker credentials, and never publish the Firebase service-account key.
System Architecture — Overview
The smart irrigation system consists of four main layers, each handled by a different component. The following diagram shows the complete data flow from the physical sensors in the soil to the user's browser:
flowchart LR
subgraph Field["Field (Outdoor)"]
A["Soil Moisture<br/>(Capacitive)"]
B["Air Temp + Humidity<br/>(DHT11)"]
C["Light Level<br/>(BH1750)"]
end
subgraph SensorNode["ESP32 (TTGO T-Higrow)"]
D["Read sensors<br/>Build JSON<br/>Publish via MQTT"]
end
subgraph RaspberryPi["Raspberry Pi"]
E["Mosquitto<br/>MQTT Broker"]
F["guarda_dados.py<br/>MQTT → Firestore"]
end
subgraph Cloud["Google Cloud (Firebase)"]
G[(Firestore<br/>estufa)]
H["Cloud Functions<br/>Cache + API"]
I["Firebase Hosting<br/>CDN"]
end
subgraph User["End User"]
J["Web Browser<br/>estufaiscte.pt"]
end
A --> D
B --> D
C --> D
D -->|"Wi-Fi<br/>MQTT QoS 1"| E
E --> F
F -->|"HTTPS<br/>Firebase SDK"| G
G -->|"Trigger"| H
H -->|"Cached response"| I
I -->|"HTTPS"| J
style A fill:#e1f5ee,stroke:#0f6e56,color:#04342c
style B fill:#e1f5ee,stroke:#0f6e56,color:#04342c
style C fill:#e1f5ee,stroke:#0f6e56,color:#04342c
style D fill:#faeeda,stroke:#854f0b,color:#412402
style E fill:#e6f1fb,stroke:#185fa5,color:#042c53
style F fill:#e6f1fb,stroke:#185fa5,color:#042c53
style G fill:#eeedfe,stroke:#534ab7,color:#26215c
style H fill:#eeedfe,stroke:#534ab7,color:#26215c
style I fill:#eeedfe,stroke:#534ab7,color:#26215c
style J fill:#faece7,stroke:#993c1d,color:#4a1b0c
Each layer is described in detail in the chapters that follow.
Sensor Node — LILYGO TTGO T-Higrow
1. Hardware Specifications
The sensor node used in this system is the LILYGO TTGO T-Higrow [11], a development board purpose-built for environmental and plant monitoring. The board integrates the following components onto a single PCB:
- ESP32 microcontroller — a dual-core 32-bit processor running at 240 MHz with built-in Wi-Fi (802.11 b/g/n) and Bluetooth 4.2 [1]. The Wi-Fi capability allows the board to connect directly to a local network and transmit data wirelessly without any additional modules. The ESP32 also supports deep sleep mode, which reduces power consumption to approximately 10 µA [1], making it suitable for battery-powered deployments in the field.
- Capacitive soil moisture sensor — connected to GPIO 32 (analog input). Unlike resistive soil moisture sensors, a capacitive sensor does not have exposed metal electrodes in direct contact with the soil. This means it does not suffer from corrosion over time, which significantly increases its lifespan and measurement reliability in long-term outdoor deployments.
- DHT11 digital sensor — connected to GPIO 16. This sensor measures both air temperature (range: 0–50 °C, accuracy: ±2 °C) and relative air humidity (range: 20–80%, accuracy: ±5%) [2]. It communicates over a single-wire digital protocol. While not the most precise sensor available, the DHT11 provides sufficient accuracy for irrigation decision-making, where the goal is to detect general environmental trends rather than laboratory-grade precision.
- BH1750 ambient light sensor — connected via the I2C bus (SDA on GPIO 25, SCL on GPIO 26). This sensor measures light intensity in lux (range: 1–65535 lux, resolution: 1 lux) [3]. Light data is relevant for irrigation because evaporation rates and plant water consumption are directly influenced by sunlight exposure. A plant receiving high light intensity will typically require more water than one in shade.
- Power control pin (GPIO 4) — the board includes a dedicated power pin that controls the supply voltage to the onboard sensors. By setting this pin HIGH, the sensors receive power and become active. Setting it LOW cuts power to the sensors entirely. This mechanism is essential for energy efficiency: the sensors are only powered during the brief reading window, and remain off during deep sleep.
1.1 Pin Connection Map
The following table shows every electrical connection between the ESP32 and the onboard sensors. All connections are pre-wired on the TTGO T-Higrow PCB — no external wiring is required.
| ESP32 GPIO | Direction | Connected to | Signal type | Protocol |
|---|---|---|---|---|
| GPIO 4 | Output | Sensor power rail | Digital HIGH/LOW | — |
| GPIO 16 | Bidirectional | DHT11 data pin | Single-wire, 40-bit frames | Proprietary |
| GPIO 25 | Bidirectional | BH1750 SDA | Serial data | I2C |
| GPIO 26 | Output | BH1750 SCL | Serial clock | I2C |
| GPIO 32 | Input | Capacitive soil probe | Analog voltage (0–3.3 V) | ADC (12-bit) |
| 3.3 V | Power | DHT11 VCC, BH1750 VCC | — | — |
| GND | Power | DHT11 GND, BH1750 GND, Soil probe GND | — | — |
The following diagram illustrates the internal connections on the TTGO T-Higrow board:
flowchart TD
subgraph ESP32["ESP32 Microcontroller"]
GPIO4["GPIO 4<br/>(Power Control)"]
GPIO16["GPIO 16<br/>(DHT11 Data)"]
GPIO25["GPIO 25<br/>(I2C SDA)"]
GPIO26["GPIO 26<br/>(I2C SCL)"]
GPIO32["GPIO 32<br/>(ADC Input)"]
WIFI["Wi-Fi Radio<br/>802.11 b/g/n"]
end
subgraph PowerRail["Sensor Power Rail"]
VCC["3.3 V (switched)"]
end
subgraph Sensors["Onboard Sensors"]
DHT["DHT11<br/>Temp + Humidity"]
BH["BH1750<br/>Light (I2C 0x23)"]
SOIL["Capacitive<br/>Soil Probe"]
end
subgraph Network["Local Network"]
BROKER["Mosquitto Broker<br/>(Raspberry Pi)"]
end
GPIO4 -->|"HIGH = ON<br/>LOW = OFF"| VCC
VCC --> DHT
VCC --> BH
VCC --> SOIL
GPIO16 <-->|"Single-wire"| DHT
GPIO25 <-->|"SDA"| BH
GPIO26 -->|"SCL"| BH
SOIL -->|"Analog 0–3.3 V"| GPIO32
WIFI -->|"MQTT QoS 1<br/>Port 1883"| BROKER
style ESP32 fill:#faeeda,stroke:#854f0b,color:#412402
style Sensors fill:#e1f5ee,stroke:#0f6e56,color:#04342c
style Network fill:#e6f1fb,stroke:#185fa5,color:#042c53
style PowerRail fill:#eeedfe,stroke:#534ab7,color:#26215c
Note: The I2C bus (GPIO 25 and 26) uses non-default pins. The standard ESP32 I2C pins are GPIO 21 (SDA) and GPIO 22 (SCL), but the TTGO T-Higrow routes them to GPIO 25 and 26 on its PCB. This is why the firmware explicitly declares them with
Wire.begin(25, 26)instead of using the defaultWire.begin().
2. Sensor Types and Design Rationale
The TTGO T-Higrow board integrates three sensors that use two different communication paradigms: one analog and two digital. The following table summarises each sensor, its interface, and the rationale for that interface type.
| Sensor | Interface | GPIO | Protocol | Why this interface |
|---|---|---|---|---|
| Capacitive soil moisture | Analog (12-bit ADC) | 32 | Voltage reading | Capacitive sensing is inherently analog — the sensor outputs a continuous voltage proportional to soil water content. A digital interface would add cost to a component designed to remain buried in soil permanently. |
| DHT11 (temp + humidity) | Digital | 16 | Single-wire, 40-bit frame with checksum | Temperature and humidity benefit from the sensor's internal calibration and error-checked transmission. The built-in checksum allows corrupted readings to be detected and discarded. |
| BH1750 (light) | Digital | SDA 25, SCL 26 | I2C bus (address 0x23) |
The BH1750's internal 16-bit ADC provides higher resolution than the ESP32's 12-bit ADC could achieve with a raw analog photodiode. The I2C bus also allows multiple sensors to share two wires. |
The analog sensor (soil moisture) is susceptible to electrical noise, which is mitigated in firmware through value clamping (constrain()). The digital sensors are inherently noise-resistant because data is transmitted as discrete packets rather than continuous voltages.
2.1 Measurement Uncertainty and Sensor Limitations
The following table summarises the accuracy specifications of each sensor, as stated in their respective datasheets:
| Sensor | Measurement | Range | Accuracy | Resolution |
|---|---|---|---|---|
| Capacitive soil moisture | Soil moisture | 0–100% (mapped) | ±3–5% (estimated) | ~0.08% (12-bit ADC) |
| DHT11 | Air temperature | 0–50 °C | ±2 °C | 1 °C |
| DHT11 | Relative humidity | 20–80% | ±5% | 1% |
| BH1750 | Light intensity | 1–65535 lux | ±20% (typical) | 1 lux |
The DHT11 is the least precise sensor in the system. Alternatives such as the DHT22 (±0.5 °C, ±2% RH) [4] or the SHT31 (±0.3 °C, ±2% RH) [5] offer significantly better accuracy. However, the DHT11 was not an active design choice — it comes pre-soldered on the TTGO T-Higrow board. Replacing it would require desoldering the component and rewiring, which negates the board's integrated design and risks damaging the PCB. The DHT11's precision is adequate for this application because irrigation decisions are based on trends and thresholds over hours, not on point-accurate measurements. A ±2 °C error does not affect whether a temperature trend indicates increased evaporation, and a ±5% humidity uncertainty falls well within the decision band between "dry" and "adequately moist" soil.
The capacitive soil moisture sensor lacks a published accuracy specification because its output depends on calibration quality and soil composition. The estimated ±3–5% uncertainty is derived from the ESP32 ADC's documented non-linearity at the extremes of its input range (0–3.3 V) [6]. This should be validated empirically during deployment.
For future iterations requiring higher precision (e.g., scientific research or tightly controlled greenhouse environments), the DHT11 should be replaced with a DHT22 or SHT31 — a firmware change limited to the sensor type definition and library initialisation.
3. Soil Moisture Sensor Calibration
The capacitive soil moisture sensor returns a raw analog value between 0 and 4095 (the ESP32 ADC is 12-bit). This raw value has no inherent meaning on its own — it must be mapped to a percentage scale through a calibration process.
3.1 Calibration Procedure — Step by Step
- Prepare dry soil — a sample of the target soil (the same type used in the garden or greenhouse) was placed in a container and left to dry in direct sunlight for several days until completely dry to the touch.
- Record dry reference — the sensor was fully inserted into the dry soil. The serial monitor in the Arduino IDE was opened (
Serial.begin(115200)) and the raw analog value was observed usingSerial.println(analogRead(32)). Multiple readings were taken over 30 seconds and averaged. The result was 3425, which represents the 0% moisture reference point. - Saturate the soil — the same soil sample was then thoroughly watered until it was visibly waterlogged, with water pooling on the surface.
- Record wet reference — the sensor was inserted into the saturated soil and the raw analog value was recorded and averaged using the same procedure. The result was 1635, which represents the 100% moisture reference point.
- Define constants in code — the two reference values were added to the firmware as compile-time constants:
const int ValorSeco = 3425; // Raw ADC value in completely dry soil
const int ValorMolhado = 1635; // Raw ADC value in fully saturated soil
- Map and constrain — in the main code, the raw reading is linearly mapped between these two extremes using the Arduino
map()function, and clamped to the 0–100 range usingconstrain()to prevent out-of-bounds values caused by minor sensor fluctuations:
int valorBrutoSolo = analogRead(PinoSensorHumidadeSolo);
int percentagemSolo = map(valorBrutoSolo, ValorSeco, ValorMolhado, 0, 100);
percentagemSolo = constrain(percentagemSolo, 0, 100);
Note: Calibration values are specific to the soil type and sensor unit used. If the system is deployed in a different soil composition (e.g., clay vs. sandy soil), the calibration process should be repeated to ensure accurate readings.
4. Development Environment
The firmware was developed in the Arduino IDE (v2.x) with the Espressif ESP32 board package and four external libraries: PubSubClient (MQTT client, by Nick O'Leary), DHT sensor library (Adafruit), BH1750 (Christopher Laws), and ArduinoJson (Benoît Blanchon). The compiled binary was uploaded to the TTGO T-Higrow via USB-C using the CP2104 serial interface. Detailed installation and configuration steps are provided in Appendix C.
Subsequent upgrade:
PubSubClientwas later replaced withMQTT(thearduino-mqttlibrary by Joël Gähwiler / 256dpi) so the publish side could operate at QoS 1. The other three libraries are unchanged. Full rationale and API differences are in Section 5.1 and in Section 2.2 of the Raspberry Pi chapter.
5. Firmware — Full Code Walkthrough
The firmware runs entirely inside the setup() function. The loop() function is intentionally left empty because the ESP32 enters deep sleep at the end of setup() and never reaches loop(). When the deep sleep timer expires, the ESP32 reboots and re-executes setup() from the beginning, effectively creating a timed cycle.
5.1 Library Imports and Pin Definitions
#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
#include <Wire.h>
#include <BH1750.h>
#include <ArduinoJson.h>
| Library | Purpose |
|---|---|
WiFi.h |
Provides the ESP32 Wi-Fi connectivity functions (connect, disconnect, status check). |
PubSubClient.h |
Implements the MQTT client, allowing the ESP32 to connect to an MQTT broker and publish messages [14]. |
DHT.h |
Handles communication with the DHT11 sensor for air temperature and humidity readings. |
Wire.h |
Implements the I2C protocol used to communicate with the BH1750 light sensor. |
BH1750.h |
Provides high-level functions to initialize and read data from the BH1750 ambient light sensor. |
ArduinoJson.h |
Handles JSON serialisation. Produces well-formed JSON regardless of how the ESP32 formats floating-point values, avoiding malformed payloads that manual String concatenation could produce. |
#define PinoSensorHumidadeSolo 32
#define PowerPIN 4
#define I2C_SDA 25
#define I2C_SCL 26
#define DHTPIN 16
#define TypeDHT DHT11
These definitions assign human-readable names to the GPIO pins used by each component. This makes the code easier to maintain — if a sensor is reconnected to a different pin, only the definition needs to change.
Subsequent upgrade:
PubSubClientwas later replaced withMQTT(thearduino-mqttlibrary by Joël Gähwiler / 256dpi) so the publish side could operate at QoS 1 (Section 2.2 of the Raspberry Pi chapter).PubSubClientaccepts a QoS argument only onsubscribe()— itspublish()API has no QoS parameter and transmits unconditionally at QoS 0. The replacement library keeps a synchronous API that closely mirrorsPubSubClient, so the surrounding firmware code (helpers, JSON serialisation, deep-sleep flow) was largely unaffected; the changes are limited to a handful of API call sites flagged inline in Sections 5.2, 5.4, 5.8 and 5.9. The only line that changes in this subsection is the include:#include <PubSubClient.h>becomes#include <MQTT.h>.
5.2 Object Initialization
DHT dht(DHTPIN, TypeDHT);
BH1750 lightMeter;
Two sensor objects are created globally. The dht object is initialized with its data pin (GPIO 16) and sensor type (DHT11). The lightMeter object represents the BH1750 and will be configured later in setup().
WiFiClient espClient;
PubSubClient client(espClient);
The WiFiClient provides the underlying TCP connection, and PubSubClient wraps it with MQTT functionality. This layered approach means the MQTT client uses the Wi-Fi connection as its transport layer.
Subsequent upgrade: When the MQTT library was swapped to
arduino-mqtt(Section 5.1), the client constructor changed. The new library takes a buffer size rather than a transport reference, so the two-line declaration above becomes:
WiFiClient espClient;
MQTTClient client(256); // buffer size in bytes
The
WiFiClientis still required — it is now passed toclient.begin(host, port, espClient)insideligarMQTT()(Section 5.4 and 5.8) instead of being handed to the constructor.
5.3 Calibration Constants, Timing, and Network Configuration
const int ValorSeco = 3425;
const int ValorMolhado = 1635;
#define TEMPO_DORMIR 300 // Deep sleep duration in seconds (5 min)
#define WIFI_TIMEOUT_MS 15000 // Max time to wait for Wi-Fi (15 s)
#define MQTT_TIMEOUT_MS 10000 // Max time to wait for MQTT (10 s)
const char* ssid = "<your-wifi-ssid>";
const char* password = "<your-wifi-password>";
const char* mqtt_server = "<broker-ip>"; // e.g. 192.168.1.100
The calibration constants (obtained through the process described in Section 3) define the dry and wet reference points. TEMPO_DORMIR sets the deep sleep duration in seconds (300 = 5 minutes). Two timeout constants were introduced to prevent the device from blocking indefinitely when the network is unavailable: WIFI_TIMEOUT_MS limits the Wi-Fi connection attempt to 15 seconds, and MQTT_TIMEOUT_MS limits the broker connection attempt to 10 seconds. If either timeout is reached, the device skips transmission entirely and enters deep sleep to conserve battery — the reading is lost, but the next one arrives 5 minutes later. Without these timeouts, a downed router or broker would cause the ESP32 to loop indefinitely until the battery drains completely.
Production note: The Wi-Fi SSID, password, and broker IP are hard-coded in the firmware for prototype simplicity. In a production deployment, these values should be externalised — either stored in the ESP32's non-volatile storage (NVS) partition and configured via a provisioning interface (e.g., BLE or a captive portal on first boot), or loaded from a configuration file on the SPIFFS filesystem. This would allow redeployment to a different network without recompiling and re-flashing the firmware.
Subsequent upgrade: When broker authentication was enabled (Section 2.3 of the Raspberry Pi chapter), two additional constants were declared alongside the network credentials to hold the broker username and password, plus a third for the PUBACK timeout introduced with QoS 1:
const char* mqtt_user = "<broker-user>";
const char* mqtt_pass = "<broker-password>";
#define MQTT_PUBACK_TIMEOUT 3000 // Max time to wait for the PUBACK (ms)
The same production-note caveat applies to the broker credentials: in a real deployment they should not be hard-coded in firmware.
5.4 Helper Functions
Three helper functions centralise logic that would otherwise be duplicated across multiple code paths:
entrarDeepSleep() — encapsulates the complete shutdown sequence (MQTT disconnect, sensor power off, Wi-Fi disconnect, deep sleep entry). This function is called from every exit path — successful transmission, Wi-Fi timeout, MQTT timeout, and sensor failure — ensuring that the device always shuts down cleanly regardless of the reason.
void entrarDeepSleep() {
client.disconnect();
delay(200);
digitalWrite(PowerPIN, LOW);
WiFi.disconnect(true);
delay(100);
esp_sleep_enable_timer_wakeup(TEMPO_DORMIR * 1000000ULL);
esp_deep_sleep_start();
}
ligarWiFi() — attempts to connect to the Wi-Fi network and returns true on success or false if the timeout is reached. The original firmware used an infinite while loop; this version checks millis() on every iteration and aborts after WIFI_TIMEOUT_MS milliseconds.
bool ligarWiFi() {
WiFi.begin(ssid, password);
unsigned long inicio = millis();
while (WiFi.status() != WL_CONNECTED) {
if (millis() - inicio > WIFI_TIMEOUT_MS) return false;
delay(500);
}
return true;
}
ligarMQTT() — attempts to connect to the MQTT broker and returns true on success or false if the timeout is reached. Same timeout pattern as ligarWiFi().
bool ligarMQTT() {
client.setServer(mqtt_server, 1883);
unsigned long inicio = millis();
while (!client.connected()) {
if (millis() - inicio > MQTT_TIMEOUT_MS) return false;
if (client.connect("ESP32_Estufa")) return true;
delay(2000);
}
return true;
}
5.5 Setup — Sensor Power-Up and Initialization
pinMode(PowerPIN, OUTPUT);
digitalWrite(PowerPIN, HIGH);
delay(1000);
GPIO 4 is configured as an output and set HIGH to supply power to the onboard sensors. A 1-second delay follows to allow the sensors' internal circuitry to stabilize before any readings are taken. Without this delay, the first readings could be unreliable.
dht.begin();
Wire.begin(I2C_SDA, I2C_SCL);
lightMeter.begin(BH1750::ONE_TIME_HIGH_RES_MODE);
dht.begin()initializes the DHT11 communication protocol.Wire.begin(25, 26)starts the I2C bus on the specified SDA and SCL pins. The TTGO T-Higrow uses non-default I2C pins, so they must be explicitly declared.lightMeter.begin(BH1750::ONE_TIME_HIGH_RES_MODE)configures the BH1750 to take a single high-resolution measurement and then power down. This mode is ideal for battery-powered applications because the sensor does not continuously consume power — it only activates for the duration of one reading.
delay(2000);
An additional 2-second delay ensures all sensors have completed their internal startup sequences. The DHT11 in particular requires approximately 1–2 seconds after initialization before it can provide a stable reading.
5.6 Sensor Readings and Validation
int valorBrutoSolo = analogRead(PinoSensorHumidadeSolo);
int percentagemSolo = map(valorBrutoSolo, ValorSeco, ValorMolhado, 0, 100);
percentagemSolo = constrain(percentagemSolo, 0, 100);
The soil moisture sensor is read via the ESP32's 12-bit ADC on GPIO 32. The raw value (ranging from 0 to 4095) is mapped to a 0–100% scale using the calibration constants obtained during the wet/dry calibration process described in Section 3. The constrain() function ensures the result stays within bounds — without it, a raw value slightly outside the calibration range (due to temperature drift or sensor noise) could produce a negative percentage or a value above 100.
float humidadeAr = dht.readHumidity();
float temperaturaAr = dht.readTemperature();
Two separate calls read the relative humidity (%) and temperature (°C) from the DHT11. These are returned as floating-point values. If the sensor fails to respond (e.g., a wiring issue), the functions return NaN (Not a Number).
float nivelLuz = lightMeter.readLightLevel();
The BH1750 returns the ambient light intensity in lux as a floating-point value. Since the sensor was initialized in one-time mode, this call triggers the measurement, waits for the result, and then the sensor goes idle.
NaN validation — after all sensors are read, the firmware checks whether any reading returned NaN. If so, the entire transmission is skipped and the device enters deep sleep immediately:
if (isnan(humidadeAr) || isnan(temperaturaAr) || isnan(nivelLuz)) {
Serial.println("Erro: leitura NaN detetada. A saltar transmissao.");
entrarDeepSleep();
return;
}
This check prevents the device from wasting battery on a Wi-Fi connection and MQTT transmission only to send corrupted data. Without it, a NaN value would be serialised into the JSON payload as the string "nan", which the Cloud Function's Number() conversion would then reject — but only after the ESP32 had already spent 3–5 seconds connected to the network. By catching the error early, the active time is reduced from ~8 seconds to ~3 seconds on failure cycles, directly extending battery life.
5.7 Wi-Fi Connection
if (!ligarWiFi()) {
Serial.println("Wi-Fi indisponivel. A saltar transmissao.");
entrarDeepSleep();
return;
}
The Wi-Fi connection now uses the ligarWiFi() helper function (defined in Section 5.4), which returns false if the connection is not established within WIFI_TIMEOUT_MS (15 seconds). If the timeout is reached, the device skips all network activity, enters deep sleep, and retries on the next wake cycle. This prevents the device from draining its battery on a downed or out-of-range router.
5.8 MQTT Connection and Data Transmission
if (!ligarMQTT()) {
Serial.println("MQTT broker indisponivel. A saltar transmissao.");
entrarDeepSleep();
return;
}
The MQTT connection uses the ligarMQTT() helper function (defined in Section 5.4), which applies the same timeout pattern as the Wi-Fi connection — if the broker is unreachable within MQTT_TIMEOUT_MS (10 seconds), the device enters deep sleep. The client identifies itself as "ESP32_Estufa", a unique client ID required by the MQTT protocol. A detailed explanation of MQTT, the broker, and QoS levels is provided in the Raspberry Pi chapter (Section 2 — Communication Protocol — MQTT).
JsonDocument doc;
doc["sensor"] = 1;
doc["humidade_solo"] = percentagemSolo;
doc["temp_ar"] = temperaturaAr;
doc["humidade_ar"] = humidadeAr;
doc["luminosidade"] = nivelLuz;
char payload[256];
serializeJson(doc, payload);
All sensor readings are packed into a JSON string using the ArduinoJson library. A JsonDocument object acts as an in-memory representation of the JSON structure — fields are assigned using simple key-value syntax, and serializeJson() converts the document into a valid JSON string written to a fixed-size char buffer (256 bytes, more than sufficient for 5 fields).
The ArduinoJson library is used for JSON serialisation because it guarantees well-formed output regardless of how the ESP32 internally formats floating-point values. Without a dedicated serialisation library, JSON would need to be built by manually concatenating strings — an approach prone to producing malformed payloads if a float is represented in scientific notation (e.g., 1.2e+03 instead of 1200.0). ArduinoJson handles number formatting, character escaping, and structural syntax (braces, commas, colons) automatically. It also writes directly to a fixed-size char buffer on the stack, avoiding the repeated heap allocations that String concatenation would cause on a microcontroller with limited RAM [13].
JSON was chosen as the payload format because it is human-readable, easy to parse on the server side (Python, JavaScript, etc.), and universally supported. Each field is labeled with a descriptive key so the receiving end knows exactly what each value represents without relying on positional encoding.
bool sucesso = client.publish("sensores/jardim", payload, true);
The JSON payload is published to the MQTT topic sensores/jardim. The third parameter (true) enables the retained message flag, meaning the broker will store this message and deliver it immediately to any new subscriber that connects later. The function returns true if the message was successfully placed in the outgoing buffer (note: this does not guarantee delivery to the broker — it only confirms the local buffer accepted it).
Naming note: The MQTT client identifies itself to the broker as
"ESP32_Estufa". This client ID is an arbitrary string used only by the broker to distinguish connected devices — it does not need to match the topic name. In this system the same firmware base is intended to serve both a garden sensor (topic:sensores/jardim) and a greenhouse sensor (topic:sensores/estufa). The client IDESP32_Estufawas assigned during the initial greenhouse-oriented development phase and was not updated when the garden deployment was added. In a multi-node production setup, each node should be given a descriptive client ID that reflects its physical location (e.g.,ESP32_Jardim,ESP32_Estufa) to make broker logs easier to read and debug.Subsequent upgrade: The publish call shown above used
PubSubClientat QoS 0 withretained = true. The system was later upgraded to QoS 1 withretained = false. SincePubSubClientdoes not expose QoS on publish, it was replaced with thearduino-mqttlibrary (Joël Gähwiler / 256dpi) — see Section 5.1. The new publish line is:
// Signature: publish(topic, payload, retained, qos)
bool sucesso = client.publish("sensores/jardim", payload, false, 1);
The connection helper
ligarMQTT()was also updated to pass credentials (Section 2.3 of the Raspberry Pi chapter):client.connect("ESP32_Estufa", mqtt_user, mqtt_pass). Theclient.setServer(...)call was replaced withclient.begin(mqtt_server, 1883, espClient)because the new library takes the underlying transport inbegin()instead of in the constructor. The retained flag was disabled because retaining sensor telemetry causes the broker to replay the last reading to any new subscriber as if it were current, which masks downtime.Subsequent upgrade: Moving to QoS 1 introduced the possibility of duplicate writes. When a
PUBACKis lost on its way back to the ESP32, the broker has already accepted the message but the publisher does not know; on the next session the same payload may be redelivered, and without deduplication the Pi-side script would write two identical documents to Firestore. To let the subscriber detect this, the firmware now stamps every reading with a deterministic ID. A 32-bit counter is held in RTC slow memory (so it survives deep sleep but resets on power loss):
RTC_DATA_ATTR uint32_t contadorLeituras = 0;
declared next to the network objects. On every wake the counter is incremented before the JSON document is built, and the value is concatenated with the sensor ID into a unique string written to a new
idfield:
contadorLeituras++;
doc["id"] = "esp32_1_" + String(contadorLeituras);
The Pi-side dedup logic that consumes this field is described in the Raspberry Pi chapter, Section 5.6.
5.9 Flush and Shutdown
for (int i = 0; i < 10; i++) {
client.loop();
delay(100);
}
entrarDeepSleep();
After publishing, the MQTT buffer is flushed with 10 iterations of client.loop() (totaling ~1 second). The client.loop() function processes the MQTT client's internal network queue, handling outgoing transmissions and incoming acknowledgments. This gives the client enough time to physically transmit the buffered message over the network before disconnecting — without it, the ESP32 could enter deep sleep before the message is actually sent.
Once the flush completes, entrarDeepSleep() handles the complete shutdown sequence. The shutdown logic is not duplicated here — the same helper is called from every exit path (sensor failure, Wi-Fi timeout, MQTT timeout, and successful transmission), ensuring consistent behaviour.
Subsequent upgrade: The fixed 10-iteration flush above is correct for QoS 0, where a successful
publish()only requires the bytes to be handed to the TCP stack. At QoS 1 the publisher must additionally wait for the broker'sPUBACKbefore considering the message delivered. The flush was therefore replaced with a 3-second polling loop that callsclient.loop()repeatedly to process the acknowledgement:
unsigned long inicioFlush = millis();
while (millis() - inicioFlush < MQTT_PUBACK_TIMEOUT) {
client.loop();
delay(50);
}
The 3-second cap (
MQTT_PUBACK_TIMEOUT, declared in Section 5.3) is a safety net: if the broker is reachable but the round-trip is degraded, the device still falls back to deep sleep instead of waiting indefinitely and draining the battery.
5.10 The Empty Loop
void loop() {
}
The loop() function is required by the Arduino framework but serves no purpose in this firmware. The entire operational cycle — power on sensors, read data, connect to Wi-Fi, publish via MQTT, and enter deep sleep — is completed within setup(). The device reboots on every wake cycle, making loop() unreachable.
6. Firmware Flowchart
The following diagram illustrates the complete wake cycle executed by the ESP32 on every boot. The entire flow runs inside setup() — the device never reaches loop(). Three early-exit paths lead directly to deep sleep: a NaN sensor reading, a Wi-Fi connection timeout, and an MQTT broker timeout. In all three cases the entrarDeepSleep() helper is called, ensuring a consistent and clean shutdown regardless of the reason for termination.
flowchart TD
A([Wake from deep sleep]) --> B[Power on sensors<br/>GPIO 4 → HIGH<br/>1 s stabilization delay]
B --> C[Initialize sensors<br/>DHT11 · I2C · BH1750<br/>2 s warm-up delay]
C --> D[Read all sensors<br/>Soil moisture → map 0–100%<br/>Air temp + humidity<br/>Light level in lux]
D --> NAN{Any reading<br/>is NaN?}
NAN -->|Yes — sensor fault| SLEEP([Deep sleep — 5 min])
NAN -->|No| E{Wi-Fi connected<br/>within 15 s?}
E -->|Timeout| SLEEP
E -->|Connected| F{MQTT broker<br/>reachable within 10 s?}
F -->|Timeout| SLEEP
F -->|Connected| G[Build JSON payload<br/>id · sensor · humidade_solo · temp_ar<br/>humidade_ar · luminosidade]
G --> H[Publish to sensores/jardim<br/>QoS 1, retained = false]
H --> I[Wait for PUBACK<br/>up to 3 s]
I --> J[Shutdown sequence<br/>MQTT disconnect<br/>GPIO 4 → LOW<br/>Wi-Fi disconnect]
J --> SLEEP
SLEEP -. Timer wakeup · full reboot .-> A
style A fill:#f1efe8,stroke:#5f5e5a,color:#2c2c2a
style SLEEP fill:#f1efe8,stroke:#5f5e5a,color:#2c2c2a
style B fill:#faeeda,stroke:#854f0b,color:#412402
style C fill:#e1f5ee,stroke:#0f6e56,color:#04342c
style D fill:#e1f5ee,stroke:#0f6e56,color:#04342c
style NAN fill:#fff3cd,stroke:#856404,color:#3d2c00
style E fill:#e6f1fb,stroke:#185fa5,color:#042c53
style F fill:#e6f1fb,stroke:#185fa5,color:#042c53
style G fill:#eeedfe,stroke:#534ab7,color:#26215c
style H fill:#eeedfe,stroke:#534ab7,color:#26215c
style I fill:#eeedfe,stroke:#534ab7,color:#26215c
style J fill:#faece7,stroke:#993c1d,color:#4a1b0c
The total active time per cycle is approximately 8 seconds in the normal path (sensor read + Wi-Fi association + MQTT publish + buffer flush). The Wi-Fi and MQTT timeouts of 15 s and 10 s respectively act as hard upper bounds that protect battery life when the network is unavailable. The remaining ~292 seconds are spent in deep sleep consuming approximately 10 µA.
7. Battery Life Estimation
Since the sensor node is designed for outdoor deployment, battery autonomy is a critical design consideration. The following theoretical calculation estimates how long the node can operate on a single charge using a standard 18650 Li-ion battery with 3000 mAh capacity (the battery holder included on the TTGO T-Higrow board accepts this format).
7.1 Current Consumption per Phase
| Phase | Duration | Current Draw | Source |
|---|---|---|---|
| Deep sleep | ~292 s (4 min 52 s) | ~10 µA (0.01 mA) | ESP32 datasheet (RTC timer only) |
| Active: sensor init + read | ~3 s | ~30 mA | DHT11 + BH1750 + ADC |
| Active: Wi-Fi TX + MQTT | ~5 s | ~120 mA | ESP32 Wi-Fi transmit (peak ~240 mA, average ~120 mA) |
| Total active phase | ~8 s | ~90 mA (weighted average) | — |
7.2 Average Current per Cycle
One complete cycle lasts 300 seconds (5 minutes). The average current is:
Iavg = (Iactive × tactive + Isleep × tsleep) ÷ ttotal
Iavg = (90 mA × 8 s + 0.01 mA × 292 s) ÷ 300 s = (720 + 2.92) ÷ 300 ≈ 2.41 mA
7.3 Estimated Battery Life
Battery life = Battery capacity ÷ Iavg = 3000 mAh ÷ 2.41 mA ≈ 1245 hours ≈ 52 days
With a 3000 mAh battery, the sensor node is estimated to operate for approximately 52 days (nearly 2 months) on a single charge. In practice, this value will be lower due to battery self-discharge (~3% per month for Li-ion), voltage regulator inefficiency, and the fact that Wi-Fi connection time can vary significantly depending on router response time and signal strength.
To extend battery life further, the sleep interval could be increased from 5 minutes to 10 or 15 minutes during periods where rapid changes in soil moisture are unlikely (e.g., during winter or on overcast days). Doubling the sleep interval to 10 minutes would roughly halve the average current and extend the estimated battery life to approximately 100 days.
Note: These calculations are theoretical estimates based on datasheet values. Real-world battery life will be lower due to battery self-discharge (~3% per month for Li-ion), voltage regulator inefficiency, Wi-Fi connection time variability, and ambient temperature effects on battery capacity. The actual autonomy should be validated during the deployment phase by running the sensor node on a fully charged battery and recording the date when it stops reporting. This empirical measurement is planned as part of the system's validation tests.
8. Expected Delivery Reliability
The choice of QoS 0 (fire and forget) for MQTT communication means that no delivery guarantee exists at the protocol level. However, the theoretical delivery rate on a local Wi-Fi network can be estimated based on the known failure modes of the system.
Subsequent upgrade: The analysis in this section assumes the original QoS 0 deployment and is preserved as a record of the prototype's expected behaviour. Under the current QoS 1 setup (Section 2.2 of the Raspberry Pi chapter), the protocol provides a delivery acknowledgement (
PUBACK) for every reading, so "no delivery guarantee at the protocol level" no longer applies, and Cause 3 below (insufficient flush window) is replaced by "noPUBACKreceived withinMQTT_PUBACK_TIMEOUT(3 s)" — see Section 5.9 of this chapter. A re-analysis of the expected delivery rate under QoS 1 was not conducted; it should be added as part of the validation plan in Section 8.2.
8.1 Potential Causes of Message Loss
There are three scenarios where a published message could fail to reach Firestore:
- Wi-Fi connection failure — if the router is temporarily unresponsive (e.g., during a firmware update or heavy traffic), the
ligarWiFi()helper aborts afterWIFI_TIMEOUT_MS(15 seconds) and the device enters deep sleep. The reading for that cycle is lost, but the device is protected from draining its battery on an unreachable network. - MQTT broker unavailable — if Mosquitto is restarting (e.g., after a Raspberry Pi system update) or the Raspberry Pi has lost power, the
ligarMQTT()helper aborts afterMQTT_TIMEOUT_MS(10 seconds). Same outcome: one lost reading, battery preserved. - Buffer flush insufficient — the 1-second flush window (
client.loop()× 10) may occasionally be too short if the network is congested, causing the message to remain in the outgoing buffer when the ESP32 enters deep sleep.
In cases 1 and 2, the lost reading is not recoverable on the ESP32 side — the device has no local storage to buffer unsent messages. However, on the Raspberry Pi side, the guarda_dados.py script includes a local file buffer (see Raspberry Pi chapter, Section 5.4) that retries failed Firestore writes, ensuring that readings successfully received by the broker are never permanently lost due to cloud outages.
8.2 Expected Delivery Rate
On a stable local network with the Raspberry Pi powered continuously, message loss is expected to be rare — well below 1–2% of total messages. At 288 messages per day (one every 5 minutes), this would translate to fewer than 3–5 lost messages per day in the worst case.
Since irrigation decisions are based on trends over hours rather than individual data points, occasional message loss does not affect the system's ability to make correct decisions. The next reading arrives 5 minutes later with virtually identical values. This justifies the use of QoS 0: the overhead of QoS 1 (additional PUBACK packets, longer active time, higher battery consumption) would provide a marginal improvement in delivery rate at a disproportionate cost to battery life.
Validation plan: As part of the system's deployment phase, a delivery reliability test should be conducted by comparing the expected number of messages (based on uptime and sleep interval) against the actual number of documents in Firestore over a period of at least 7 days. This will provide empirical data to confirm or refine the theoretical estimates above.
Raspberry Pi — Central Hub
1. Role in the System Architecture
The hardware used for the central hub is a Raspberry Pi 5 with 8 GB of RAM, a quad-core ARM Cortex-A76 processor running at 2.4 GHz, and a 32 GB microSD card for storage [9]. This board is significantly overpowered for the current workload — running an MQTT broker and a lightweight Python script consumes less than 1% of the CPU and approximately 150 MB of RAM. A Raspberry Pi Zero W (single-core, 512 MB RAM) would be sufficient for these tasks. However, the Raspberry Pi 5 was chosen for two practical reasons: it was already available, and its surplus processing power provides headroom for future expansions such as running irrigation control logic (processing sensor data and publishing pump commands via MQTT), a local AI inference model for irrigation prediction, or hosting a local web dashboard as a fallback when internet connectivity is unavailable.
The Raspberry Pi runs two key services: an MQTT broker (Mosquitto) that receives data published by the ESP32 sensor nodes, and a Python script (guarda_dados.py) that subscribes to the MQTT topic, processes incoming messages, and stores them permanently in a Cloud Firestore database hosted on Google Firebase.
This bridge pattern decouples the sensor hardware from the database layer. The ESP32 does not need to know anything about Firebase, authentication tokens, or HTTPS — it simply publishes a lightweight MQTT message on the local network. The Raspberry Pi handles all the complexity of cloud communication, authentication, and data persistence.
1.1 Why Not Send Data Directly from the ESP32 to Firebase?
The ESP32 is technically capable of making HTTPS requests directly to the Firebase REST API, bypassing the Raspberry Pi entirely. This approach was considered and rejected for the following reasons:
-
Battery consumption — an HTTPS request to Firebase involves a TLS handshake (establishing an encrypted connection), which requires multiple round trips to a remote server. On a local network, the MQTT publish completes in under 100 ms. An HTTPS request to a Firebase server in
europe-west1would take 200–500 ms, keeping the Wi-Fi radio active 2–5× longer and significantly reducing battery life. This penalty is multiplied across every battery-powered node — the system currently has one battery-powered sensor node and is designed so that additional sensor nodes can be added easily; the actuator node that controls the irrigation pump is mains-powered (see the Irrigation Control chapter). Keeping each battery node's active time to an absolute minimum is critical for long-term unattended operation. -
Centralised communication — with multiple ESP32 nodes in the field (currently one sensor node and one pump controller, and easily extended with more sensor nodes), having each device manage its own HTTPS connection to Firebase would mean maintaining separate TLS sessions, separate authentication tokens, and separate error handling logic on every node. By routing all communication through a single MQTT broker on the Raspberry Pi, the field devices are reduced to simple MQTT publishers/subscribers with minimal firmware complexity, and the Raspberry Pi handles all cloud communication in one place. If the Firebase project configuration changes (e.g., a new service account key), only the Raspberry Pi script needs updating — not the firmware on every field device.
-
Authentication complexity — Firebase REST API requires an OAuth 2.0 token or a service account key. Embedding a private key in the ESP32 firmware is a security risk (firmware can be extracted from flash memory), and implementing OAuth token refresh on a constrained microcontroller adds code complexity and failure points.
-
Resilience and edge computing — with the Raspberry Pi as an intermediary, the system can continue collecting data even if the internet connection drops. The MQTT broker stores retained messages locally, and the Python script buffers readings to disk during outages and retransmits them when connectivity is restored (see Section 5.4). The Raspberry Pi can also perform edge computing — making local irrigation decisions (e.g., activating the pump via MQTT) based on sensor data without depending on cloud availability. This is critical for agricultural deployments where internet connectivity may be intermittent or unreliable.
-
Multi-subscriber flexibility — with MQTT, any number of services can subscribe to the sensor data independently (database writer, local pump controller, monitoring dashboard, AI module). With direct ESP32-to-Firebase, the data goes to one destination only, and adding a new consumer requires modifying the firmware on every node.
2. Communication Protocol — MQTT
This system uses MQTT (Message Queuing Telemetry Transport), a lightweight publish/subscribe messaging protocol widely adopted in IoT applications [7]. MQTT was selected over HTTP for three reasons: its minimal packet overhead reduces active Wi-Fi time on battery-powered devices; its pub/sub architecture decouples the sensor nodes from downstream consumers; and its retained-message feature ensures late-joining subscribers receive the most recent reading immediately.
The broker used in this system is Mosquitto, an open-source MQTT implementation running on the Raspberry Pi [8]. The following diagram illustrates the pub/sub topology:
flowchart LR
subgraph Publisher
A[ESP32<br/>T-Higrow]
end
subgraph Broker["Mosquitto Broker (Raspberry Pi)"]
B[Topic:<br/>sensores/jardim]
end
subgraph Subscribers
C[guarda_dados.py]
D[Web Dashboard]
E[Future: AI Module]
end
A -->|publish| B
B -->|forward| C
B -->|forward| D
B -->|forward| E
style A fill:#e1f5ee,stroke:#0f6e56,color:#04342c
style B fill:#faeeda,stroke:#854f0b,color:#412402
style C fill:#e6f1fb,stroke:#185fa5,color:#042c53
style D fill:#e6f1fb,stroke:#185fa5,color:#042c53
style E fill:#e6f1fb,stroke:#185fa5,color:#042c53
Adding a new consumer (e.g., an AI prediction module) requires zero changes to the ESP32 firmware — the new service simply subscribes to the existing topic.
The system publishes to the topic sensores/jardim. The topic hierarchy is designed for scalability: additional zones or sensor types can be addressed via sub-topics (e.g., sensores/estufa/solo), and subscribers can use MQTT wildcards (sensores/#) to receive data from all zones simultaneously.
2.1 Quality of Service (QoS) Levels
MQTT defines three QoS levels with increasing delivery guarantees. The following diagrams illustrate the packet exchange for each level:
QoS 0 — At most once
sequenceDiagram
participant P as ESP32 (Publisher)
participant B as MQTT Broker
participant S as guarda_dados.py (Subscriber)
P->>B: PUBLISH (QoS 0)
B->>S: PUBLISH (QoS 0)
Note over P,S: No acknowledgment. Lost messages<br/>are not retransmitted.
QoS 1 — At least once
sequenceDiagram
participant P as ESP32 (Publisher)
participant B as MQTT Broker
participant S as guarda_dados.py (Subscriber)
P->>B: PUBLISH (QoS 1)
B->>S: PUBLISH (QoS 1)
S-->>B: PUBACK
B-->>P: PUBACK
Note over P,S: Guaranteed delivery. Duplicates possible<br/>if PUBACK is lost.
QoS 2 — Exactly once
sequenceDiagram
participant P as ESP32 (Publisher)
participant B as MQTT Broker
participant S as guarda_dados.py (Subscriber)
P->>B: PUBLISH (QoS 2)
B-->>P: PUBREC
P->>B: PUBREL
B->>S: PUBLISH (QoS 2)
S-->>B: PUBREC
B->>S: PUBREL
S-->>B: PUBCOMP
B-->>P: PUBCOMP
Note over P,S: Four-step handshake. No loss, no duplicates.<br/>Highest overhead.
2.2 QoS Level Used in This System
This system uses QoS 0. This choice is justified by four factors:
- Battery life — each additional acknowledgment packet (PUBACK in QoS 1, or the four-step handshake in QoS 2) extends the ESP32's active Wi-Fi time, directly reducing battery autonomy.
- Data redundancy — readings arrive every 5 minutes. A single lost reading has negligible impact on trend-based irrigation decisions, as the next reading arrives shortly with near-identical values.
- Local network — the ESP32 and broker communicate over a private LAN, where packet loss rates are negligible compared to public internet links.
- Retained messages — messages are published with the retained flag enabled, ensuring the broker always stores the latest reading for late-joining subscribers.
Subsequent upgrade: The four-factor argument above held during the prototype phase but was re-evaluated after early operation, and the system was upgraded to QoS 1 on both the publish side (ESP32 → broker) and the subscribe side (broker → Pi). Two observations motivated the change: (i) silent loss on a battery-powered node with intermittent Wi-Fi has a longer tail than expected — a half-broken socket still returns success under QoS 0, with no feedback to the publisher — and (ii) the extra PUBACK packet costs negligible airtime relative to a 5-minute cycle, so the original "QoS 0 saves battery" argument did not survive measurement. The implementation required replacing the MQTT library on the ESP32 (
PubSubClientdoes not expose QoS on publish — see Section 5.8 of the firmware chapter) and replacing the post-publish flush with a PUBACK-aware wait (Section 5.9 of the firmware chapter). The retained flag was also disabled in the same change: retaining sensor telemetry causes the broker to replay the last reading to any new subscriber as if it were current, which masks downtime; withretained = falsethe absence of new readings is itself a signal, and recency is reconstructed from thedata_horafield added on receipt byguarda_dados.py.
2.3 Security and Hardening
The current Mosquitto configuration uses allow_anonymous true, meaning any device on the local network can connect to the broker and publish or subscribe to any topic without authentication. For a prototype deployed on a private home network, this is acceptable — the network itself acts as the security perimeter. However, a production deployment would require several hardening measures:
Authentication — Mosquitto supports username/password authentication. A password file is created with mosquitto_passwd and referenced in the configuration:
password_file /etc/mosquitto/passwd
allow_anonymous false
Each ESP32 node would be assigned unique credentials, stored in the firmware. This prevents unauthorized devices from injecting fake sensor data into the system (e.g., an attacker publishing a soil moisture of 0% to trigger unnecessary irrigation).
TLS/SSL encryption — by default, MQTT traffic on port 1883 is transmitted in plaintext. Anyone on the same network can capture and read the sensor data using a packet sniffer. Mosquitto supports TLS encryption on port 8883 using a certificate issued by a Certificate Authority (CA) or a self-signed certificate:
listener 8883
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
cafile /etc/mosquitto/certs/ca.crt
The ESP32's WiFiClientSecure class supports TLS connections, so the firmware change would be replacing WiFiClient with WiFiClientSecure and loading the CA certificate.
Access Control Lists (ACLs) — Mosquitto can restrict which topics each client is allowed to publish or subscribe to. For example, ESP32_Estufa could be limited to publishing on sensores/jardim only, while guarda_dados.py could be limited to subscribing. This follows the principle of least privilege and limits the damage if any single client's credentials are compromised.
Network isolation — in a larger deployment (e.g., a commercial greenhouse with dozens of sensors), the IoT devices should be placed on a separate VLAN (Virtual LAN) with firewall rules that only allow MQTT traffic to the Raspberry Pi. This prevents a compromised sensor node from being used as an entry point to attack other devices on the main network.
These measures were not implemented in the current prototype to reduce development complexity, but they represent essential steps for any real-world deployment of this system.
Subsequent upgrade: Of the measures listed in this section, authentication has since been implemented. The configuration was changed from
allow_anonymous trueto a credentialed listener:
listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd
The password file is populated with
mosquitto_passwd:
sudo mosquitto_passwd -c /etc/mosquitto/passwd estufa
sudo systemctl restart mosquitto
The ESP32 now supplies these credentials inside
client.connect(clientId, user, pass)(Section 5.4 and 5.8 of the firmware chapter), andguarda_dados.pycallscliente_mqtt.username_pw_set(MQTT_USER, MQTT_PASS)before connecting (Section 5.7 of this chapter). TLS on port 8883 and per-topic Access Control Lists (both discussed above) remain future work.
3. Raspberry Pi Setup — Step by Step
3.1 Operating System
The Raspberry Pi runs Raspberry Pi OS Lite (Debian-based, 64-bit), a minimal Linux distribution without a graphical desktop. The Lite variant was chosen because the Raspberry Pi operates headless (no monitor or keyboard) — all interaction happens via SSH from another computer on the network.
- Download the Raspberry Pi Imager from raspberrypi.com/software.
- Insert a microSD card (16 GB or larger) into the computer.
- In the Imager, select Raspberry Pi OS Lite (64-bit).
- Click the gear icon to pre-configure:
- Hostname:
raspberrypi - Enable SSH: Yes, with password authentication
- Username:
pi - Password: (set a secure password)
- Wi-Fi SSID:
<your-wifi-ssid> - Wi-Fi Password:
<your-wifi-password> - Locale: Europe/Lisbon
- Hostname:
- Write the image to the microSD card.
- Insert the card into the Raspberry Pi and power it on.
- From another computer on the same network, connect via SSH:
ssh pi@raspberrypi
3.2 Assign a Static IP Address
The ESP32 firmware hard-codes the broker IP address. To ensure the Raspberry Pi always receives the same address, a static IP is configured by editing the DHCP client configuration:
sudo nano /etc/dhcpcd.conf
Add the following lines at the end of the file:
interface wlan0
static ip_address=<broker-ip>/24
static routers=<gateway-ip>
static domain_name_servers=<gateway-ip> 8.8.8.8
Note: Replace
<broker-ip>with the static IP address you assign to the Raspberry Pi (e.g.,192.168.1.100), and<gateway-ip>with your router's IP (e.g.,192.168.1.1). The same IP must be used in the ESP32 firmware'smqtt_serverconstant.
Save and reboot:
sudo reboot
After rebooting, verify the IP address:
hostname -I
# Expected output: the static IP you configured
3.3 Install Mosquitto MQTT Broker
Mosquitto is installed from the default Raspberry Pi OS repositories:
sudo apt update
sudo apt install -y mosquitto mosquitto-clients
This installs the broker (mosquitto) and the command-line client tools (mosquitto-clients, useful for testing).
By default, Mosquitto 2.x only allows connections from localhost. Since the ESP32 needs to connect from the local network, the configuration must be updated:
sudo nano /etc/mosquitto/conf.d/local.conf
Add the following lines:
listener 1883
allow_anonymous true
listener 1883— tells Mosquitto to accept connections on port 1883 from all network interfaces (not just localhost).allow_anonymous true— allows clients to connect without username/password authentication. This is acceptable for a local network prototype; a production deployment should use TLS and authentication.
Restart Mosquitto and enable it to start on boot:
sudo systemctl restart mosquitto
sudo systemctl enable mosquitto
To verify the broker is running and listening on port 1883:
sudo systemctl status mosquitto
# Should show: active (running)
mosquitto_sub -h localhost -t "sensores/jardim" -v
# This subscribes to the topic. Leave this running and power on the ESP32.
# Within 5 minutes, you should see the JSON payload appear.
3.4 Install Python Dependencies
The guarda_dados.py script requires two external Python packages:
pip install paho-mqtt firebase-admin --break-system-packages
paho-mqtt— the Python MQTT client library used to subscribe to the broker.firebase-admin— the official Google Firebase Admin SDK used to write data to Firestore.
The --break-system-packages flag is required on Raspberry Pi OS (Bookworm and later) because the system Python is managed by the OS package manager and rejects pip install by default.
3.5 Create a Firebase Project and Obtain the Service Account Key
The following steps are performed in the Firebase Console (console.firebase.google.com):
- Click Add project and name it (e.g.,
smart-irrigation). - Disable Google Analytics (not needed for this prototype).
- Once the project is created, navigate to Build → Firestore Database.
- Click Create database. Select Start in production mode and choose the server location closest to Lisbon (e.g.,
europe-west1). - Navigate to Project Settings → Service accounts.
- Click Generate new private key. A JSON file is downloaded — this file contains the credentials the Raspberry Pi needs to authenticate with Firebase.
- Transfer the JSON file to the Raspberry Pi:
scp <project-id>-firebase-adminsdk-<key-id>.json pi@raspberrypi:~/
Security note: This JSON file contains a private key that grants full read/write access to the Firestore database. It should never be committed to a public Git repository or shared publicly.
3.6 Configure Firestore Security Rules
Firestore security rules control who can read and write data through the client SDKs (web, mobile). In the Firebase Console, navigate to Firestore Database → Rules. The rules for this system are:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false;
}
}
}
These rules deny all reads and writes from client-side applications. This may seem counterintuitive — how does the Raspberry Pi write data, and how do the Cloud Functions read it?
The answer is that the Firebase Admin SDK (used by both guarda_dados.py on the Raspberry Pi and the Cloud Functions on the server) bypasses security rules entirely. The Admin SDK authenticates with a service account key, which grants it unrestricted access to the database regardless of what the rules say. Security rules only apply to requests made through the client SDKs (e.g., the Firebase JavaScript SDK in a web browser).
This means:
- The Raspberry Pi writes to Firestore using the Admin SDK with the service account key → rules do not apply, writes succeed.
- The Cloud Functions read from Firestore using the Admin SDK (initialized with
admin.initializeApp()) → rules do not apply, reads succeed. - The web dashboard does not read from Firestore directly. Instead, it calls the HTTP API (
/api), which is a Cloud Function that reads from Firestore server-side and returns the data as JSON. The browser never touches Firestore — it only talks to the API. - Any unauthorized client attempting to read or write Firestore directly through the client SDK is blocked by the
if falserule.
This is the most restrictive possible configuration and is the recommended approach when all data access is mediated by server-side code.
3.7 Firestore Indexes
Firestore automatically creates single-field indexes for every field in every document. However, queries that filter or sort by multiple fields require composite indexes, which must be created manually. The system uses two composite indexes on the estufa collection, visible in the Firebase Console under Firestore Database → Indexes:
| Collection | Fields indexed | Order | Status |
|---|---|---|---|
estufa |
sensor (ascending), data_hora (descending), __name__ (descending) |
Composite | Active |
estufa |
sensor (ascending), data_hora (ascending), __name__ (ascending) |
Composite | Active |
These indexes support two common query patterns:
- Most recent readings first —
sensor ascending + data_hora descendingallows the query "get the last N readings from sensor X, sorted from newest to oldest" to execute efficiently. This is used by the cache trigger function when building the cache document. - Chronological order —
sensor ascending + data_hora ascendingallows the query "get all readings from sensor X in chronological order" to execute efficiently. This is used by the daily cleanup function when finding documents older than 5 days.
Without these composite indexes, Firestore would reject the queries with an error message and provide a link to create the missing index. The indexes were created by clicking these auto-generated links during development.
4. Database Choice — Cloud Firestore
The system uses Cloud Firestore, a NoSQL document-oriented database provided by Google Firebase [15]. Firestore was chosen for the following reasons:
- Real-time synchronization — Firestore supports real-time listeners, meaning the web dashboard can receive new sensor readings the instant they are written to the database, without polling.
- Serverless scaling — there is no need to manage a database server. Firestore runs entirely in the cloud and scales automatically with the volume of data.
- Document model — each sensor reading is stored as an independent document inside a collection called
estufa. This maps naturally to the system's data flow: one MQTT message equals one Firestore document. - Free tier — Firebase's Spark plan provides 1 GiB of storage and 50,000 reads per day at no cost, which is more than sufficient for a prototype system that writes one document every 5 minutes [10].
Authentication with Firestore is handled via a service account key — a JSON file generated in the Firebase Console that grants the Raspberry Pi write access to the database without requiring user login credentials.
5. Script Walkthrough — guarda_dados.py
The script runs as a long-lived process on the Raspberry Pi. It connects to the local MQTT broker, listens for messages on the sensores/jardim topic, and writes each message to Firestore with a server-generated UTC timestamp. If Firestore is temporarily unreachable, the reading is saved to a local buffer file and retransmitted on the next successful write cycle.
5.1 Library Imports
import paho.mqtt.client as mqtt
import json
import firebase_admin
from firebase_admin import credentials
from firebase_admin import firestore
import datetime
import os
import time
| Library | Purpose |
|---|---|
paho.mqtt.client |
MQTT client library for Python. Handles connection to the broker, topic subscription, and message reception [19]. |
json |
Parses the incoming MQTT payload (a JSON string) into a Python dictionary. Also used to serialise/deserialise the local buffer file. |
firebase_admin |
Official Google Firebase Admin SDK. Provides authenticated access to Firestore from a server environment. |
credentials |
Sub-module of firebase_admin used to load the service account key file for authentication. |
firestore |
Sub-module of firebase_admin that provides the Firestore database client. |
datetime |
Used to generate a UTC timestamp for each sensor reading. |
os |
Used to check for the existence of the buffer file and to delete it after a successful flush. |
time |
Used for short delays between retry attempts. |
5.2 Firebase Initialization
cred = credentials.Certificate('<project-id>-firebase-adminsdk-<key-id>.json')
firebase_admin.initialize_app(cred)
db = firestore.client()
The credentials.Certificate() function loads the service account key from a JSON file stored on the Raspberry Pi. This file contains the project ID, private key, and client email needed to authenticate with Firebase. The initialize_app() call registers the credential globally, and firestore.client() returns a reference to the Firestore database that will be used throughout the script's lifetime.
The entire initialization block is wrapped in a try/except to catch authentication errors (expired key, missing file, incorrect permissions) and terminate the script early with a clear error message rather than failing silently later during a write operation.
5.3 Configuration
MQTT_BROKER = "localhost"
MQTT_TOPIC = "sensores/jardim"
BUFFER_FILE = "/home/pi/buffer_pendente.json"
MAX_BUFFER_SIZE = 500
MAX_RETRIES = 3
The broker address is localhost because the Mosquitto MQTT broker runs on the same Raspberry Pi as this script. The topic sensores/jardim matches exactly the topic the ESP32 publishes to — if these do not match, no messages will be received.
Three new constants control the buffering behaviour: BUFFER_FILE is the path to a local JSON file where readings are stored when Firestore is unavailable; MAX_BUFFER_SIZE caps the buffer at 500 entries to prevent the file from growing indefinitely on extended outages (at one reading every 5 minutes, 500 entries covers approximately 42 hours); MAX_RETRIES sets the number of write attempts per reading before falling back to the local buffer.
Naming clarification: The MQTT topic (
sensores/jardim) and the Firestore collection name (estufa) do not need to match — they are independent identifiers used by different parts of the system. The topic is an MQTT address defined in the ESP32 firmware; the collection name is a Firestore namespace chosen when the database was created. Theestufacollection name was set during the initial greenhouse-oriented design phase of the project. The garden sensor was added subsequently and publishes to a different topic (sensores/jardim), but the data from all nodes is stored in the sameestufacollection, distinguished by thesensorfield (e.g.,sensor: 1for the garden node). References tosensores/estufaelsewhere in this document refer to a possible future topic for a dedicated greenhouse sensor node.Subsequent upgrade: Two extra constants were added to this configuration block when broker authentication was enabled (Section 2.3):
MQTT_USER = "<broker-user>"
MQTT_PASS = "<broker-password>"
These are passed to the MQTT client via
username_pw_set()beforeconnect()is called (Section 5.7).
5.4 Local Buffer — Write Resilience
A direct db.collection('estufa').add(dados) call can fail for several reasons: a temporary network outage, a Firebase quota limit being reached, or a server-side maintenance window. Without protection, a failed write would mean a permanently lost reading. To prevent this, the script implements a local file buffer with retry logic.
def escrever_firestore(dados):
for tentativa in range(1, MAX_RETRIES + 1):
try:
db.collection('estufa').add(dados)
return True
except Exception as e:
if tentativa < MAX_RETRIES:
time.sleep(2)
# All retries failed — save to local buffer
adicionar_ao_buffer(dados)
return False
When a new reading arrives, the script attempts to write it to Firestore up to 3 times, with a 2-second pause between retries. If all 3 attempts fail, the reading is appended to buffer_pendente.json on disk. This ensures that no reading is ever permanently lost due to a temporary Firestore outage.
The buffer flush happens automatically after every successful write:
def on_message(client, userdata, msg):
payload = msg.payload.decode('utf-8')
dados = json.loads(payload)
dados['data_hora'] = datetime.datetime.now(datetime.timezone.utc)
escrever_firestore(dados)
flush_buffer() # Send any buffered readings from earlier failures
The flush_buffer() function iterates through all entries in the buffer file and attempts to write each one to Firestore. Entries that succeed are removed from the buffer; entries that fail remain for the next attempt. When all entries are successfully sent, the buffer file is deleted. This function is also called on script startup — so if the script crashed while Firebase was down and was restarted by systemd, the buffered readings are retransmitted automatically.
The buffer file uses JSON format, which means datetime objects must be converted to ISO 8601 strings before serialisation and converted back when deserialised. This conversion is handled transparently by the buffer functions.
5.5 Connection Callback — on_connect
def on_connect(client, userdata, flags, rc):
client.subscribe(MQTT_TOPIC)
This function is called automatically by the MQTT client library whenever a connection (or reconnection) to the broker is established. The rc parameter contains the connection result code (0 = success). Inside this callback, the client subscribes to the sensores/jardim topic. Placing the subscription inside on_connect rather than in the main body of the script ensures that if the connection drops and is later re-established, the subscription is automatically renewed without manual intervention.
Subsequent upgrade: The
subscribe()call was later changed to operate at QoS 1 to match the upgraded publish side:client.subscribe(MQTT_TOPIC, qos=1). Rationale in Section 2.2.
5.6 Message Processing Pipeline
Each incoming MQTT message passes through the following pipeline:
- Decode the payload — the raw MQTT payload is a byte string.
decode('utf-8')converts it to a regular Python string. - Parse JSON —
json.loads()converts the JSON string into a Python dictionary containing the sensor readings. - Add UTC timestamp —
datetime.datetime.now(datetime.timezone.utc)generates the current date and time in UTC (Coordinated Universal Time). Using explicit UTC rather thandatetime.now()(which returns local time) is critical because the Raspberry Pi's local timezone may vary depending on configuration or daylight saving time changes. If local time were used, timestamps in the database would shift by one hour during summer time transitions, creating inconsistencies in the historical data. - Write to Firestore —
escrever_firestore(dados)attempts to write with retries. If all retries fail, the reading is buffered locally. - Flush buffer —
flush_buffer()attempts to send any readings that were buffered from earlier failures.
Error handling is split into two layers: json.JSONDecodeError catches malformed payloads (corrupted MQTT messages), and the retry logic inside escrever_firestore() handles Firestore failures. In both cases, the script continues running — a single error should never crash the data collection process.
Subsequent upgrade: With QoS 1 in place (Section 2.2), the MQTT broker can deliver the same payload more than once — most often when a
PUBACKfrom the broker is lost on its way back to the publisher, leading the ESP32 to retransmit on the next session even though Firestore already has the reading. To prevent duplicate documents, an in-memory deduplication step was inserted between Parse JSON and Add UTC timestamp in the pipeline above. The ESP32 stamps every reading with a deterministicid(Section 5.8 of the firmware chapter, "Subsequent upgrade" on dedup); the subscriber keeps the last 500 IDs in an LRU-bounded set and discards any incoming message whoseidis already present:
from collections import deque
IDS_RECENTES_MAX = 500 # ~40 h of readings at 5 min cadence
ids_vistos = deque(maxlen=IDS_RECENTES_MAX)
ids_set = set()
# inside on_message, after json.loads():
msg_id = dados.get("id")
if msg_id and msg_id in ids_set:
print(f"[DEDUP] {msg_id} já processada, a ignorar redelivery.")
return
Two parallel structures are used so the membership check is O(1) (the
set) while the eviction order is FIFO (thedeque). An ID is added to both structures only after the Firestore write succeeds — readings that fail and are moved to the local buffer must remain eligible for a future retransmission to complete the persistence:
if escrever_firestore(dados) and msg_id:
if len(ids_vistos) == ids_vistos.maxlen:
ids_set.discard(ids_vistos[0])
ids_vistos.append(msg_id)
ids_set.add(msg_id)
The 500-entry window covers roughly 40 hours of readings at one every five minutes — comfortably larger than any plausible redelivery window for this system. The cache is in-memory only, so a script restart loses it; the trade-off is acceptable because (a) the ESP32-side counter only resets on a power cycle, and (b) duplicates within a single broker session are the dominant failure mode the cache is designed to catch.
5.7 MQTT Client Startup
flush_buffer() # Send any readings buffered from a previous session
cliente_mqtt = mqtt.Client()
cliente_mqtt.on_connect = on_connect
cliente_mqtt.on_message = on_message
cliente_mqtt.connect(MQTT_BROKER, 1883, 60)
cliente_mqtt.loop_forever()
Before entering the main loop, flush_buffer() is called to send any readings that were buffered during a previous session (e.g., if the script crashed while Firebase was down and was restarted by systemd).
A new MQTT client instance is created and the two callback functions are registered. The connect() call initiates the connection to the local broker on port 1883 (the standard unencrypted MQTT port). The third parameter (60) is the keep-alive interval in seconds — the client will send a periodic ping to the broker every 60 seconds to maintain the connection.
loop_forever() is a blocking call that runs the MQTT client's network loop indefinitely. It handles incoming messages, reconnection attempts, and keep-alive pings in a single thread. The script will remain running until it is manually stopped (via Ctrl+C or a systemctl stop command) or an unrecoverable error occurs.
Subsequent upgrade: When broker authentication was enabled (Section 2.3), credentials were supplied to the client via
username_pw_set()beforeconnect():
cliente_mqtt.username_pw_set(MQTT_USER, MQTT_PASS)
cliente_mqtt.connect(MQTT_BROKER, 1883, 60)
The constants
MQTT_USERandMQTT_PASSare declared in the configuration block (Section 5.3).
Subsequent upgrade:
paho-mqtt2.0 changed the client constructor and the callback signatures, so a baremqtt.Client()now defaults to the new (v2) callback API — under which the v1-styleon_connect(client, userdata, flags, rc)andon_messagehandlers above would no longer fire as written. To stay compatible with both major versions (and to matchdecisor_rega.py, Section 2.4 of the Irrigation Control chapter), the client is created through a small compatibility shim:
try:
cliente_mqtt = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) # paho-mqtt >= 2.0
except (AttributeError, TypeError):
cliente_mqtt = mqtt.Client() # paho-mqtt 1.x
6. Firestore Document Structure
Each document in the estufa collection has the following structure:
| Field | Type | Description |
|---|---|---|
id |
String | Deterministic unique reading ID assigned by the ESP32 (esp32_<sensor>_<counter>). Used by the Pi subscriber to discard QoS 1 redeliveries before the Firestore write. |
sensor |
Integer | Identifies which physical sensor node sent the reading (e.g., 1). |
humidade_solo |
Integer | Soil moisture percentage (0–100%), derived from the calibrated capacitive sensor. |
temp_ar |
Float | Air temperature in degrees Celsius, measured by the DHT11. |
humidade_ar |
Float | Relative air humidity as a percentage, measured by the DHT11. |
luminosidade |
Float | Ambient light level in lux, measured by the BH1750. |
data_hora |
Timestamp | UTC date and time when the reading was received by the Raspberry Pi. |
Firestore automatically indexes all fields, which means queries such as "retrieve all readings where soil moisture is below 30%" or "get the last 24 hours of temperature data" can be executed efficiently without additional configuration.
Subsequent upgrade: The
idfield was added at the same time as the move to QoS 1 (Section 2.2). Under QoS 0 every published message carried exactly one delivery attempt and a "lost message" simply did not appear in the collection; under QoS 1 a lostPUBACKcauses the broker to retransmit, which without an explicit identifier would produce duplicate documents in Firestore. The full mechanism is described in Section 5.8 of the firmware chapter (ESP32 side: RTC-backed counter +idstamp) and Section 5.6 of this chapter (Pi side: in-memory LRU set, mark-on-success).
7. Data Flow — Sensor to Database
flowchart LR
A[ESP32<br/>T-Higrow] -->|MQTT publish<br/>sensores/jardim| B[Mosquitto<br/>MQTT Broker]
B -->|MQTT subscribe<br/>sensores/jardim| C[guarda_dados.py<br/>Python script]
C -->|Firebase Admin SDK<br/>HTTPS| D[(Cloud Firestore<br/>estufa collection)]
style A fill:#e1f5ee,stroke:#0f6e56,color:#04342c
style B fill:#faeeda,stroke:#854f0b,color:#412402
style C fill:#e6f1fb,stroke:#185fa5,color:#042c53
style D fill:#eeedfe,stroke:#534ab7,color:#26215c
The ESP32 publishes a JSON message to the local MQTT broker every 5 minutes. The Python script, running continuously on the same Raspberry Pi, receives the message within milliseconds, appends a UTC timestamp, and writes it to Firestore over HTTPS. The entire path from sensor reading to cloud database entry typically completes in under 2 seconds.
8. Deployment as a systemd Service — Step by Step
Running guarda_dados.py manually in a terminal session means the script stops when the SSH session ends. To ensure continuous, unattended operation, the script is deployed as a systemd service.
8.1 Create the Service File
sudo nano /etc/systemd/system/guarda_dados.service
Paste the following content:
[Unit]
Description=MQTT to Firebase bridge for smart irrigation
After=network-online.target mosquitto.service
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi
ExecStart=/usr/bin/python3 /home/pi/guarda_dados.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
Key fields explained:
After=network-online.target mosquitto.service— ensures the script only starts after the network is connected and the Mosquitto broker is running. Without this, the script would start before the broker and fail to connect.Restart=always— if the script crashes for any reason (unhandled exception, memory issue), systemd will automatically restart it after 10 seconds.User=pi— the script runs under the same user that owns the Firebase key file, avoiding permission issues.WorkingDirectory=/home/pi— ensures the script can find the Firebase service account JSON file using its relative path.
8.2 Enable and Start the Service
sudo systemctl daemon-reload
sudo systemctl enable guarda_dados
sudo systemctl start guarda_dados
daemon-reload— tells systemd to re-read the service files after creating the new one.enable— configures the service to start automatically on every boot.start— starts the service immediately.
8.3 Verify the Service is Running
sudo systemctl status guarda_dados
Expected output:
● guarda_dados.service - MQTT to Firebase bridge for smart irrigation
Loaded: loaded (/etc/systemd/system/guarda_dados.service; enabled)
Active: active (running) since ...
To view real-time logs:
journalctl -u guarda_dados -f
This streams the script's console output (sensor readings, errors) in real time, useful for debugging.
8.4 Updating the Script
When the script is modified on the development computer, the updated version is transferred to the Raspberry Pi and the service is restarted:
# From the development computer:
scp guarda_dados.py pi@raspberrypi:~/guarda_dados.py
# On the Raspberry Pi (or via SSH):
sudo systemctl restart guarda_dados
The service picks up the new code immediately. No reboot is required.
Firebase Platform — Cloud Functions, Hosting, and Web Dashboard
1. Architecture Overview
Once the sensor data is stored in Cloud Firestore (written by the Raspberry Pi's guarda_dados.py script), it needs to be delivered to end users through a web interface. Rather than having the web dashboard read directly from Firestore on every page load — which would be slow, expensive at scale, and expose the database structure to the client — the system uses a layered architecture built entirely on the Firebase platform:
- Cloud Functions — serverless backend code that runs on Google's infrastructure. Three functions handle different responsibilities: caching new data as it arrives, cleaning old records daily, and serving an HTTP API for the dashboard.
- Firebase Hosting — a global CDN (Content Delivery Network) that serves the static web dashboard files (HTML, CSS, JavaScript) and also acts as a reverse proxy for the API, caching responses to reduce function invocations and Firestore reads [16].
- App Check — a security layer that verifies every API request originates from the legitimate web application (using reCAPTCHA Enterprise), blocking automated scrapers and unauthorized clients [17].
- Custom Domain — the dashboard is accessible at
estufaiscte.pt, a custom domain registered and configured to point to Firebase Hosting.
The following diagram illustrates the complete data flow from Firestore to the user's browser:
flowchart LR
A[(Cloud Firestore<br/>estufa collection)] -->|onDocumentCreated<br/>trigger| B[Cloud Function<br/>atualizarCache]
B -->|write| C[(Firestore<br/>cache collection)]
D[User Browser<br/>estufaiscte.pt] -->|GET /api| E[Firebase Hosting<br/>CDN]
E -->|CDN miss| F[Cloud Function<br/>getEstufaData]
F -->|read| C
F -->|response| E
E -->|CDN hit or<br/>cached response| D
style A fill:#eeedfe,stroke:#534ab7,color:#26215c
style B fill:#faeeda,stroke:#854f0b,color:#412402
style C fill:#eeedfe,stroke:#534ab7,color:#26215c
style D fill:#e1f5ee,stroke:#0f6e56,color:#04342c
style E fill:#e6f1fb,stroke:#185fa5,color:#042c53
style F fill:#faeeda,stroke:#854f0b,color:#412402
2. Firebase Project Setup — Step by Step
2.1 Install Firebase CLI
The Firebase CLI (Command Line Interface) is required to deploy Cloud Functions and Hosting files. It is installed globally via npm:
npm install -g firebase-tools
After installation, authenticate with the Google account that owns the Firebase project:
firebase login
2.2 Initialize the Project
In the project root directory, run:
firebase init
Select the following features when prompted:
- Firestore — to deploy security rules
- Functions — to deploy the serverless backend
- Hosting — to deploy the web dashboard
When asked for the public directory, enter public. This is where the index.html file (the dashboard) will be placed.
2.3 Register a Web Application
Before the dashboard can communicate with Firebase services (App Check, Hosting CDN), a web application must be registered in the Firebase project:
- In the Firebase Console, navigate to Project Settings → General.
- Under Your apps, click Add app and select the Web platform (
</>). - Enter an app nickname (e.g.,
Estufa) and optionally enable Firebase Hosting for this app. - Firebase generates a configuration object containing the project's API key, auth domain, project ID, and other identifiers:
const firebaseConfig = {
apiKey: "<your-firebase-api-key>",
authDomain: "<project-id>.firebaseapp.com",
databaseURL: "https://<project-id>-default-rtdb.europe-west1.firebasedatabase.app",
projectId: "<project-id>",
storageBucket: "<project-id>.firebasestorage.app",
messagingSenderId: "<sender-id>",
appId: "1:<sender-id>:web:<app-hash>",
measurementId: "<measurement-id>"
};
- This configuration is embedded directly in the
index.htmlfile. It is not a secret — the API key only identifies the project and is safe to expose in client-side code. Access control is enforced by Firebase Security Rules and App Check, not by the API key.
The registered application appears in the Firebase Console under Project Settings → Your apps with its application ID (1:<sender-id>:web:<app-hash>).
2.4 Project File Structure
After initialization, the project has the following structure:
project-root/
├── firebase.json ← Hosting and Functions configuration
├── public/
│ └── index.html ← Web dashboard (single-page application)
└── functions/
├── package.json ← Node.js dependencies
└── index.js ← Cloud Functions (API, cache trigger, cleanup)
2.5 Enable App Check with reCAPTCHA Enterprise
App Check is a Firebase security feature that ensures only the legitimate web application can call the backend API. It works by requiring every API request to include a cryptographic token generated by reCAPTCHA Enterprise, which Google provides as part of the Firebase platform.
Step 1 — Create a reCAPTCHA Enterprise key:
- In the Google Cloud Console (console.cloud.google.com), navigate to Security → reCAPTCHA Enterprise.
- Click Create Key.
- Set the key type to Website and add the domain(s) where the dashboard is hosted:
<project-id>.web.app,<project-id>.firebaseapp.com, andestufaiscte.pt. - Disable the checkbox verification (the key should work invisibly, without showing a CAPTCHA puzzle to the user).
- Save the key. Google generates a site key — a string like
<your-recaptcha-site-key>. This key is embedded in the dashboard's JavaScript code.
Step 2 — Register the key in Firebase App Check:
- In the Firebase Console, navigate to App Check.
- Select the web application registered in step 2.3 (
Estufa). - Click reCAPTCHA Enterprise as the attestation provider.
- Paste the site key generated in step 1.
- Click Save.
Step 3 — Enforce App Check on Cloud Functions:
- Still in the Firebase Console under App Check, navigate to the APIs tab.
- Find Cloud Functions in the list and click Enforce.
- This tells Firebase to reject any Cloud Function request that does not include a valid App Check token. Requests from the dashboard (which includes the token via the
X-Firebase-AppCheckheader) will succeed; requests from unauthorized clients (scripts, bots, Postman) will be rejected with a 401 error.
Step 4 — Initialize App Check in the dashboard:
The dashboard initializes App Check on page load using the Firebase JavaScript SDK:
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaEnterpriseProvider("<your-recaptcha-site-key>"),
isTokenAutoRefreshEnabled: true
});
The isTokenAutoRefreshEnabled: true setting ensures the token is refreshed automatically before it expires (tokens are valid for approximately 1 hour). Every subsequent API call retrieves the current token and includes it in the request header.
3. Hosting Configuration — firebase.json
The firebase.json file defines how Firebase Hosting serves the static files and routes API requests to the Cloud Function:
{
"hosting": {
"public": "public",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{
"source": "/api",
"function": "getEstufaData",
"region": "europe-west1"
}
]
},
"functions": [
{
"source": "functions",
"codebase": "default",
"ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log", "*.local"]
}
]
}
Key configuration points:
public: "public"— all files in thepublic/directory are served as static content by the CDN. This includes theindex.htmldashboard.rewrites— this is the critical entry. When the browser requests/api, Firebase Hosting does not serve a static file. Instead, it forwards the request to thegetEstufaDataCloud Function running ineurope-west1. However — and this is the performance optimization — the CDN caches the function's response based on theCache-Controlheaders set by the function. Subsequent requests for the same URL within the cache window are served directly from the CDN edge node closest to the user, without invoking the function or reading from Firestore at all.functions.source: "functions"— tells Firebase CLI where to find the Cloud Functions source code.
4. Cloud Functions — index.js
The backend consists of three Cloud Functions deployed to the europe-west1 region (chosen for proximity to Lisbon). All three are written in JavaScript using the Firebase Functions v2 SDK and run on Node.js.
4.1 Constants and Validation
const CINCO_DIAS_MS = 5 * 24 * 60 * 60 * 1000;
const MAX_SENSOR_ID = 9999;
const MAX_SENSORES_ATIVOS = 200;
const MAX_DADOS_CACHE = 720;
CINCO_DIAS_MS— the system retains 5 days of data. Older records are deleted by the daily cleanup function. Five days was chosen as a balance between having enough historical data for trend analysis and keeping storage costs within the Firebase free tier.MAX_DADOS_CACHE— at one reading every 5 minutes, 5 days produce 1440 readings. The cache stores up to 720 entries per sensor (approximately 2.5 days at maximum frequency), which is sufficient for the dashboard's chart display while keeping the cached document size manageable.
The code includes validation functions that reject data outside physically plausible ranges:
const LIMITES_SCHEMA = {
temp_ar: { min: -40, max: 85 },
humidade_solo: { min: 0, max: 100 },
humidade_ar: { min: 0, max: 100 },
luminosidade: { min: 0, max: 150000 },
};
If a sensor reading falls outside these limits (for example, a soil moisture of 150% or a temperature of 200 °C), the reading is discarded as corrupt rather than stored and displayed.
This validation is particularly important because sensor failures are not always obvious. A common failure mode for the DHT11 is returning NaN (Not a Number) when the data line is noisy or the sensor is damaged. The ESP32 firmware includes a NaN check (see Sensor Node chapter, Section 5.6) that aborts transmission if any reading is invalid — so under normal circumstances, NaN values never reach the Cloud Function. However, the server-side validation acts as a second line of defence: if a future firmware version removes or bypasses the client-side check, or if a different sensor node without the validation publishes to the same topic, the Cloud Function will still reject the corrupt reading. The structured log (log("warn", "schema_invalido", ...)) records the rejection for debugging purposes but no alert is sent to the user.
Similarly, if the ESP32's ADC malfunctions and reports a soil moisture of 0% for an extended period (stuck sensor), the current system would accept these readings as valid (0% is within the 0–100 range) and display them without warning. The user would need to visually notice the anomaly on the dashboard chart. This represents a limitation of the current validation approach — it catches impossible values but not implausible patterns.
4.2 CORS — Cross-Origin Resource Sharing
const ORIGENS_PERMITIDAS = [
"https://<project-id>.web.app",
"https://<project-id>.firebaseapp.com",
"https://estufaiscte.pt",
];
Browsers enforce a security policy called CORS that prevents a web page from making requests to a different domain than the one it was loaded from. Since the dashboard is served from Firebase Hosting and calls the Cloud Function API, the function must explicitly declare which origins are allowed to make requests. All three domains are included: the two default Firebase Hosting URLs and the custom domain estufaiscte.pt. The aplicarCors() function sets the appropriate Access-Control-Allow-Origin header and handles preflight OPTIONS requests.
4.3 App Check Verification
async function verificarAppCheck(req, res) {
const token = req.headers["x-firebase-appcheck"];
if (!token) {
res.status(401).json({ erro: "App Check token em falta." });
return false;
}
await admin.appCheck().verifyToken(token);
return true;
}
Every API request must include a valid App Check token in the X-Firebase-AppCheck header. This token is generated by the Firebase App Check SDK in the browser using reCAPTCHA Enterprise, which cryptographically proves that the request comes from the legitimate web application and not from an automated script or unauthorized client. The Cloud Function verifies this token server-side before processing the request. If the token is missing or invalid, the request is rejected with a 401 status.
4.4 Function 1 — atualizarCacheAoReceberDado (Firestore Trigger)
This function is triggered automatically every time a new document is created in the estufa collection (i.e., every time the Raspberry Pi writes a new sensor reading).
exports.atualizarCacheAoReceberDado = onDocumentCreated(
{ document: "estufa/{docId}", region: REGION },
async (event) => { ... }
);
What it does — step by step:
- Extracts the new document's data — reads the sensor ID, timestamp, and all measurement values from the newly created Firestore document.
- Validates the data — checks that the sensor ID is a positive integer, the timestamp is valid, and all measurement values fall within the physically plausible ranges defined in
LIMITES_SCHEMA. If any check fails, the document is ignored. - Reads the existing cache — each sensor has a dedicated cache document at
cache/{sensorId}. The function reads the current cache, filters out entries older than 5 days, and checks for duplicate triggers (Firestore guarantees at-least-once execution, so the same document creation can trigger the function twice). - Appends the new reading — the validated reading is added to the cache array, which is then sorted chronologically and trimmed to
MAX_DADOS_CACHEentries. - Writes the updated cache — the entire array is written back to the cache document in a single operation.
- Registers the sensor — if this is the first reading from a new sensor ID, the function adds the ID to a
config/estufa_infodocument that tracks all active sensors. This is done only once per sensor (not on every reading) to avoid unnecessary writes.
Why a cache layer? Without it, the API function would need to query the estufa collection directly — sorting by timestamp, filtering by sensor ID, and limiting to the last 5 days. This query would read hundreds of Firestore documents per API call, which is slow and expensive. The cache pre-computes the result: the API simply reads one document (cache/{sensorId}) and returns it. This reduces the API's Firestore reads from hundreds to exactly one.
4.5 Function 2 — limpezaDiaria (Scheduled)
exports.limpezaDiaria = onSchedule(
{ schedule: "every 24 hours", region: REGION },
async () => { ... }
);
This function runs automatically once every 24 hours. It deletes all documents in the estufa collection where the data_hora timestamp is older than 5 days. Deletion is performed in batches of 500 (the Firestore batch write limit) to avoid timeout errors on large collections.
This cleanup prevents the Firestore storage from growing indefinitely. At one document every 5 minutes, the system produces approximately 288 documents per day per sensor. Without cleanup, a single sensor would accumulate over 100,000 documents per year — well beyond the free tier limits.
4.6 Function 3 — getEstufaData (HTTP API)
exports.getEstufaData = onRequest(
{ region: REGION },
async (req, res) => { ... }
);
This is the HTTP endpoint called by the web dashboard. It accepts GET requests with a tipo query parameter:
GET /api?tipo=sensores — returns the list of all active sensor IDs. The response includes a Cache-Control: public, max-age=0, s-maxage=300 header, meaning the CDN caches this response for 5 minutes. All users requesting the sensor list within that window receive the cached response without invoking the function.
GET /api?tipo=dados&sensorId=1 — returns the cached data array for the specified sensor. The response includes Cache-Control: public, max-age=0, s-maxage=600, meaning the CDN caches it for 10 minutes — matching the ESP32's 5-minute reporting interval (the CDN will always have at most one stale reading).
The max-age=0 directive tells the browser not to cache the response locally (so the user always gets fresh data when they reload), while s-maxage tells the CDN to cache it (so the Cloud Function is not invoked on every request from every user).
5. CDN Caching Strategy
The caching strategy is a deliberate optimization that dramatically reduces costs and latency:
| Request | Without CDN | With CDN |
|---|---|---|
| 100 users load the dashboard simultaneously | 100 Cloud Function invocations, 100 Firestore reads | 1 Cloud Function invocation, 1 Firestore read, 99 CDN cache hits |
| Sensor list refresh every 5 min | 1 function invocation per user | 1 function invocation total (shared cache) |
| Data refresh every 10 min | 1 function invocation per user per sensor | 1 function invocation total per sensor |
This means the system can serve thousands of concurrent users while staying within the Firebase free tier, because the vast majority of requests never reach the Cloud Function.
6. Web Dashboard — index.html
The web dashboard is a single-page application built with vanilla HTML, CSS, and JavaScript — no framework (React, Vue, etc.) is used. It is served as a single index.html file from Firebase Hosting. The external dependencies are Chart.js [12] (for the historical data chart), Font Awesome [18] (for icons), and Google Fonts — the Fraunces, Spectral, and JetBrains Mono typefaces used by the shared editorial visual theme (see Section 6.9).
6.1 Firebase and App Check Initialization
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.8.1/firebase-app.js";
import { initializeAppCheck, ReCaptchaEnterpriseProvider, getToken }
from "https://www.gstatic.com/firebasejs/10.8.1/firebase-app-check.js";
const app = initializeApp(firebaseConfig);
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaEnterpriseProvider("<your-recaptcha-site-key>"),
isTokenAutoRefreshEnabled: true
});
The Firebase SDK is loaded as ES modules directly from Google's CDN. App Check is initialized with a reCAPTCHA Enterprise provider — this runs invisibly in the background (no CAPTCHA puzzle for the user) and generates a cryptographic token that proves the request originates from the legitimate web page.
6.2 API Communication
async function chamarApi(params) {
const { token } = await getToken(appCheck, false);
const url = `${API_URL}?${new URLSearchParams(params)}`;
const resp = await fetch(url, {
method: "GET",
headers: { "X-Firebase-AppCheck": token },
});
return resp.json();
}
Every API call follows the same pattern: obtain a fresh App Check token, build the URL with query parameters, send a GET request with the token in the header, and parse the JSON response. The URL /api is resolved by Firebase Hosting's rewrite rule to the getEstufaData Cloud Function — but if the CDN has a cached response, the function is never invoked.
6.3 Polling with Exponential Backoff
The dashboard does not use real-time listeners (WebSockets). Instead, it polls the API at regular intervals:
const POLL_MS = 10 * 60 * 1000; // 10 minutes (matches CDN cache)
const BACKOFF_MS = [30_000, 60_000, 120_000, POLL_MS];
Under normal conditions, the dashboard fetches new data every 10 minutes — aligned with the CDN's s-maxage=600 cache duration. If a fetch fails (network error, server error), the dashboard applies exponential backoff: it waits 30 seconds before the first retry, then 60 seconds, then 120 seconds, and finally falls back to the normal 10-minute interval. This prevents a flood of retry requests during temporary outages.
6.4 Sensor Selector (Dynamic Dropdown)
On page load, the dashboard calls GET /api?tipo=sensores to retrieve the list of active sensor IDs. It dynamically generates a dropdown menu with one option per sensor. When the user selects a different sensor, the dashboard fetches that sensor's data from the API (or from a local in-memory cache if it was fetched recently).
The dropdown supports full keyboard navigation: ArrowDown/ArrowUp to move between options, Enter/Space to select, and Escape to close. This ensures accessibility for users who do not use a mouse.
6.5 KPI Cards
Four cards at the top of the page display the most recent reading for each metric:
| Card | Metric | Unit | Color |
|---|---|---|---|
| Temp. Ar | Air temperature | °C | Red (#ff7675) |
| Humidade Solo | Soil moisture | % | Teal (#00cec9) |
| Humidade Ar | Air humidity | % | Blue (#74b9ff) |
| Luminosidade | Light level | Lux | Yellow (#fdb813) |
While data is loading, the cards display a skeleton animation (a shimmering placeholder) to indicate that content is being fetched. The cards are draggable — the user can rearrange their order by dragging (desktop) or long-pressing and dragging (mobile). The order is persisted in localStorage and restored on the next visit.
6.6 Historical Chart (Chart.js)
The main chart uses Chart.js to render a time-series line chart with four datasets (one per metric). The chart has two Y-axes: the left axis (0–100) is shared by temperature, soil moisture, and air humidity; the right axis auto-scales independently for luminosity (which can reach tens of thousands of lux).
Three filter buttons allow the user to view different time ranges:
- 1 Hour — shows the last ~12 readings (useful for checking current conditions)
- 1 Day — shows the last ~288 readings (useful for identifying daily patterns)
- 5 Days — shows the full dataset (useful for observing multi-day trends)
The selected filter is saved in localStorage and restored on the next visit.
6.7 CSV Export
The Export CSV button generates a downloadable CSV file containing all data points visible in the current time range. The file includes a BOM (Byte Order Mark, \uFEFF) for correct encoding in Microsoft Excel, and uses the Europe/Lisbon timezone for all timestamps. The filename includes the sensor ID, time range, and current date (e.g., sensor_1_1d_2025-06-15.csv).
6.8 Online/Offline Detection
The dashboard monitors the sensor's status and the user's internet connection:
- Sensor status badge — if the most recent reading is older than 30 minutes, the badge changes from green "Online" to red "Offline", indicating the sensor may have lost power or connectivity.
- Browser connectivity — the dashboard listens to the browser's
onlineandofflineevents. When the connection drops, a warning toast is displayed. When it returns, the dashboard immediately fetches fresh data.
6.9 Visual Design and Navigation
The dashboard shares a single editorial visual theme with this documentation site, so the two pages read as parts of one publication. The theme is built on CSS custom properties (--paper, --green, --amber, --text-primary, etc.) and three Google Fonts: Fraunces (headings and the large KPI figures), Spectral (body text), and JetBrains Mono (labels, the sensor selector, filter buttons, and timestamps). A full-bleed green masthead at the top carries an eyebrow, the page title, and a subtitle, with the live controls (status badge and sensor selector) aligned to the top-right.
The system uses a single light theme. An earlier version had a light/dark toggle (persisted in localStorage via a data-theme="dark" attribute on <body>); it was removed when the dashboard was unified with the documentation's look, which is light-only, so that both pages keep one consistent appearance. The KPI accent colours (red, teal, blue, yellow) and the four chart series colours are unchanged.
Cross-page navigation uses round Font Awesome icon buttons in the top-right of the masthead: the dashboard links to this build-and-reference documentation page (a book icon → /documentation), and this page links back to the dashboard (a house icon → /).
7. Custom Domain — estufaiscte.pt
The dashboard is accessible at the custom domain estufaiscte.pt instead of the default Firebase URL (<project-id>.web.app). The setup process was:
7.1 Register the Domain
The domain estufaiscte.pt was purchased from a domain registrar.
7.2 Connect to Firebase Hosting
-
In the Firebase Console, navigate to Hosting → Custom domains.
-
Click Add custom domain and enter
estufaiscte.pt. -
Firebase provides two DNS TXT records for domain ownership verification. These records were added in the domain registrar's DNS management panel.
-
After verification (which can take up to 24 hours), Firebase provides two A records with IP addresses pointing to Google's global CDN infrastructure. These IP addresses are unique to each Firebase project and are displayed in the Firebase Console during the setup wizard. Both A records were added to the domain's DNS configuration in the registrar's panel.
-
Firebase automatically provisions a free SSL/TLS certificate for the domain, enabling HTTPS. No manual certificate management is required — Firebase handles renewal automatically.
7.3 Verify
After DNS propagation (typically 1–48 hours), the dashboard is accessible at:
https://estufaiscte.pt— custom domain (primary)https://<project-id>.web.app— default Firebase domain (still works)
8. Deployment — Step by Step
8.1 Install Dependencies
In the functions/ directory, install the Node.js dependencies:
cd functions
npm install
8.2 Deploy Cloud Functions
firebase deploy --only functions
This uploads the index.js file to Google Cloud, builds it, and deploys the three functions (atualizarCacheAoReceberDado, limpezaDiaria, getEstufaData) to the europe-west1 region. The first deployment takes 1–2 minutes; subsequent deployments are faster.
8.3 Deploy Hosting (Dashboard)
firebase deploy --only hosting
This uploads all files in the public/ directory to Firebase Hosting's global CDN. The index.html file becomes immediately available at both the default URL and the custom domain.
8.4 Deploy Everything at Once
firebase deploy
This deploys Functions, Hosting, and Firestore rules in a single command. This is the recommended approach when multiple components have been modified.
8.5 Verify Deployment
After deployment, open https://estufaiscte.pt in a browser. The dashboard should:
- Display the sensor dropdown populated with active sensor IDs.
- Show the four KPI cards with the most recent reading.
- Render the historical chart with data from the last 24 hours (default filter).
- Show a green "Online" badge if the sensor reported within the last 30 minutes.
Irrigation Control — Decision-Making and Pump Actuation
The chapters so far describe a monitoring system: it senses the soil–plant–atmosphere environment and delivers the data to a dashboard. A monitoring system becomes an irrigation system only when that data is turned into water. This chapter documents the two components that close that loop:
- an edge decision-maker (
decisor_rega.py) that runs on the Raspberry Pi and decides, on every reading, whether to water; and - a pump-actuator node — a second ESP32 driving a DFRobot DFR0523 peristaltic pump — that carries out the watering command.
Both are designed around one principle: the decision logic is isolated so that the present rule-based version can later be replaced by a neural network without changing anything else in the system.
1. Role in the System Architecture
The decision-maker runs on the Raspberry Pi, alongside the Mosquitto broker and guarda_dados.py. It subscribes to the same live sensor stream (sensores/jardim) and publishes commands on a new topic (rega/comando); the pump-actuator node subscribes to that topic and reports its state on rega/estado. The control loop is therefore entirely local:
sequenceDiagram
participant S as ESP32 sensor node
participant B as Mosquitto broker (Pi)
participant D as decisor_rega.py (Pi)
participant P as ESP32 pump node (DFR0523)
S->>B: publish reading (sensores/jardim)
B->>D: deliver reading
D->>D: decidir_rega(features) + cooldown check
D->>B: publish regar command (rega/comando, QoS 1)
B->>P: deliver command
P->>P: run pump for duracao seconds, then auto-stop
P->>B: publish parado state (rega/estado)
Running the decision at the edge — not in the cloud — means irrigation keeps working even when the internet connection drops, which is the same reasoning that justified the Raspberry Pi as a gateway (see the Raspberry Pi — Central Hub chapter). It also follows the irrigation literature, which finds that closed-loop control (reacting to measured state) outperforms open-loop scheduling.
Important — power: unlike the battery sensor node, the pump-actuator node is not battery-powered. The DFR0523's motor draws roughly 5 W, so the node stays connected to mains/USB power and never enters deep sleep — it must always be reachable to receive a watering command.
2. Edge Decision-Maker — decisor_rega.py
2.1 How a Decision Is Made
The decision-maker reacts to each reading that arrives (approximately every five minutes). For the current single-zone deployment, only readings from sensor 1 drive the pump. The rule has three steps.
Step 1 — whether to water (soil moisture only). Soil moisture is the only variable that decides yes or no, in three bands:
| Soil moisture | Decision |
|---|---|
≥ 55 % (LIMIAR_ALVO) | No watering — at or above the target |
| 30 %–55 % | No watering — adequate band |
< 30 % (LIMIAR_SECO) | Water |
The gap between the trigger (30 %) and the target (55 %) provides hysteresis: a watering pushes the soil toward the upper band, so the system does not switch on and off around a single value.
Step 2 — how much to water (the other three variables). Air temperature, air humidity, and light do not decide whether to water; they only scale the dose duration:
deficit = 55 − soil(how far below the target the soil is);demanda= an evaporative-demand factor in the range[1.0, 2.0]that grows with higher temperature, lower air humidity, and stronger light — the conditions in which a plant loses more water;duration = (20 + deficit × 1.5) × demanda, clamped to [20, 120] seconds.
The pump speed (intensidade) is fixed at 80 %; it is the duration that carries the dose.
Worked example. Soil at 22 %, temperature 30 °C, air humidity 40 %, light 30 000 lux:
- 22 % < 30 % → water;
deficit = 55 − 22 = 33;demanda = 1.0 + 0.25 (heat) + 0.10 (dry air) + 0.125 (light) = 1.475;duration = (20 + 33 × 1.5) × 1.475 ≈ 102 s;- command sent:
{"acao":"regar","intensidade":80,"duracao":102}.
The full decision tree:
flowchart TD
A[New reading on sensores/jardim] --> B{sensor == 1?}
B -- no --> Z[Ignore]
B -- yes --> C{soil >= 55%?}
C -- yes --> N1[No watering: at/above target]
C -- no --> D{soil >= 30%?}
D -- yes --> N2[No watering: adequate band]
D -- no --> E{Watered in the last 30 min?}
E -- yes --> N3[No watering: cooldown]
E -- no --> F["duration = (20 + deficit x 1.5) x demanda, clamp 20-120 s"]
F --> G[Publish regar at 80 percent for duration to rega/comando]
2.2 Cooldown — Avoiding Over-Watering
Even when the rule decides to water, a cooldown (COOLDOWN_S = 30 min) suppresses any further watering for half an hour after a dose. This is necessary because soil moisture lags the addition of water: the water needs time to soak in and reach the buried probe. Without the cooldown, the system would re-read the still-dry soil five minutes later and water again, eventually saturating the pot.
Design note — calibration. The thresholds (30 % / 55 %) depend on the specific plant and soil, and the relationship between duration and volume of water depends on the pump's flow rate. Both are heuristics in this version and must be calibrated empirically in the target deployment.
2.3 Designed to Be Replaced by a Neural Network
The whole decision lives in a single function, decidir_rega(features), with a fixed input (the four measurements) and a fixed output (a command). When the planned neural network is ready, only the body of that function is replaced with model inference — the MQTT handling, the cooldown, the publishing, and the pump firmware all stay the same.
2.4 Script Walkthrough
Configuration. Topics, the controlled zone, and the (tunable) decision thresholds:
TOPICO_SENSOR = "sensores/jardim" # readings arrive here
TOPICO_COMANDO = "rega/comando" # commands go out here (the pump subscribes)
TOPICO_DECISAO = "rega/decisao" # decision log (optional: dashboard / Firestore)
ZONA_SENSOR_ID = 1 # only this sensor drives the pump
LIMIAR_SECO = 30 # water below this soil moisture (%)
LIMIAR_ALVO = 55 # never water at/above this (%)
INTENSIDADE = 80 # pump speed (%) when watering
DURACAO_BASE_S = 20 # base watering time (s) — CALIBRATE to the pump
DURACAO_MAX_S = 120 # hard cap on a single dose (s)
K_DEFICIT = 1.5 # extra seconds per % of soil deficit
COOLDOWN_S = 30 * 60 # min seconds between waterings (soak time)
The evaporative-demand factor. Bounded so no single variable can blow the dose out of proportion:
def fator_demanda(temp, humidade_ar, luz):
f = 1.0
if temp is not None and temp > 25:
f += min((temp - 25) / 20.0, 0.5) # up to +0.5 by ~45 C
if humidade_ar is not None and humidade_ar < 50:
f += min((50 - humidade_ar) / 100.0, 0.3) # up to +0.3 by ~20% RH
if luz is not None and luz > 20000:
f += min((luz - 20000) / 80000.0, 0.2) # up to +0.2 by ~100k lux
return max(1.0, min(f, 2.0))
The decision itself (the function the neural network will later replace):
def decidir_rega(features) -> Comando:
solo = features.get("humidade_solo")
if solo is None:
return Comando("nenhuma", motivo="sem leitura de humidade do solo")
if solo >= LIMIAR_ALVO:
return Comando("nenhuma", motivo=f"solo {solo}% >= alvo {LIMIAR_ALVO}%")
if solo >= LIMIAR_SECO:
return Comando("nenhuma", motivo=f"solo {solo}% dentro da banda adequada")
deficit = LIMIAR_ALVO - solo
demanda = fator_demanda(features.get("temp_ar"),
features.get("humidade_ar"),
features.get("luminosidade"))
duracao = (DURACAO_BASE_S + deficit * K_DEFICIT) * demanda
duracao = int(max(DURACAO_BASE_S, min(duracao, DURACAO_MAX_S)))
return Comando("regar", intensidade=INTENSIDADE, duracao=duracao, motivo="...")
Message handling and the cooldown gate. On each reading the decision is computed, then suppressed if a dose was issued too recently:
def on_message(client, userdata, msg):
leitura = json.loads(msg.payload.decode("utf-8"))
if leitura.get("sensor") != ZONA_SENSOR_ID:
return
features = extrair_features(leitura)
comando = decidir_rega(features)
if comando.acao == "regar":
desde = time.time() - ultima_rega.get(ZONA_SENSOR_ID, 0)
if desde < COOLDOWN_S: # still soaking — hold off
comando = Comando("nenhuma", motivo="em cooldown")
else:
ultima_rega[ZONA_SENSOR_ID] = time.time()
if comando.acao == "regar":
publicar_comando(client, comando) # to rega/comando
publicar_decisao(client, ZONA_SENSOR_ID, comando, features) # to rega/decisao
Startup. The client is built so the v1-style callbacks work on both paho-mqtt 1.x and 2.x (the 2.x release changed the constructor):
try:
cliente = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) # paho-mqtt >= 2.0
except (AttributeError, TypeError):
cliente = mqtt.Client() # paho-mqtt 1.x
cliente.username_pw_set(MQTT_USER, MQTT_PASS)
cliente.on_connect = on_connect
cliente.on_message = on_message
cliente.connect(MQTT_BROKER, 1883, 60)
cliente.loop_forever()
2.5 Deployment as a systemd Service
Like guarda_dados.py, the decision-maker runs unattended and restarts on failure or reboot:
[Unit]
Description=Edge irrigation decision-maker
After=network-online.target mosquitto.service
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi
ExecStart=/usr/bin/python3 /home/pi/decisor_rega.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now decisor_rega
3. Pump-Actuator Node — ESP32 + DFRobot DFR0523
3.1 The Pump and How It Is Controlled
The actuator is a DFRobot DFR0523 "Gravity: Digital Peristaltic Pump." [20] A peristaltic pump moves liquid by squeezing a flexible tube with rollers, so the liquid never touches the mechanism — ideal for clean, repeatable dosing. The pump carries an on-board PPM motor driver whose control signal is identical to a standard servo motor, so the ESP32 controls it with an ordinary servo library:
- a neutral angle (≈ 90°) stops the pump;
- moving toward 0° or 180° runs it in one direction or the other, with speed proportional to the distance from 90°.
The firmware maps the requested intensity (0–100 %) to a servo angle:
int intensidadeParaAngulo(int intensidade) {
intensidade = constrain(intensidade, 0, 100);
if (intensidade == 0) return ANGULO_PARADO; // 90 deg = stop
int desvio = map(intensidade, 0, 100, 0, 90); // 0..90 deg from "stop"
return SENTIDO_DESCENDENTE ? (ANGULO_PARADO - desvio) // 100% -> 0 deg
: (ANGULO_PARADO + desvio); // (flip if it runs backwards)
}
3.2 Wiring and Power
The DFR0523 uses a 3-pin Gravity cable (signal / VCC / GND):
| Pin | Connects to | Notes |
|---|---|---|
| Signal | ESP32 GPIO 13 (PWM-capable) | The servo-style control signal |
| VCC | External 5 V supply (≥ 1 A) | The motor draws ~5 W; the ESP32's USB 5 V (~2.5 W) is not enough |
| GND | Common ground | Must be shared between the 5 V supply and the ESP32 |
Why an external supply: powering the pump from the microcontroller's USB rail would brown-out the board. The pump gets its own 5 V source, and the two grounds are tied together so the control signal has a common reference.
3.3 Firmware — Code Walkthrough
Libraries, pins, and topics. The node reuses the same MQTT library as the sensor node, plus the ESP32Servo library [21] and ArduinoJson:
#include <WiFi.h>
#include <MQTT.h> // arduino-mqtt (same as the sensor node)
#include <ESP32Servo.h>
#include <ArduinoJson.h>
#define PINO_BOMBA 13 // PWM GPIO to the pump signal pin
#define ANGULO_PARADO 90 // servo angle that stops the pump
#define DURACAO_MAX_S 120 // hard upper bound on one watering (s)
const char* TOPICO_COMANDO = "rega/comando"; // we SUBSCRIBE here
const char* TOPICO_ESTADO = "rega/estado"; // we PUBLISH here
Starting and stopping the pump. Starting also (re)arms the auto-stop timer; stopping always leaves the pump in a known-safe state:
void iniciarBomba(int intensidade, int duracaoS, const char* motivo) {
intensidade = constrain(intensidade, 0, 100);
if (intensidade == 0 || duracaoS <= 0) { pararBomba("comando_invalido"); return; }
duracaoS = constrain(duracaoS, 1, DURACAO_MAX_S); // never exceed the safety cap
bomba.write(intensidadeParaAngulo(intensidade));
fimBombeamentoMs = millis() + (unsigned long)duracaoS * 1000UL; // (re)arm timer
aBombear = true;
publicarEstado("a_regar", motivo);
}
The MQTT command callback. A regar command starts (or refreshes) a dose; parar stops immediately:
void mensagemRecebida(String &topic, String &payload) {
JsonDocument doc;
if (deserializeJson(doc, payload)) return; // ignore malformed JSON
const char* acao = doc["acao"] | "";
if (strcmp(acao, "parar") == 0) {
pararBomba("comando");
} else if (strcmp(acao, "regar") == 0) {
iniciarBomba(doc["intensidade"] | 0, doc["duracao"] | 0, "comando");
}
}
The main loop enforces the two time-independent safeties — failsafe-stop on a lost link, and the auto-stop timer:
void loop() {
client.loop(); // process incoming commands
if (WiFi.status() != WL_CONNECTED || !client.connected()) {
if (aBombear) pararBomba("failsafe_sem_ligacao"); // never water blind
/* ... periodic reconnect ... */
}
if (aBombear && (long)(millis() - fimBombeamentoMs) >= 0) {
pararBomba("timeout"); // dose finished
}
}
3.4 Safety / Failsafes
Because this node moves water, every failure mode leaves the pump off:
| Situation | Safeguard |
|---|---|
| No stop command ever arrives (Pi crash, network down) | Hard run-time cap — the dose carries a duration and the pump auto-stops; the firmware also clamps any request to DURACAO_MAX_S (120 s) |
| Wi-Fi/MQTT link drops while pumping | Failsafe stop — the loop stops the pump immediately |
| Boot / reconnect | The pump is forced to stopped as its initial state |
| Duplicated command (MQTT QoS 1 redelivery) | A repeated regar only refreshes the timer — it does not stack, so it cannot double-dose |
| Node dies unexpectedly | An MQTT Last Will publishes {"estado":"offline"} so the rest of the system knows |
4. Command and Status Topics
| Topic | Direction | Payload |
|---|---|---|
rega/comando | decisor → pump (QoS 1) | {"acao":"regar","intensidade":80,"duracao":102} or {"acao":"parar"} |
rega/estado | pump → system (QoS 1) | {"estado":"a_regar|parado|offline","intensidade":N,"motivo":"..."} |
rega/decisao | decisor → system | A record of every decision (acted on or not), with the reason and the feature values, for logging on the dashboard |
5. End-to-End Control Flow
- The sensor node publishes a reading on
sensores/jardim. - The broker delivers it to both
guarda_dados.py(which stores it) anddecisor_rega.py(which evaluates it). decisor_rega.pyruns the rule. If the soil is dry and the 30-minute cooldown has elapsed, it publishes aregarcommand (80 % speed, a duration scaled by the soil deficit and the evaporative demand) onrega/comando.- The pump node receives the command, runs the pump for the requested time, and auto-stops — even if it loses contact with the broker mid-dose.
- The pump node reports its state on
rega/estado, and the decisor logs the decision onrega/decisao.
Because the watering time is bounded by the pump node's own timer, the worst case for any single failure is a missed watering or one dose of at most 120 seconds — never an uncontrolled pump.
Appendix A — Glossary of Key Concepts
This glossary defines technical terms used throughout this document that may not be immediately familiar to readers from non-technical backgrounds.
| Term | Definition |
|---|---|
| Analog sensor | A sensor that outputs a continuous voltage proportional to the measured quantity. The voltage must be converted to a digital number by an ADC before the microcontroller can process it. |
| Broker (MQTT) | A central server that receives messages from publishers and forwards them to subscribers. In this system, Mosquitto running on the Raspberry Pi acts as the broker. |
| Callback function | A function that is not called directly by the programmer, but is automatically invoked by a library or framework when a specific event occurs (e.g., a new MQTT message arrives). |
| CDN (Content Delivery Network) | A network of servers distributed around the world that cache and serve web content from the server closest to the user, reducing latency and server load. Firebase Hosting uses Google's global CDN. |
| Client ID | A unique string that identifies a device when it connects to an MQTT broker. No two devices can use the same client ID simultaneously. |
| Composite index | A database index that covers multiple fields, allowing queries that filter or sort by more than one field to execute efficiently. |
| CORS | A browser security mechanism that prevents a web page from making requests to a different domain than the one it was loaded from, unless the server explicitly allows it. |
| Deep sleep | A low-power mode where the microcontroller shuts down most of its circuitry (CPU, Wi-Fi, peripherals) and only keeps a small timer running. When the timer expires, the device reboots completely. |
| Dielectric permittivity | A physical property of a material that describes how well it stores electrical energy. Water has a high dielectric permittivity, which is why the capacitive soil moisture sensor can detect water content — wetter soil stores more energy, changing the sensor's capacitance. |
| Document (Firestore) | A single record in a Firestore database, similar to a row in a traditional database. Each document contains a set of key-value pairs (fields). |
| Collection (Firestore) | A group of documents in Firestore, similar to a table in a traditional database. In this system, the estufa collection stores sensor readings. |
| Edge computing | Processing data locally (on the Raspberry Pi) rather than sending it to a remote cloud server. This allows the system to make decisions even when internet connectivity is unavailable. |
| Exponential backoff | A retry strategy where the wait time between retries increases progressively (e.g., 30s, 60s, 120s) to avoid overwhelming a server that may be temporarily overloaded. |
| Firmware | Software that runs on a microcontroller (the ESP32 in this case). Unlike desktop software, firmware is compiled and uploaded directly to the device's flash memory. |
| GPIO (General-Purpose Input/Output) | Physical pins on a microcontroller that can be configured as inputs (to read sensors) or outputs (to control actuators like LEDs or relays). |
| I2C (Inter-Integrated Circuit) | A two-wire communication protocol (SDA for data, SCL for clock) used to connect multiple digital sensors to a microcontroller using just two pins. |
| Idempotent operation | An operation that produces the same result regardless of how many times it is executed. For example, writing the same document to Firestore twice results in the same state as writing it once. |
| JSON (JavaScript Object Notation) | A lightweight text format for representing structured data as key-value pairs. Used in this system to encode sensor readings for transmission via MQTT. |
| Keep-alive | A periodic ping sent by an MQTT client to the broker to prove the connection is still active. If the broker does not receive a keep-alive within the configured interval, it considers the client disconnected. |
| KPI (Key Performance Indicator) | In the context of this dashboard, the four live-updated cards displaying the most recent sensor readings (temperature, soil moisture, air humidity, light level). |
| Lux | The SI unit of illuminance, measuring the amount of visible light falling on a surface. Full sunlight is approximately 100,000 lux; a well-lit office is approximately 500 lux. |
| NoSQL | A category of databases that do not use the traditional table-and-row structure of SQL databases. Firestore is a document-oriented NoSQL database where data is organized into collections of documents. |
| Payload | The actual data content of a message, as opposed to its headers or metadata. In this system, the MQTT payload is a JSON string containing sensor readings. |
| Polling | Periodically checking for new data by sending repeated requests at fixed intervals, as opposed to receiving push notifications when new data is available. |
| Publish/subscribe (pub/sub) | A messaging pattern where senders (publishers) do not send messages directly to receivers. Instead, they publish to a topic, and the broker forwards the message to all subscribers of that topic. |
| Retained message | An MQTT feature where the broker stores the last message published to a topic and delivers it immediately to any new subscriber, even if the message was published before the subscriber connected. |
| Reverse proxy | A server that sits between clients and backend services, forwarding client requests to the appropriate service. Firebase Hosting acts as a reverse proxy, forwarding /api requests to the Cloud Function. |
| Service account | A special type of Google account used by applications (not humans) to authenticate with Firebase services. It is represented by a JSON key file containing a private key. |
| Serverless | A cloud computing model where the cloud provider manages all infrastructure. The developer writes functions that run on demand without provisioning or managing servers. Cloud Functions is a serverless platform. |
| Skeleton animation | A UI pattern that displays animated placeholder shapes (shimmering rectangles) where content will appear, indicating that data is being loaded. |
| systemd | The service manager in modern Linux distributions. It can start, stop, restart, and monitor long-running programs (services) and ensure they start automatically on boot. |
| TLS/SSL | Encryption protocols that secure data in transit over a network. HTTPS uses TLS to encrypt web traffic. MQTT can also use TLS to encrypt messages between clients and the broker. |
| Topic (MQTT) | A hierarchical string (e.g., sensores/jardim) that acts as an address for messages. Publishers send messages to a topic; subscribers listen to a topic. |
| Trigger (Firestore) | A Cloud Function that runs automatically when a specific event occurs in the database (e.g., a new document is created in the estufa collection). |
| Wildcard (MQTT) | A special character used in topic subscriptions. # matches all sub-levels (e.g., sensores/# matches sensores/jardim, sensores/estufa, etc.). |
Appendix B — List of Acronyms
| Acronym | Full Form |
|---|---|
| ACL | Access Control List |
| ADC | Analog-to-Digital Converter |
| AI | Artificial Intelligence |
| API | Application Programming Interface |
| ARM | Advanced RISC Machine (processor architecture) |
| BOM | Byte Order Mark |
| CA | Certificate Authority |
| CAPTCHA | Completely Automated Public Turing test to tell Computers and Humans Apart |
| CDN | Content Delivery Network |
| CLI | Command Line Interface |
| CORS | Cross-Origin Resource Sharing |
| CPU | Central Processing Unit |
| CSS | Cascading Style Sheets |
| CSV | Comma-Separated Values |
| DHCP | Dynamic Host Configuration Protocol |
| DHT | Digital Humidity and Temperature (sensor family) |
| DNS | Domain Name System |
| ESP | Espressif (manufacturer of the ESP32 chip) |
| GPIO | General-Purpose Input/Output |
| HTML | HyperText Markup Language |
| HTTP | HyperText Transfer Protocol |
| HTTPS | HyperText Transfer Protocol Secure |
| I2C | Inter-Integrated Circuit |
| IDE | Integrated Development Environment |
| IoT | Internet of Things |
| IP | Internet Protocol |
| JSON | JavaScript Object Notation |
| KPI | Key Performance Indicator |
| LAN | Local Area Network |
| MQTT | Message Queuing Telemetry Transport |
| NoSQL | Not Only SQL |
| OAuth | Open Authorization |
| OS | Operating System |
| PCB | Printed Circuit Board |
| QoS | Quality of Service |
| RAM | Random Access Memory |
| REST | Representational State Transfer |
| RH | Relative Humidity |
| RTC | Real-Time Clock |
| SCL | Serial Clock Line (I2C) |
| SDA | Serial Data Line (I2C) |
| SDK | Software Development Kit |
| SSH | Secure Shell |
| SSID | Service Set Identifier (Wi-Fi network name) |
| SSL | Secure Sockets Layer |
| TCP | Transmission Control Protocol |
| TLS | Transport Layer Security |
| TTGO | Brand name (LilyGo subsidiary) |
| TX | Transmit |
| TXT | Text (DNS record type) |
| URL | Uniform Resource Locator |
| USB | Universal Serial Bus |
| UTC | Coordinated Universal Time |
| VLAN | Virtual Local Area Network |
Appendix C — Arduino IDE Setup
The following steps detail the installation and configuration of the development environment used to compile and upload firmware to the TTGO T-Higrow board.
1. Install the Arduino IDE — download from arduino.cc/en/software and install.
2. Add ESP32 board support — open File → Preferences, paste the following URL into Additional Board Manager URLs:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
Then open Tools → Board → Boards Manager, search for esp32, and install the package by Espressif Systems. Select Tools → Board → ESP32 Dev Module.
3. Install required libraries — via Sketch → Include Library → Manage Libraries:
| Library | Author |
|---|---|
PubSubClient |
Nick O'Leary |
DHT sensor library |
Adafruit |
BH1750 |
Christopher Laws |
ArduinoJson |
Benoît Blanchon |
WiFi.h and Wire.h are included with the ESP32 board package.
Subsequent upgrade:
PubSubClientwas later replaced withMQTT(thearduino-mqttlibrary by Joël Gähwiler / 256dpi) so the publish side could operate at QoS 1 — see Section 2.2 of the Raspberry Pi chapter for context, and Section 5.1 of the firmware chapter for the API differences. In the Library Manager, search forMQTTand install the entry by Joel Gahwiler.PubSubClientcan be removed once the firmware no longer references it. The pump-actuator node (Irrigation Control chapter) additionally requires the ESP32Servo library (Kevin Harrington / John K. Bennett) [21], installed the same way via the Library Manager.
4. Connect the board — connect the TTGO T-Higrow via USB-C. Select Tools → Port → /dev/ttyUSB0 (Linux) or COMx (Windows). If the port does not appear, install the CP2104 USB driver from Silicon Labs.
5. Configure upload settings:
| Setting | Value |
|---|---|
| Board | ESP32 Dev Module |
| Upload Speed | 115200 |
| Flash Frequency | 80 MHz |
| Flash Size | 4MB (32Mb) |
| Partition Scheme | Default 4MB with spiffs |
6. Upload — click Verify to compile, then Upload to flash. Open Tools → Serial Monitor at 115200 baud to verify the first wake cycle.
References
| # | Source |
|---|---|
| [1] | Espressif Systems, "ESP32 Series Datasheet," v4.6, 2024. Available: https://www.espressif.com/sites/default/files/documentation/esp32_datasheet_en.pdf |
| [2] | Aosong Electronics, "DHT11 — Temperature & Humidity Sensor Datasheet," 2023. Available: https://www.mouser.com/datasheet/2/758/DHT11-Technical-Data-Sheet-Translated-Version-1143054.pdf |
| [3] | ROHM Semiconductor, "BH1750FVI — Digital 16-bit Serial Output Type Ambient Light Sensor IC Datasheet," 2011. Available: https://www.mouser.com/datasheet/2/348/bh1750fvi-e-186247.pdf |
| [4] | Aosong Electronics, "DHT22 (AM2302) — Digital Temperature & Humidity Sensor Datasheet," 2023. Available: https://www.sparkfun.com/datasheets/Sensors/Temperature/DHT22.pdf |
| [5] | Sensirion AG, "SHT31 — Digital Humidity Sensor Datasheet," 2022. Available: https://sensirion.com/media/documents/213E6A3B/63A5A569/Datasheet_SHT3x_DIS.pdf |
| [6] | Espressif Systems, "ESP32 Technical Reference Manual," v5.2, Section 29 — ADC, 2024. Available: https://www.espressif.com/sites/default/files/documentation/esp32_technical_reference_manual_en.pdf |
| [7] | OASIS Standard, "MQTT Version 5.0," 2019. Available: https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html |
| [8] | Eclipse Foundation, "Eclipse Mosquitto — An Open Source MQTT Broker." Available: https://mosquitto.org/ |
| [9] | Raspberry Pi Ltd, "Raspberry Pi 5 Product Brief," 2023. Available: https://www.raspberrypi.com/products/raspberry-pi-5/ |
| [10] | Google, "Firebase Pricing — Spark Plan." Available: https://firebase.google.com/pricing |
| [11] | LILYGO, "TTGO T-Higrow — Plant Monitoring Sensor Board," GitHub repository. Available: https://github.com/Xinyuan-LilyGO/T-Higrow |
| [12] | Chart.js Contributors, "Chart.js — Open Source HTML5 Charts." Available: https://www.chartjs.org/ |
| [13] | Benoît Blanchon, "ArduinoJson — JSON Library for Embedded C++," v7. Available: https://arduinojson.org/ |
| [14] | Nick O'Leary, "PubSubClient — MQTT Client for Arduino," GitHub repository. Available: https://github.com/knolleary/pubsubclient |
| [15] | Google, "Cloud Firestore Documentation." Available: https://firebase.google.com/docs/firestore |
| [16] | Google, "Firebase Hosting Documentation." Available: https://firebase.google.com/docs/hosting |
| [17] | Google, "Firebase App Check Documentation." Available: https://firebase.google.com/docs/app-check |
| [18] | Font Awesome, "Font Awesome — The Web's Icon Library," v6. Available: https://fontawesome.com/ |
| [19] | Eclipse Foundation, "Paho MQTT Python Client," GitHub repository. Available: https://github.com/eclipse/paho.mqtt.python |
| [20] | DFRobot, "Gravity: Digital Peristaltic Pump (SKU: DFR0523) — Product Wiki." Available: https://wiki.dfrobot.com/Gravity__Digital_Peristaltic_Pump_SKU__DFR0523 |
| [21] | K. Harrington and J. K. Bennett, "ESP32Servo — Servo library for the ESP32 (Arduino)," GitHub repository. Available: https://github.com/madhephaestus/ESP32Servo |