Build & Reference Documentation

Smart Irrigation System

A resilient, reproducible end-to-end IoT architecture with edge decision-making, a learned irrigation model, and a conversational assistant.

Live dashboard: estufaiscte.pt Source code: /code Stack: ESP32 · Raspberry Pi · MQTT · Firebase · scikit-learn · Telegram Context: M.Sc. METI · ISCTE-IUL

About this page & a note on secrets

This is the complete build-and-reference documentation, mirroring the dissertation. Every credential shown is a placeholder — never publish real Wi-Fi/broker credentials, the Firebase service-account key, or the Telegram bot token. The full, copy-able source for every script is on the source-code page.

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/><your-domain>"]
    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;
const int ValorMolhado = 1635;
  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);

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
#define WIFI_TIMEOUT_MS  15000
#define MQTT_TIMEOUT_MS  10000

const char* ssid        = "<your-wifi-ssid>";
const char* password    = "<your-wifi-password>";
const char* mqtt_server = "<broker-ip>";

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

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:

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

Phase Duration Current Draw Source
Deep sleep ~292 s (4 min 52 s) ~10 µA (0.01 mA) ESP32 datasheet (RTC timer only)
Active: sensor init + read ~3 s ~30 mA DHT11 + BH1750 + ADC
Active: Wi-Fi TX + MQTT ~5 s ~120 mA ESP32 Wi-Fi transmit (peak ~240 mA, average ~120 mA)
Total active phase ~8 s ~90 mA (weighted average)

7.2 Average Current per Cycle

One complete cycle lasts 300 seconds (5 minutes). The average current is calculated as:

$$ I_{avg} = \frac{(I_{active} \times t_{active}) + (I_{sleep} \times t_{sleep})}{t_{total}} $$

$$ I_{avg} = \frac{(90 \text{ mA} \times 8 \text{ s}) + (0.01 \text{ mA} \times 292 \text{ s})}{300 \text{ s}} = \frac{720 + 2.92}{300} \approx 2.41 \text{ mA} $$

7.3 Estimated Battery Life

$$ \text{Battery life} = \frac{\text{Battery capacity}}{I_{avg}} = \frac{3000 \text{ mAh}}{2.41 \text{ mA}} \approx 1245 \text{ hours} \approx 52 \text{ 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.



9. Subsequent Upgrade — Flapping Fix and On-Demand Diagnostics

After deployment, the sensor node began flapping: the live monitor repeatedly raised Sensor 1 sem resposta alerts followed minutes later by voltou a responder, where previously it had been stable. Cross-referencing the alerts with broker arrival times revealed genuine, irregular gaps of 15 minutes to ~3 hours, all multiples of the 5-minute cycle — the node really was disappearing, not a monitor fault. The power supply was a fixed USB source, ruling out a weak battery.

The root cause was in the firmware, not the Raspberry Pi. Every failure path in setup() called entrarDeepSleep() and slept the full 5-minute cycle with no retry, so one bad cycle became a 5-minute gap and N consecutive bad cycles became an N×5-minute gap. The dominant trigger was the DHT11, which was read once with no retry and intermittently returns NaN; a single NaN aborted the entire transmission, discarding the valid soil and light readings as well. A secondary trigger was occasional Wi-Fi/MQTT association failure on wake.

Three changes resolved it (full code in Appendix D.1):

  1. Parametrised deep sleep. entrarDeepSleep(uint32_t segundos = TEMPO_DORMIR) plus a new TEMPO_RETRY (60 s): any failed cycle now sleeps one minute instead of five, bounding the data gap regardless of the cause.
  2. DHT11 retry. lerDHT() retries up to five times (2 s apart) before giving up, and the isnan() validation now covers only the DHT. The BH1750 returns a negative value (not NaN) on error, so it is handled separately and no longer aborts the send — the reading is transmitted with luminosidade = 0.
  3. More robust Wi-Fi. ligarWiFi() forces WIFI_STA mode, calls WiFi.disconnect(true) to clear stale radio state left by deep sleep, and logs WiFi.status() and RSSI on timeout for diagnosis.

Lesson learned: an early version of the fix also added WiFi.setSleep(false), which unexpectedly prevented the node from associating at all (continuous WIFI_TIMEOUT, zero messages at the broker). Removing it and adding the post-sleep WiFi.disconnect(true) restored connectivity. Disabling Wi-Fi power save after deep sleep should not be assumed to be universally beneficial.

9.1 On-Demand Failure Diagnostics

Because the node cannot report a failure while it is failing — the network is the very thing that is down — it accumulates two values in RTC memory (RTC_DATA_ATTR, which survives deep sleep but resets on power loss): ciclosFalhados (cycles missed since the last successful send) and ultimoErro (a reason code mapped to DHT_NAN, WIFI_TIMEOUT or MQTT_TIMEOUT). These are carried out on the next reading that succeeds, as the JSON fields falhas and ultimo_erro, and then reset to zero.

These two fields are diagnostics, not sensor data, and by design must not be persisted to Firestore. MQTT fans each reading out to its independent subscribers: guarda_dados.py drops the fields immediately after parsing (so the database stays clean), while monitor.py — a separate subscriber — reads them, records them in state.json, and appends the reason to the recovery alert (for example, "voltou a responder (tinha falhado 3 ciclo(s); ultimo motivo: WIFI_TIMEOUT)"). The Telegram assistant can therefore explain intermittent drops on demand, without any new proactive alerts and without polluting the historical record. The handling is described in the Raspberry Pi chapter and the full sources are listed in Appendix D.



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

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

mosquitto_sub -h localhost -t "sensores/jardim" -v

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)

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

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.

Subsequent upgrade — buffer durability. The resilience guarantee above ("no reading is ever permanently lost") was later tested directly through fault injection: with the sensor still publishing normally, the Raspberry Pi's outbound access to Firestore was blocked at the firewall (an nftables rule rejecting traffic to Google's address ranges on port 443), simulating a cloud outage while keeping the local network alive. The test exposed a defect that silently lost the buffered readings — the exact failure the buffer was meant to prevent. A reading captured during the outage (esp32_1_8) was confirmed absent from Firestore even after connectivity was restored.

The root cause was a three-step interaction. First, flush_buffer() converted each entry's data_hora field from an ISO string back into a datetime object in place, mutating the very list it was iterating. Second, when a re-send still failed (the outage was ongoing), that entry — now holding a non-serialisable datetime — was written back to disk by guardar_buffer(). Third, json.dump() cannot serialise a datetime and raised TypeError after open(path, "w") had already truncated the file; since only IOError was caught, the file was left truncated and corrupted. On the next cycle, carregar_buffer() detected the invalid JSON and discarded the whole buffer. In effect, the buffer survived only while an outage lasted less than a single retry cycle; any longer outage corrupted and erased the pending data.

Three changes hardened the mechanism:

  1. Serialise from a copy. flush_buffer() now builds a throw-away copy for the Firestore write, leaving the buffered original with its data_hora string intact, so a failed entry remains JSON-serialisable:
for dado in buffer:
    try:
        envio = dict(dado)
        if isinstance(envio.get("data_hora"), str):
            envio["data_hora"] = datetime.datetime.fromisoformat(envio["data_hora"])
        db.collection('estufa').add(envio)
    except Exception:
        falhas.append(dado)
  1. Atomic, defensive writes. guardar_buffer() writes to a temporary file and renames it over the target with os.replace() — an atomic operation on POSIX — so the buffer file can never be observed in a truncated state, even if the process is killed mid-write. json.dump(..., default=str) and a broadened except (IOError, OSError, TypeError) provide a second line of defence against any stray non-serialisable value:
def guardar_buffer(buffer):
    tmp = f"{BUFFER_FILE}.tmp"
    try:
        with open(tmp, "w") as f:
            json.dump(buffer, f, default=str)
        os.replace(tmp, BUFFER_FILE)
    except (IOError, OSError, TypeError) as e:
        print(f"[ERROR] Falha ao guardar buffer local: {e}")
  1. One buffer file per service. Because two ingestion services run in parallel on the Pi (estufa.service and guarda_dados.service, both executing this script), a single shared buffer file would still be corruptible through concurrent writes (and prone to lost updates even with atomic renames, as one process's append overwrites the other's). BUFFER_FILE was therefore made configurable via an environment variable, set to a distinct path in each unit's systemd drop-in, eliminating the shared-state hazard without the complexity of file locking:
BUFFER_FILE = os.environ.get("BUFFER_FILE", "/home/pi/buffer_pendente.json")

After these changes, the same fault-injection test was repeated against the buffer logic in isolation: the buffer grew across successive outage cycles (1 → 2 → 3 entries) and survived repeated offline flush attempts without corruption or data loss, with data_hora correctly preserved as a string throughout. This episode underlines a general principle for resilience code: a recovery path that is never exercised under real failure conditions cannot be assumed to work — it must be tested by deliberately inducing the failure it claims to handle.

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
ids_vistos       = deque(maxlen=IDS_RECENTES_MAX)
ids_set          = set()

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. This count-bounded design, however, rested on a flawed assumption — that an id, once recorded, would not recur. In practice the ESP32 counter resets to 1 on every power cycle, so ids are reused: a reading sent after a restart can carry an id (e.g. esp32_1_8) that is still present in the cache from before the restart. The cache then rejects a perfectly valid, different reading as a duplicate. This was not hypothetical — fault-injection testing reproduced it directly: after power-cycling the sensor, the first post-reboot readings (esp32_1_1, esp32_1_2, esp32_1_3) were silently discarded as "duplicates" and never reached Firestore.

Further upgrade — time-windowed deduplication. The cache was therefore re-bounded by time rather than by count. An id only counts as a redelivery if it reappears within DEDUP_WINDOW_S seconds; this window is set comfortably above the QoS-1 retransmit horizon (seconds) and well below the sensor cadence (~5 minutes), so a genuine QoS-1 resend is still caught while a reused id after a reboot — necessarily minutes later — is correctly treated as new data:

DEDUP_WINDOW_S = 120
ids_vistos     = {}

msg_id = dados.get("id")
agora  = time.monotonic()
visto  = ids_vistos.get(msg_id)
if msg_id and visto is not None and agora - visto <= DEDUP_WINDOW_S:
    return

if escrever_firestore(dados) and msg_id:
    ids_vistos[msg_id] = agora
    for k in [k for k, t in ids_vistos.items() if agora - t > DEDUP_WINDOW_S]:
        del ids_vistos[k]

The dictionary is purged on every write, so it stays naturally small (a handful of entries at this cadence) without an arbitrary count cap, and a monotonic clock is used so the window is immune to wall-clock adjustments. The design now deliberately favours never dropping a real reading: in the worst case (a redelivery arriving after the window, or two reboots within it) it admits a rare duplicate document — already tolerated by the system, which intentionally runs two parallel ingestion services — rather than risk discarding genuine data. This reflects a considered ordering of failure modes: a duplicate is a cosmetic nuisance that a downstream query can collapse, whereas a dropped reading is unrecoverable.

5.7 MQTT Client Startup

flush_buffer()

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)
except (AttributeError, TypeError):
    cliente_mqtt = mqtt.Client()

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 time-windowed cache, mark-on-success). The Pi-side cache is bounded by time rather than by count precisely because the RTC-backed counter, while it survives deep sleep, is lost on a full power cycle and so reuses ids across reboots (see the "Further upgrade" note in Section 5.6).


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:

scp guarda_dados.py pi@raspberrypi:~/guarda_dados.py

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 <your-domain>, 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/><your-domain>] -->|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 <your-domain>.
  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://<your-domain>",
];

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 <your-domain>. 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;
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 the project's 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 the static build-and-reference documentation page (a book icon → /documentation), and that page links back to the dashboard (a house icon → /). The documentation page renders this same dissertation content as a styled, self-contained web page served from the same Firebase Hosting site.


7. Custom Domain — <your-domain>

The dashboard is accessible at the custom domain <your-domain> instead of the default Firebase URL (<project-id>.web.app). The setup process was:

7.1 Register the Domain

The domain <your-domain> 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 <your-domain>.

  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://<your-domain> 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. The chapter closes (Section 6) with the first concrete steps toward that learned replacement: a local SQLite dataset logger and a Random Forest baseline model.


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 {"acao":"regar",...} (rega/comando, QoS 1)
    B->>P: deliver command
    P->>P: run pump for `duracao` seconds, then auto-stop
    P->>B: publish {"estado":"parado",...} (rega/estado)

Running the decision at the edge — not in the cloud — means irrigation keeps working even when the internet connection drops, which is the same reasoning that justified the Raspberry Pi as a gateway (see the Raspberry Pi — Central Hub chapter). It also follows the irrigation literature, which finds that closed-loop control (reacting to measured state) outperforms open-loop scheduling.

Important — power: unlike the battery sensor node, the pump-actuator node is not battery-powered. The DFR0523's motor draws roughly 5 W, so the node stays connected to mains/USB power and never enters deep sleep — it must always be reachable to receive a watering command.


2. Edge Decision-Maker — decisor_rega.py

2.1 How a Decision Is Made

The decision-maker reacts to each reading that arrives (approximately every five minutes). For the current single-zone deployment, only readings from sensor 1 drive the pump. The rule has three steps.

Step 1 — whether to water (soil moisture only). Soil moisture is the only variable that decides yes or no, in three bands:

Soil moisture Decision
≥ 55 % (LIMIAR_ALVO) No watering — at or above the target
30 %–55 % No watering — adequate band
< 30 % (LIMIAR_SECO) Water

The gap between the trigger (30 %) and the target (55 %) provides hysteresis: a watering pushes the soil toward the upper band, so the system does not switch on and off around a single value.

Step 2 — how much to water (the other three variables). Air temperature, air humidity, and light do not decide whether to water; they only scale the dose duration:

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% 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"
TOPICO_COMANDO = "rega/comando"
TOPICO_DECISAO = "rega/decisao"
ZONA_SENSOR_ID = 1

LIMIAR_SECO    = 30
LIMIAR_ALVO    = 55
INTENSIDADE    = 80
DURACAO_BASE_S = 20
DURACAO_MAX_S  = 120
K_DEFICIT      = 1.5
COOLDOWN_S     = 30 * 60

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)
    if humidade_ar is not None and humidade_ar < 50:
        f += min((50 - humidade_ar) / 100.0, 0.3)
    if luz is not None and luz > 20000:
        f += min((luz - 20000) / 80000.0, 0.2)
    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:
            comando = Comando("nenhuma", motivo="em cooldown")
        else:
            ultima_rega[ZONA_SENSOR_ID] = time.time()

    if comando.acao == "regar":
        publicar_comando(client, comando)
    publicar_decisao(client, ZONA_SENSOR_ID, comando, features)

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)
except (AttributeError, TypeError):
    cliente = mqtt.Client()
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

Deployment status: the script and the unit file above are written and ready, but the service is deliberately not yet enabled in the running deployment. Closed-loop watering is held back until two prerequisites are met: the pump node must be permanently mounted at the greenhouse with a reliable radio link (Section 3.5 — pending the external-antenna board), and the thresholds and dose-to-volume relationship must be calibrated in place (Section 2.2, design note). Until then, watering is exercised manually through the conversational assistant's estufa-rega skill (Conversational Assistant chapter, Section 5), which speaks the same rega/comando protocol — so the actuator side is validated end-to-end before the automatic decision-maker is switched on.


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;
  int desvio = map(intensidade, 0, 100, 0, 90);
  return SENTIDO_DESCENDENTE ? (ANGULO_PARADO - desvio)
                             : (ANGULO_PARADO + desvio);
}

3.2 Wiring and Power

The DFR0523 uses a 3-pin Gravity cable (signal / VCC / GND):

Pin Connects to Notes
Signal ESP32 GPIO 13 (PWM-capable) The servo-style control signal
VCC External 5 V supply (≥ 2.5 A) The motor draws ~5 W — 1.8 A continuous, 2.5 A peak per the manufacturer's specifications [20]; the ESP32's USB 5 V rail is not enough
GND Common ground Must be shared between the 5 V supply and the ESP32

Why an external supply: powering the pump from the microcontroller's USB rail would brown-out the board. The pump gets its own 5 V source, and the two grounds are tied together so the control signal has a common reference. Note the 2.5 A peak figure: an earlier draft of this chapter sized the supply at "≥ 1 A" from the ~5 W average draw alone, but the datasheet's stall/start-up peak is what the supply must actually survive — an undersized supply browns out exactly at the moment the motor starts. A second practical detail from bench testing: the PPM driver has a dead band around the stop position (pulse widths of roughly 1400–1600 µs), so requested intensities below ~10 % do not move the motor at all; the conversational skill therefore clamps intensity to a minimum of 20 %, and the decision-maker's fixed 80 % sits comfortably above the band.

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>
#include <ESP32Servo.h>
#include <ArduinoJson.h>

#define PINO_BOMBA      13
#define ANGULO_PARADO   90
#define DURACAO_MAX_S   120

const char* TOPICO_COMANDO = "rega/comando";
const char* TOPICO_ESTADO  = "rega/estado";

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);
  bomba.write(intensidadeParaAngulo(intensidade));
  fimBombeamentoMs = millis() + (unsigned long)duracaoS * 1000UL;
  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;
  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();

  if (WiFi.status() != WL_CONNECTED || !client.connected()) {
    if (aBombear) pararBomba("failsafe_sem_ligacao");

  }

  if (aBombear && (long)(millis() - fimBombeamentoMs) >= 0) {
    pararBomba("timeout");
  }
}

3.4 Safety / Failsafes

Because this node moves water, every failure mode leaves the pump off:

Situation Safeguard
No stop command ever arrives (Pi crash, network down) Hard run-time cap — the dose carries a duration and the pump auto-stops; the firmware also clamps any request to DURACAO_MAX_S (120 s)
Wi-Fi/MQTT link drops while pumping Failsafe stop — the loop stops the pump immediately
Boot / reconnect The pump is forced to stopped as its initial state
Duplicated command (MQTT QoS 1 redelivery) A repeated regar only refreshes the timer — it does not stack, so it cannot double-dose
Node dies unexpectedly An MQTT Last Will publishes {"estado":"offline"} so the rest of the system knows

3.5 Bench Validation and a Connection-Stability Investigation

With the firmware flashed, the node was validated end-to-end against the live broker: ESP32_Bomba associated, authenticated with its Mosquitto credentials, and answered a parar published on rega/comando with {"estado":"parado","motivo":"comando"} on rega/estado; killing the node confirmed the Last Will ({"estado":"offline"}). But the connection would not stay up: the broker expelled the node roughly 36 seconds after each connect, and it reconnected about 25 seconds later — a flapping cycle that left it offline around 40 % of the time. Because the client connects with a clean session, any command published during an offline window is simply lost, which is unacceptable for an actuator that "must always be reachable" (Section 1). The investigation that followed found two genuine firmware bugs and, beneath them, an environmental root cause — and is documented here because each layer produced a fix worth keeping.

Layer 1 — keepalive starvation. The broker log showed the node being disconnected for "exceeded timeout": the arduino-mqtt library defaults to a 10-second keepalive, and the firmware's loop() ran in a tight spin with no yield, so the client never got around to sending its PINGREQ in time. The fix sets an explicit, more forgiving keepalive and yields the CPU each iteration:

client.setOptions(60, true, 1000);
...
void loop() {
  client.loop();
  delay(10);
  ...
}

together with an explicit WiFi.mode(WIFI_STA) and a periodic heartbeat publish on rega/estado, which both exercises the uplink and gives the rest of the system a liveness signal. After this flash the broker stopped logging timeouts — but the flapping continued.

Layer 2 — Wi-Fi modem sleep. The node now held its session, yet it only responded to commands in the first seconds after each reconnection; outside that window it answered neither MQTT publishes nor even ICMP ping. This is the signature of the ESP32's default modem-sleep mode, in which the radio powers down between beacon intervals and downlink traffic effectively never reaches the node. The fix is WiFi.setSleep(false) — applied after association, because applying it before WiFi.begin() broke association entirely on the sensor node (firmware chapter, Section 9, "Lesson learned"). The same flash shortened the heartbeat from 5 minutes to 20 seconds and raised the MQTT socket timeout from 1 s to 5 s to ride out slow round-trips. Ping now answered — but at the greenhouse, the flapping still continued.

Root cause — a marginal radio link, not software. Instrumenting the heartbeat with WiFi.RSSI() told the real story: at the greenhouse the node saw −67 to −80 dBm, and a continuous ping alongside the broker log showed ICMP and MQTT dying together — the entire Wi-Fi association was dropping for 30–40 seconds and re-forming ~25 seconds later. The decisive experiment was to move the node, unchanged, next to the router: RSSI improved to −42 to −55 dBm, the connection ran for over six minutes without a single drop, and a parar issued late in that window was answered in under a second. The firmware is sound; the link budget at the greenhouse is not.

This also explains an apparent paradox: the sensor node lives in the same spot and works. Its deep-sleep duty cycle means every transmission is a fresh, independent association attempt lasting a few seconds — a burst that tolerates a marginal link, with the Section 9 retry logic absorbing the cycles that fail. An actuator's always-on TCP session, by contrast, must hold the association continuously, and a link that collapses every minute or two makes that impossible no matter what the software does.

Remedy. The pump node will move to a board with an ESP32-WROOM-32U module, which replaces the on-board PCB antenna with a U.FL connector for an external 2.4 GHz antenna (3–5 dBi on a pigtail, mountable outside the enclosure and oriented toward the router). The sketch transfers unchanged — same module family, same GPIO 13. Two software mitigations, applicable on any board after association, are also noted: locking the station to 802.11b (the longest-range modulation: esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_11B)) and raising the transmit power to the maximum (esp_wifi_set_max_tx_power(78)).

What the investigation validated: every one of the drops during the flapping period left the pump stopped — the failsafes of Section 3.4 fired exactly as designed, and the Last Will reported each loss. One behavioural footnote was also confirmed: the node publishes its "arranque" status on every MQTT reconnection, not only on power-up, so that message alone does not indicate a reboot.


4. Command and Status Topics

Topic Direction Payload
rega/comando decisor → pump (QoS 1) {"acao":"regar","intensidade":80,"duracao":102} or {"acao":"parar"}
rega/estado pump → system (QoS 1) `{"estado":"a_regar"
rega/decisao decisor → system A record of every decision (acted on or not), with the reason and the feature values, for logging on the dashboard

5. End-to-End Control Flow

  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.


6. Towards a Learned Decision — Local Dataset and a First Model

Section 2.3 isolated the whole decision inside decidir_rega(features) precisely so that the hand-written rule could later be replaced by a learned model. A learned model needs training data, and the data the system already stores in Firestore is awkward for that purpose: bulk reads count against the free-tier quota, require a network round-trip per page, and arrive as JSON documents rather than rows. Two scripts close that gap.

6.1 Local Logging — regista_sqlite.py

regista_sqlite.py is a third independent MQTT subscriber on the Raspberry Pi — alongside guarda_dados.py (cloud persistence) and monitor.py (liveness) — that writes everything relevant to irrigation into a single local SQLite file, ~/estufa.db. SQLite is the natural fit for a training corpus on the edge: the data never leaves the Pi, reads are free and fast, the file is trivially backed up by copying it, and pandas loads it natively with one SQL query.

The database mirrors the three MQTT topics of the irrigation loop:

Table Fed by Contents
leituras sensores/jardim Every sensor reading (soil moisture, air temperature/humidity, light, timestamps)
decisoes rega/decisao Every decision the decisor publishes — watered or not, with the reason and the feature values it saw
eventos_rega rega/estado Every pump state change (a_regar, parado, offline) with intensity and motive

Together these three tables are exactly a supervised-learning log: the features (leituras), the actions taken (decisoes), and the physical outcome (eventos_rega plus the subsequent soil-moisture trajectory in leituras).

Like guarda_dados.py, the logger must tolerate QoS 1 redelivery: a reading whose id was already seen within a 120-second window is dropped, the same time-bounded deduplication strategy adopted on the Firestore side (Raspberry Pi chapter, Section 5.6) and for the same reason — the RTC-backed counter on the ESP32 reuses ids after a power cycle, so the window must be bounded by time, not by id alone.

Deployment note: unlike the other two subscribers, the logger is not run as an always-on service. It is started on demand for data-collection campaigns, which keeps the set of permanent services on the Pi minimal and makes each recording window an explicit, deliberate act. Promoting it to a systemd unit is a one-file step if continuous logging later proves useful.

6.2 A First Model — treinar_rf.py

treinar_rf.py is the first concrete step from the rule of Section 2.1 toward the learned replacement of Section 2.3. Rather than learning the watering decision directly (which would require many labelled watering episodes), it learns something the data already contains in abundance: predicting soil moisture a configurable number of readings ahead from the current feature vector. A model that can anticipate "the soil will cross the dry threshold within the next hour" enables anticipatory watering — dosing before the deficit occurs, ideally in the cool part of the day — instead of the purely reactive rule.

The model is a Random Forest regressor (scikit-learn), chosen deliberately over a neural network for this first iteration:

The script reads ~/estufa.db with pandas, builds the lagged target (soil moisture N readings ahead), and splits train/test chronologically — never randomly, since shuffling a time series would leak future information into training and produce flatteringly meaningless scores. Its dependencies (scikit-learn, pandas) are the only Python packages in the whole system that exist purely for the learning pipeline.

Status: the logging and training pipeline is implemented and smoke-tested; what it awaits is time — a logged dataset spanning enough days, and enough real watering events, for the learned predictions to be trustworthy. When that bar is met, the prediction plugs into decidir_rega() as designed: the MQTT plumbing, the cooldown, the command protocol, and the pump firmware all remain untouched.

Conversational Assistant — Telegram Bot (OpenClaw + Local LLM)

1. Role in the System Architecture

The web dashboard (Firebase chapter) answers the question "what are the sensors reading right now?" through a visual interface that the user must open in a browser. The conversational assistant answers a broader class of questions — "is sensor 1 still alive?", "why did the bridge stop writing to Firestore?", "restart the monitor" — in natural language, from a chat application the user already has on their phone. It is a second consumer of the same MQTT data, sitting alongside the database bridge and the dashboard, and it adds two capabilities the dashboard does not have: on-demand diagnostics of the Raspberry Pi stack, and proactive alerts that reach the user without them having to look at anything.

The assistant is built on OpenClaw [22], a self-hosted gateway that connects a chat channel (here, Telegram) to a Large Language Model (LLM) and to a set of local skills — small, whitelisted scripts the model is allowed to run on the host. The user sends a message in Telegram; the LLM interprets the intent, decides whether a skill is needed, runs it on the Raspberry Pi, reads the result, and replies in plain language. Crucially, the model never touches the plant or the broker directly — it can only invoke the specific scripts exposed to it, each of which is read-only or requires explicit confirmation (Section 5).

flowchart LR
    subgraph Phone["User's Phone"]
        U["Telegram app"]
    end

    subgraph TG["Telegram Cloud"]
        API["Bot API<br/>@<your-bot>"]
    end

    subgraph Pi["Raspberry Pi"]
        GW["OpenClaw Gateway<br/>(user systemd service<br/>127.0.0.1:18789)"]
        LLM["LLM provider<br/>Gemini / Ollama"]
        subgraph Skills["Whitelisted skills (exec)"]
            ST["estufa-status"]
            DG["estufa-diag"]
            RS["estufa-restart"]
            RG["estufa-rega"]
        end
        STATE[("state.json<br/>live monitor")]
        MON["estufa-monitor.service<br/>monitor.py (MQTT)"]
        BR["Mosquitto<br/>sensores/jardim"]
    end

    U <-->|"DM"| API
    API <-->|"long-poll (getUpdates)"| GW
    GW <-->|"prompt + tool calls"| LLM
    GW --> ST
    GW --> DG
    GW --> RS
    GW --> RG
    ST -->|"read"| STATE
    MON -->|"write"| STATE
    BR -->|"subscribe"| MON
    MON -.->|"proactive alert"| API

    style U fill:#faece7,stroke:#993c1d,color:#4a1b0c
    style API fill:#e6f1fb,stroke:#185fa5,color:#042c53
    style GW fill:#faeeda,stroke:#854f0b,color:#412402
    style LLM fill:#eeedfe,stroke:#534ab7,color:#26215c
    style Skills fill:#e1f5ee,stroke:#0f6e56,color:#04342c
    style STATE fill:#eeedfe,stroke:#534ab7,color:#26215c
    style MON fill:#e6f1fb,stroke:#185fa5,color:#042c53
    style BR fill:#faeeda,stroke:#854f0b,color:#412402

Like every other field-facing and cloud-facing piece of this system, the assistant runs entirely on the Raspberry Pi — only the LLM inference can optionally be delegated to a cloud provider (Section 3). The same edge-computing argument made for the database bridge (Raspberry Pi chapter, Section 1.1) applies here: the bot keeps working, including local-model inference and all diagnostics, even when the internet connection drops.


2. OpenClaw Gateway

OpenClaw is installed as a global npm package and run as a gateway — a long-lived process that owns the connection to the chat channel and orchestrates the LLM and the skills. Its entire behaviour is described by a single JSON configuration file, ~/.openclaw/openclaw.json, whose top-level sections map directly onto the subsystems described below: models.providers (the LLM back-ends), agents.defaults (which model and which skills the agent uses), channels.telegram (the chat front-end), and gateway (how the process is bound and authenticated).

npm install -g openclaw

The gateway is bound to the loopback interface only:

"gateway": {
  "mode": "local",
  "bind": "loopback",
  "auth": { "mode": "token", "token": "<gateway-token>" }
}

bind: loopback means the gateway listens on 127.0.0.1:18789 and is unreachable from the local network, let alone the internet. The only path from the outside world to the bot is through Telegram's servers and the allowlist (Section 4) — there is no open port on the Raspberry Pi for an attacker on the LAN to probe. Management commands issued on the Pi itself (openclaw cron list, openclaw devices list, etc.) authenticate to this same loopback endpoint with the token above and a per-device scope (operator.read, operator.write, operator.admin); privileged operations require an explicit scope upgrade, so a read-only command cannot silently perform a destructive one.


3. Language Model Providers

The agent is provider-agnostic: any number of LLM back-ends can be declared under models.providers, and the agent's default model is chosen by a provider/model string. Two providers are configured:

"models": {
  "providers": {
    "ollama": {
      "api": "ollama",
      "baseUrl": "http://127.0.0.1:11434",
      "models": [{ "id": "qwen2.5:3b", "name": "Qwen2.5 3B" }]
    },
    "google": {
      "api": "google-generative-ai",
      "baseUrl": "https://generativelanguage.googleapis.com/v1beta",
      "apiKey": { "source": "env", "id": "GEMINI_API_KEY" },
      "models": [{ "id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash" }]
    }
  }
}
Provider Model Runs where Why it is included
Ollama [24] qwen2.5:3b [25] Locally, on the Raspberry Pi (127.0.0.1:11434) A 3-billion-parameter model small enough to run on the Pi 5's CPU. Needs no internet, no API key, and has no usage quota — every request is free and private. Acts as the offline/fallback brain.
Google Gemini [26] gemini-2.5-flash Google's cloud, over HTTPS A far more capable model for fluent, reliable natural-language replies and multi-step reasoning over the skill outputs. Requires an API key (GEMINI_API_KEY) and is subject to the free tier's rate limits.

The agent defaults are kept deliberately minimal:

"agents": {
  "defaults": {
    "model": "google/gemini-2.5-flash",
    "thinkingDefault": "off",
    "skills": ["estufa-status", "estufa-rega", "estufa-diag", "estufa-restart"]
  }
}

thinkingDefault: "off" disables extended chain-of-thought reasoning by default — for the short, tool-driven exchanges this bot handles (read a sensor, report it), the extra latency and token cost of a reasoning pass are not justified.

Subsequent upgrade: The assistant was first brought up with ollama/qwen2.5:3b as the default model, so that the entire system — sensors, broker, bridge, and bot — could run end-to-end with no external dependency and no API key. Once the conversational quality of the local 3B model proved to be the limiting factor (it occasionally mis-routed a request to the wrong skill, or produced stilted replies), the default was switched to google/gemini-2.5-flash for noticeably better intent recognition and fluency. Ollama was retained as a configured provider, not removed: it remains the natural fallback when the Gemini quota is exhausted or the internet is down, and selecting it again is a one-line change to agents.defaults.model. This mirrors the broader design principle of the system — prefer local processing, reach for the cloud only where it adds clear value.

Design note — quota as a shared resource: Because the free tier of gemini-2.5-flash caps the number of requests per day, the Gemini quota behaves like a shared, exhaustible resource across everything the bot does. An early experiment scheduled a recurring agent job to "check sensor 1 every five minutes"; running an LLM turn 288 times a day silently consumed the daily quota and caused the interactive bot to start returning 429 RESOURCE_EXHAUSTED errors to the user. The job was removed, and periodic monitoring was moved entirely to the event-driven live monitor (Section 6), which needs no LLM at all. The lesson generalises: scheduled LLM polling is the wrong tool for liveness checks — an event-driven listener is cheaper, faster, and does not compete with the user for a metered resource.


4. Telegram Channel

Telegram was chosen as the chat front-end because it offers a free, well-documented Bot API [23], cross-platform clients the user already runs, and a trivial provisioning flow. The bot identity is created by messaging Telegram's @BotFather, which issues a bot token — the credential the gateway uses to send and receive messages on the bot's behalf.

The channel is declared in the configuration with a strict access policy:

"channels": {
  "telegram": {
    "enabled": true,
    "botToken": { "source": "env", "id": "TELEGRAM_BOT_TOKEN" },
    "dmPolicy": "allowlist",
    "allowFrom": ["<your-chat-id>"]
  }
}

dmPolicy: "allowlist" with a single entry in allowFrom means the bot only answers one specific Telegram account — the owner's. Any message from any other user is ignored. This is essential: the bot can read sensor data and, with confirmation, restart services on the Raspberry Pi, so it must never be operable by a stranger who happens to find its @username.

Note that neither the bot token nor the Gemini API key appears in openclaw.json. Both are declared as secret references ({ "source": "env", "id": "..." }) and resolved at runtime from an environment file, ~/.openclaw/secrets.env, which is loaded by the systemd unit (Section 8) and never committed to version control:

TELEGRAM_BOT_TOKEN=<token-from-BotFather>
GEMINI_API_KEY=<key-from-Google-AI-Studio>
TELEGRAM_CHAT_ID=<your-chat-id>

Security note: A bot token grants full control of the bot. If it is ever exposed (for example, pasted into a chat or a log), it must be revoked immediately with @BotFather's /revoke command and the new token swapped into secrets.env, followed by a restart of the gateway and the monitor. Treat it with the same care as the Firebase service-account key (Raspberry Pi chapter, Section 5).


5. Skills — Giving the Model Safe, Bounded Actions

An LLM on its own can only produce text. To let it act — read a real sensor, inspect a real service — OpenClaw uses skills. A skill is a directory under ~/.openclaw/skills/<name>/ containing a SKILL.md file: Markdown documentation with a YAML front-matter header. The header's description is read by the model to decide when the skill applies; the body tells it how to run the skill and, just as importantly, what it is forbidden to do. The allowed-tools: exec declaration is what permits the skill to run a shell command on the host — without it, the skill is documentation only.

---
name: estufa-status
description: Check irrigation sensors — latest soil reading and whether each
  sensor is still reporting. Use when asked how a sensor is doing...
allowed-tools: exec
---
# Estufa status
When the user asks about sensor status, readings, or whether sensors are online:
1. Run with `exec`: `python3 ~/.openclaw/skills/estufa-status/status.py`
2. Report the output as-is, in plain language.
## Rules
- Read-only. Never water or send any command from this skill.
- Only report what the script returns; never invent readings.

This pattern keeps the dangerous part — the actual command — out of the model's hands. The model does not compose arbitrary shell; it chooses a skill, and the skill runs a fixed, version-controlled Python script. Four skills are active:

Skill Action Safety model Underlying script
estufa-status Report the latest reading and liveness of every sensor Read-only. Reads state.json; never queries the broker, never invents values. status.py
estufa-diag Health check of the whole stack: service states, CPU/temperature/memory/disk, MQTT broker, monitor freshness, recent logs Read-only. Explicitly forbidden from restarting, watering, publishing, or editing. If a fix is needed, it must describe it and ask. diag.py
estufa-restart Restart one whitelisted service on demand Mutating, but gated. Only the services estufa-monitor, guarda_dados, estufa, openclaw-gateway; always confirms before acting; refuses anything off the whitelist; never restarts on its own initiative. restart.py
estufa-rega Water on demand for a bounded time, or stop the pump immediately Mutating, gated and clamped. Always confirms before watering; duration capped at 120 s and intensity clamped to 20–100 % in the script, mirroring the firmware's own caps; the one exception is parar, which runs without confirmation — a stop must never wait for a yes. water.py

The design philosophy across all four is least privilege with honesty. Each SKILL.md instructs the model never to invent data ("only report what the script actually returns"), to surface problems plainly (flag a sensor as [SEM RESPOSTA] rather than guess), and to refuse rather than improvise when asked for something outside the skill's remit. Mutating actions are isolated in two skills (estufa-restart and estufa-rega), each of which requires an explicit "sim" before acting and is hard-bounded inside the script itself — a fixed service whitelist in one, the 120-second/20–100 % clamps in the other — so the worst the model can do, even if it misinterprets a request, is restart a known service or run one bounded watering dose after the user confirms.

The estufa-status script illustrates the read-only contract. It does not connect to the MQTT broker; it simply reads the JSON state file maintained by the live monitor (Section 6) and formats it:

with open(STATE_FILE) as f:
    state = json.load(f)
for sid in sorted(state.get("sensors", {})):
    s = state["sensors"][sid]
    age_min = s.get("age_seconds", 0) / 60
    state_txt = "OK" if s.get("online") else "SEM RESPOSTA"
    print(f"- Sensor {sid}: humidade solo {s.get('moisture')}%, "
          f"temp ar {s.get('temp')}C, humidade ar {s.get('humidity')}%, "
          f"luz {s.get('lux')}; ultima msg ha {age_min:.0f} min [{state_txt}]")

Subsequent upgrade — the watering skill, from placeholder to real protocol: the first version of estufa-rega published a provisional "pulse" request on a topic nothing consumed — a stub registered in the agent defaults so the routing could be exercised before any actuator existed. When the pump-actuator firmware was finalised (Irrigation Control chapter, Section 3), water.py was rewritten to speak the actuator's real protocol: it publishes {"acao":"regar","intensidade":N,"duracao":S} on rega/comando at QoS 1, accepts a duration and an optional intensity (water.py DURACAO_S [INTENSIDADE]), and supports water.py parar for an immediate stop. The script's own clamps were set to exactly mirror the firmware's (120 s maximum, intensity 20–100 % — the lower bound because the pump's PPM driver has a dead band below ~10 %), so the two ends of the link can never disagree about what a legal command is. The path was then validated end-to-end against the live pump node: a watering requested in Telegram arrives at ESP32_Bomba, runs the pump, and auto-stops. Routine use at the greenhouse awaits the node's permanent mounting (Irrigation Control chapter, Section 3.5 — the external-antenna board), since a watering command must not be lost to a flapping radio link.


6. Proactive Monitoring — The Live Monitor

The estufa-status skill answers questions on demand, but the user should also be told without asking when a sensor goes silent. That job belongs to a separate, deliberately LLM-free component: a live monitor, ~/.openclaw/monitor/monitor.py, run as a system service (estufa-monitor.service).

The monitor subscribes to the same MQTT topic that the database bridge ingests (sensores/jardim). Because MQTT fans a message out to every subscriber independently, listening here does not steal messages from guarda_dados.py — both receive every reading. For each message the monitor:

  1. Auto-discovers sensors as they first appear (no hard-coded sensor list), and tracks the last-seen time and latest reading of each;
  2. Writes state.json atomically — the small state file the estufa-status skill reads. This decouples the bot from the broker: the skill reads a file in microseconds instead of opening an MQTT session on every question;
  3. Sends a Telegram alert when a sensor falls silent for longer than STALE_SECONDS (15 minutes in this deployment, set via the ESTUFA_STALE_SECONDS environment variable), and another when it recovers — reusing the bot's own token to message the owner directly.

The threshold is deliberately several reporting cycles long: the ESP32 publishes roughly every 5 minutes (firmware chapter, Section 5.3), so a 15-minute window tolerates two or three consecutive dropped readings before raising an alert, which suppresses false alarms from isolated lost messages while still flagging a genuinely dead sensor promptly.

age = now - s["last_seen"]
if age > STALE_SECONDS and s["online"]:
    s["online"] = False
if not s["online"] and not s.get("alerted"):
    s["alerted"] = True
    telegram(f"⚠️ Sensor {sid} sem resposta ha {int(age // 60)} min.")

This is the event-driven design the quota incident in Section 3 pointed to: the monitor reacts to the arrival (or non-arrival) of readings using a lightweight MQTT subscription and a periodic in-memory sweep, with no LLM involved. It costs effectively nothing per reading, runs offline, and never competes with the interactive bot for the Gemini quota. The LLM is reserved for what it is uniquely good at — turning a question in natural language into the right skill call and the result back into a readable answer — while routine liveness watching is left to a plain Python loop.

Note on retained messages: Because the monitor (like any subscriber) receives MQTT retained messages, a stale retained payload left on the topic by old firmware can poison a freshly (re)started monitor, making it report an obsolete reading as if it were current. This is one of the reasons the ESP32 firmware was changed to publish with retained = false (Raspberry Pi chapter, Section 2.2); a lingering retained message can be cleared once with mosquitto_pub -t sensores/jardim -r -n.

Known limitation — state is held in memory: The monitor tracks each sensor's last-seen time in an in-memory dictionary that is not persisted across restarts (the state.json file is written for the skill to read, but is not read back when the service starts). A sensor only enters the watch list once it has published at least one reading. The practical consequence is a blind spot: if a sensor is already silent and the monitor is then restarted (a reboot, an update, a service restart), the monitor comes up with no memory of that sensor and will not alert on it, because — as far as the fresh process knows — it never existed. A sensor that fails while the monitor is running is detected normally; only a failure that straddles a restart is missed. The fix is small — reload the last known sensors from state.json on startup so the watch list survives restarts — and is noted here as future work. A complementary safeguard would be an external heartbeat (e.g., a cloud uptime check) to watch the Raspberry Pi itself, since nothing on the Pi can alert the user if the Pi, the broker, or the monitor is the thing that is down.


7. Security and Hardening

The bot is the one component of this system that is both reachable from outside the LAN (via Telegram's servers) and capable of acting on the Raspberry Pi. Its security therefore rests on defence in depth rather than any single control:

Layer Control Effect
Network Gateway bound to loopback (Section 2) No open port on the LAN or internet; the only ingress is through Telegram + the allowlist.
Identity dmPolicy: allowlist, single allowFrom (Section 4) The bot answers exactly one Telegram account; all other senders are ignored.
Secrets Tokens/keys as env-resolved secret references, in secrets.env (chmod 600) Credentials never live in the config file or in version control; rotation is a one-file change.
Capabilities Read-only skills; mutations isolated in estufa-restart with confirmation + service whitelist (Section 5) The model cannot run arbitrary shell; the worst case is a confirmed restart of a known service.
External tools tools.web.* all set to false Web fetch and web search are disabled, so the model cannot be steered into fetching or exfiltrating arbitrary URLs.
Privilege Per-device scopes on the gateway (read/write/admin) Local management commands run with the minimum scope; destructive operations require an explicit scope upgrade.
"tools": {
  "web": {
    "fetch":  { "enabled": false },
    "search": { "enabled": false },
    "x_search": { "enabled": false }
  }
}

Disabling the web tools is a deliberate prompt-injection countermeasure as much as a feature decision: with no ability to fetch external content, there is no channel through which a malicious web page could feed instructions into the model's context. The bot's entire world is the chat with its single owner and the handful of read-mostly skills on the Pi.


8. Deployment as a systemd (user) Service

Unlike the database bridge — a system service running as root/pi (Raspberry Pi chapter, Section 8) — the gateway runs as a user service under the owner's own account. This keeps the bot, its configuration, its secrets, and its skills entirely within the user's home directory (~/.openclaw), with no elevated privileges.

The unit lives at ~/.config/systemd/user/openclaw-gateway.service:

[Unit]
Description=OpenClaw Gateway
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/node /home/<user>/.npm-global/lib/node_modules/openclaw/dist/index.js gateway --port 18789
Restart=always
RestartSec=5
Environment=HOME=/home/<user>
Environment=OPENCLAW_GATEWAY_PORT=18789

[Install]
WantedBy=default.target

The secrets are injected by a drop-in override rather than being written into the unit itself, so the credential file is referenced in exactly one place:

[Service]
EnvironmentFile=/home/<user>/.openclaw/secrets.env

A user service stops when the user logs out, which is unacceptable for a headless device. Two settings make it behave like a system service: linger keeps the user's services running with no active session, and WantedBy=default.target starts it on boot.

sudo loginctl enable-linger <user>

systemctl --user daemon-reload
systemctl --user enable  openclaw-gateway
systemctl --user start   openclaw-gateway
systemctl --user status  openclaw-gateway

Because the gateway is the bot, restarting it briefly drops any in-flight conversation; the reply to a message that triggered the restart only arrives once the gateway is back up. After editing skills or the configuration, the gateway must be restarted to pick up the changes:

systemctl --user restart openclaw-gateway

The live monitor of Section 6 is deployed as an ordinary system service (estufa-monitor.service), started after mosquitto.service so the broker is available before it subscribes, with Restart=always for the same unattended-operation reasons as the database bridge.


9. End-to-End Conversation Flow

The following diagram traces a single request — "how is sensor 1?" — from the user's phone to the reply, showing where the LLM and the skill each enter the picture.

sequenceDiagram
    participant U as User (Telegram)
    participant API as Telegram Bot API
    participant GW as OpenClaw Gateway
    participant M as LLM (Gemini / Ollama)
    participant SK as estufa-status (status.py)
    participant FS as state.json

    U->>API: "como esta o sensor 1?"
    API->>GW: deliver message (allowlist OK)
    GW->>M: prompt + available skills
    M-->>GW: invoke estufa-status
    GW->>SK: exec status.py
    SK->>FS: read latest state
    FS-->>SK: sensor 1 readings + age
    SK-->>GW: formatted text output
    GW->>M: skill result
    M-->>GW: natural-language answer
    GW->>API: send reply
    API->>U: "O sensor 1 esta OK: humidade do solo 38%, ..."

The model is consulted twice per turn: once to route the request to a skill, and once to phrase the skill's raw output as a human answer. Everything between those two steps — reading the actual sensor state — is deterministic Python on the Raspberry Pi. This separation is what makes the assistant trustworthy: the natural-language layer is flexible and forgiving, but the data it reports always comes from a fixed, auditable script, never from the model's imagination.



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.
LLM (Large Language Model) A neural network trained on large amounts of text that can interpret natural language and generate human-like responses. In this system, an LLM powers the Telegram assistant, deciding which local skill to run and phrasing the result as a readable answer.
Inference The process of running a trained model to produce an output from a given input. Local inference (e.g., Ollama on the Raspberry Pi) runs the model on the device; cloud inference (e.g., Gemini) runs it on a remote server.
Token (LLM) The unit of text an LLM processes — roughly a word fragment. Usage limits and costs of cloud LLM providers are typically measured in tokens or in requests per day.
Skill (assistant) A small, whitelisted script that an LLM-based assistant is permitted to run on the host. Each skill is documented in a SKILL.md file that tells the model when to use it and what it must not do, keeping arbitrary commands out of the model's reach.
Allowlist A security policy that permits only explicitly listed entities and denies everything else. The Telegram bot uses an allowlist of one chat ID, so it answers only its owner.
Loopback interface The virtual network interface (127.0.0.1) that a machine uses to talk to itself. A service bound to loopback is reachable only from the same machine, never from the network.
Prompt injection An attack where malicious instructions are smuggled into the text an LLM reads (e.g., from a fetched web page), attempting to override its intended behaviour. Disabling the assistant's web tools removes this channel.
Quota / rate limit A cap imposed by a cloud provider on how many requests may be made in a given period. Exceeding the free-tier quota of the Gemini API returns a 429 RESOURCE_EXHAUSTED error until the quota resets.
Linger (systemd) A setting that allows a user's systemd services to keep running even when that user is not logged in — required to run a user service on a headless device across reboots.
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
LLM Large Language Model
DM Direct Message
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.

Appendix D — Full Source Code

The complete, copy-able source for every script is on the source-code page.

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
[22] OpenClaw, "OpenClaw — Self-hosted gateway for chat-driven agents," Documentation. Available: https://docs.openclaw.ai/
[23] Telegram, "Telegram Bot API." Available: https://core.telegram.org/bots/api
[24] Ollama, "Ollama — Run large language models locally." Available: https://ollama.com/
[25] Qwen Team, Alibaba Cloud, "Qwen2.5 — Technical Report," 2024. Available: https://qwenlm.github.io/blog/qwen2.5/
[26] Google, "Gemini API Documentation — Models and Rate Limits." Available: https://ai.google.dev/gemini-api/docs