Build & Reference Documentation

Smart Irrigation System

A resilient, reproducible end-to-end IoT architecture with edge decision-making.

Live dashboard: estufaiscte.pt Stack: ESP32 · Raspberry Pi · MQTT · Firebase Context: M.Sc. METI · ISCTE-IUL

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:

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 default Wire.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

  1. 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.
  2. 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 using Serial.println(analogRead(32)). Multiple readings were taken over 30 seconds and averaged. The result was 3425, which represents the 0% moisture reference point.
  3. Saturate the soil — the same soil sample was then thoroughly watered until it was visibly waterlogged, with water pooling on the surface.
  4. 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.
  5. 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
  1. 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 using constrain() 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: PubSubClient was later replaced with MQTT (the arduino-mqtt library 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: PubSubClient was later replaced with MQTT (the arduino-mqtt library by Joël Gähwiler / 256dpi) so the publish side could operate at QoS 1 (Section 2.2 of the Raspberry Pi chapter). PubSubClient accepts a QoS argument only on subscribe() — its publish() API has no QoS parameter and transmits unconditionally at QoS 0. The replacement library keeps a synchronous API that closely mirrors PubSubClient, 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 WiFiClient is still required — it is now passed to client.begin(host, port, espClient) inside ligarMQTT() (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);
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 ID ESP32_Estufa was 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 PubSubClient at QoS 0 with retained = true. The system was later upgraded to QoS 1 with retained = false. Since PubSubClient does not expose QoS on publish, it was replaced with the arduino-mqtt library (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). The client.setServer(...) call was replaced with client.begin(mqtt_server, 1883, espClient) because the new library takes the underlying transport in begin() 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 PUBACK is 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 id field:

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's PUBACK before considering the message delivered. The flush was therefore replaced with a 3-second polling loop that calls client.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

PhaseDurationCurrent DrawSource
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 mADHT11 + BH1750 + ADC
Active: Wi-Fi TX + MQTT~5 s~120 mAESP32 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 "no PUBACK received within MQTT_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:

  1. Wi-Fi connection failure — if the router is temporarily unresponsive (e.g., during a firmware update or heavy traffic), the ligarWiFi() helper aborts after WIFI_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.
  2. 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 after MQTT_TIMEOUT_MS (10 seconds). Same outcome: one lost reading, battery preserved.
  3. 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:

  1. 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-west1 would 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.

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

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

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

  5. 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:

  1. 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.
  2. 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.
  3. Local network — the ESP32 and broker communicate over a private LAN, where packet loss rates are negligible compared to public internet links.
  4. 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 (PubSubClient does 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; with retained = false the absence of new readings is itself a signal, and recency is reconstructed from the data_hora field added on receipt by guarda_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 true to 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), and guarda_dados.py calls cliente_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.

  1. Download the Raspberry Pi Imager from raspberrypi.com/software.
  2. Insert a microSD card (16 GB or larger) into the computer.
  3. In the Imager, select Raspberry Pi OS Lite (64-bit).
  4. 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
  5. Write the image to the microSD card.
  6. Insert the card into the Raspberry Pi and power it on.
  7. 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's mqtt_server constant.

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

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

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):

  1. Click Add project and name it (e.g., smart-irrigation).
  2. Disable Google Analytics (not needed for this prototype).
  3. Once the project is created, navigate to Build → Firestore Database.
  4. Click Create database. Select Start in production mode and choose the server location closest to Lisbon (e.g., europe-west1).
  5. Navigate to Project Settings → Service accounts.
  6. Click Generate new private key. A JSON file is downloaded — this file contains the credentials the Raspberry Pi needs to authenticate with Firebase.
  7. 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:

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:

  1. Most recent readings firstsensor ascending + data_hora descending allows 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.
  2. Chronological ordersensor ascending + data_hora ascending allows 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:

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. The estufa collection 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 same estufa collection, distinguished by the sensor field (e.g., sensor: 1 for the garden node). References to sensores/estufa elsewhere 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() before connect() 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:

  1. Decode the payload — the raw MQTT payload is a byte string. decode('utf-8') converts it to a regular Python string.
  2. Parse JSONjson.loads() converts the JSON string into a Python dictionary containing the sensor readings.
  3. Add UTC timestampdatetime.datetime.now(datetime.timezone.utc) generates the current date and time in UTC (Coordinated Universal Time). Using explicit UTC rather than datetime.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.
  4. Write to Firestoreescrever_firestore(dados) attempts to write with retries. If all retries fail, the reading is buffered locally.
  5. Flush bufferflush_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 PUBACK from 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 deterministic id (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 whose id is 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 (the deque). 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() before connect():

cliente_mqtt.username_pw_set(MQTT_USER, MQTT_PASS)
cliente_mqtt.connect(MQTT_BROKER, 1883, 60)

The constants MQTT_USER and MQTT_PASS are declared in the configuration block (Section 5.3).

Subsequent upgrade: paho-mqtt 2.0 changed the client constructor and the callback signatures, so a bare mqtt.Client() now defaults to the new (v2) callback API — under which the v1-style on_connect(client, userdata, flags, rc) and on_message handlers above would no longer fire as written. To stay compatible with both major versions (and to match decisor_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 id field 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 lost PUBACK causes 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 + id stamp) 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:

8.2 Enable and Start the Service

sudo systemctl daemon-reload
sudo systemctl enable guarda_dados
sudo systemctl start guarda_dados

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:

  1. 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.
  2. 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].
  3. 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].
  4. 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:

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:

  1. In the Firebase Console, navigate to Project Settings → General.
  2. Under Your apps, click Add app and select the Web platform (</>).
  3. Enter an app nickname (e.g., Estufa) and optionally enable Firebase Hosting for this app.
  4. 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>"
};
  1. This configuration is embedded directly in the index.html file. 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:

  1. In the Google Cloud Console (console.cloud.google.com), navigate to Security → reCAPTCHA Enterprise.
  2. Click Create Key.
  3. Set the key type to Website and add the domain(s) where the dashboard is hosted: <project-id>.web.app, <project-id>.firebaseapp.com, and estufaiscte.pt.
  4. Disable the checkbox verification (the key should work invisibly, without showing a CAPTCHA puzzle to the user).
  5. 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:

  1. In the Firebase Console, navigate to App Check.
  2. Select the web application registered in step 2.3 (Estufa).
  3. Click reCAPTCHA Enterprise as the attestation provider.
  4. Paste the site key generated in step 1.
  5. Click Save.

Step 3 — Enforce App Check on Cloud Functions:

  1. Still in the Firebase Console under App Check, navigate to the APIs tab.
  2. Find Cloud Functions in the list and click Enforce.
  3. 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-AppCheck header) 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:


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;

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:

  1. Extracts the new document's data — reads the sensor ID, timestamp, and all measurement values from the newly created Firestore document.
  2. 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.
  3. 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).
  4. Appends the new reading — the validated reading is added to the cache array, which is then sorted chronologically and trimmed to MAX_DADOS_CACHE entries.
  5. Writes the updated cache — the entire array is written back to the cache document in a single operation.
  6. Registers the sensor — if this is the first reading from a new sensor ID, the function adds the ID to a config/estufa_info document 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:

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:

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

  1. In the Firebase Console, navigate to Hosting → Custom domains.

  2. Click Add custom domain and enter estufaiscte.pt.

  3. Firebase provides two DNS TXT records for domain ownership verification. These records were added in the domain registrar's DNS management panel.

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

  5. 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:


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:

  1. Display the sensor dropdown populated with active sensor IDs.
  2. Show the four KPI cards with the most recent reading.
  3. Render the historical chart with data from the last 24 hours (default filter).
  4. 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:

  1. an edge decision-maker (decisor_rega.py) that runs on the Raspberry Pi and decides, on every reading, whether to water; and
  2. 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 moistureDecision
≥ 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:

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:

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:

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):

PinConnects toNotes
SignalESP32 GPIO 13 (PWM-capable)The servo-style control signal
VCCExternal 5 V supply (≥ 1 A)The motor draws ~5 W; the ESP32's USB 5 V (~2.5 W) is not enough
GNDCommon groundMust 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:

SituationSafeguard
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 pumpingFailsafe stop — the loop stops the pump immediately
Boot / reconnectThe 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 unexpectedlyAn MQTT Last Will publishes {"estado":"offline"} so the rest of the system knows

4. Command and Status Topics

TopicDirectionPayload
rega/comandodecisor → pump (QoS 1){"acao":"regar","intensidade":80,"duracao":102} or {"acao":"parar"}
rega/estadopump → system (QoS 1){"estado":"a_regar|parado|offline","intensidade":N,"motivo":"..."}
rega/decisaodecisor → systemA 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

  1. The sensor node publishes a reading on sensores/jardim.
  2. The broker delivers it to both guarda_dados.py (which stores it) and decisor_rega.py (which evaluates it).
  3. decisor_rega.py runs the rule. If the soil is dry and the 30-minute cooldown has elapsed, it publishes a regar command (80 % speed, a duration scaled by the soil deficit and the evaporative demand) on rega/comando.
  4. 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.
  5. The pump node reports its state on rega/estado, and the decisor logs the decision on rega/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: PubSubClient was later replaced with MQTT (the arduino-mqtt library 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 for MQTT and install the entry by Joel Gahwiler. PubSubClient can 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