# Greenhouse irrigation: on a real device

**An irrigation controller with a HEART on its own ESP32-S3: the parts, the wiring, the code, how to build and install it, the factory steps, and what the grower sees.**

> **For illustration only.** The hardware below is an assumption, chosen so the steps can be concrete. Your board, pins, probes, valves and pump will differ. This product has not been built. The ESP32 Full starter's core is tested on a computer, but its ESP32 port has **not yet been compiled or run on a chip**. Nothing here is advice on electrical safety in wet places or on water-supply rules. Check every step on your own bench, and have an electrician wire the pump.

| | |
|---|---|
| **Placement** | A: inside the product. The HEART runs on the controller's ESP32-S3, in the same firmware as the schedule, in its own low-priority task. |
| **Start from** | The [ESP32 Full starter](/heart-templates/templates/esp32-full/README.md) (`heart/embeddedstandard/esp32-full/v1.2611.3`) |
| **Claim** | HEART Basic, Autonomy Level A1. HEART watches and tells; it changes nothing. The brain's Trials need HEART Full (see Section 9). |
| **Try it first** | [The simulator](/greenhouseSimulator/README.md) and the [design case study](/guides/Greenhouse-Irrigation.md) |

---

## 1. The device we assume

| Part | Assumed choice | Notes |
|---|---|---|
| Board | An ESP32-S3 module with an external antenna socket, such as the ESP32-S3-WROOM-1U, on the controller's own board | Glass, metal frames and wet plants weaken Wi-Fi: an external antenna helps. The S3 has spare ADC1 pins. |
| Soil moisture | Three capacitive soil-moisture probes (the common "v1.2" kind, analogue out), one per zone, each with a 1 MΩ pull-down at the input | Capacitive probes do not corrode like resistive ones. The pull-down makes an unplugged probe read 0 V |
| Tank level | A 0.5 to 4.5 V hydrostatic level sensor at the bottom of the tank, through a 2:1 divider | Gives the tank in per cent |
| Tank cut-off | A float switch with two contacts, near the bottom of the tank | One contact is in series with the pump contactor's coil, so the pump **cannot** run dry whatever any software does. The other goes to the ESP32 |
| Pump | A mains pump with its own thermal protector, switched by a contactor | Wired by an electrician, behind an RCD |
| Pump temperature | A waterproof DS18B20 probe on the pump body | Read by the firmware on 1-Wire |
| Valves | Three 24 V AC normally-closed solenoid valves, one per zone, switched by relays (or triacs with opto-isolators) | Normally closed: with no power, no water flows |
| Flow | A hall-effect flow meter on the main line, pulses counted by the firmware | Shows whether water really flows when a valve is open |
| Air | An SHT40 temperature and humidity sensor on I²C | |
| Door | A reed switch on the greenhouse door | Private: it shows when people are there |
| Button | A sealed push button on the controller's box, wired to ground | Held 3 seconds it is Stop. A short press approves a waiting action, such as restoring the brain |
| Status light and buzzer | A green LED and a small buzzer on the box | Tell a person about a fault with no internet |
| Power | A 24 V AC transformer for the valves, and a rectifier and 5 V buck converter for the controller | See Section 8 for its size |
| Network | The greenhouse Wi-Fi, 2.4 GHz | Reports to HEART Hub, outbound only |

### Example pin map

| Signal | Pin | In `signals.json` |
|---|---|---|
| Soil, zone 1 | GPIO1, ADC1 channel 0 | `"source": {"protocol": "on-chip", "channel": 0, ...}` |
| Soil, zone 2 | GPIO2, ADC1 channel 1 | `"source": {"protocol": "on-chip", "channel": 1, ...}` |
| Soil, zone 3 | GPIO4, ADC1 channel 3 | `"source": {"protocol": "on-chip", "channel": 3, ...}` |
| Tank level (2:1 divider) | GPIO5, ADC1 channel 4 | `"source": {"protocol": "on-chip", "channel": 4, ...}` |
| Tank cut-off float, second contact | GPIO6, external 10 kΩ pull-up | `"source": {"protocol": "on-chip", "channel": 6}`, a boolean with `"safety": true` |
| Door reed switch | GPIO7, external 10 kΩ pull-up | `"source": {"protocol": "on-chip", "channel": 7}`, a boolean, `private` |
| I²C SDA, SCL (SHT40) | GPIO8, GPIO9 | Read by your firmware, not by HEART |
| 1-Wire (pump DS18B20) | GPIO10 | Read by your firmware |
| Valve relays, zones 1 to 3 | GPIO11, GPIO12, GPIO13 | Driven by your firmware only |
| Pump contactor relay | GPIO14 | Driven by your firmware only |
| Flow meter pulses | GPIO15 | Counted by your firmware (the PCNT unit) |
| Button | GPIO16 | `CONFIG_HEART_BUTTON_GPIO=16` |
| Status LED | GPIO17 | `CONFIG_HEART_LED_GPIO=17` |
| Buzzer | GPIO18 | Driven by your escalation code |

ADC channel 2 (GPIO3) is skipped because GPIO3 is a strapping pin on the S3. On the S3, ADC1 channel *n* is GPIO *n*+1. Check your own board's pin map.

**One owner for ADC1.** The starter's `configure_io()` claims ADC1 with `adc_oneshot_new_unit()`, and ESP-IDF lets only one handle own a unit. On this page HEART reads the probes and the tank, and the firmware does not need them: its schedule runs on minutes, and its interlock is the float switch. If your firmware also reads ADC1, have it read the pins and publish the values as `sym:` sources instead, or share one handle (a change to the port).

---

## 2. Where everything runs

```text
┌──────────────────────── ESP32-S3: one firmware image ────────────────────────┐
│ Your controller firmware (high priority)    HEART (from the ESP32 starter)   │
│  schedule: minutes per zone per day          heart_tend task (low priority):  │
│  valves, pump, tank interlock (float),       checks every 30 s, alerts,       │
│  60 min/day per zone, 80 °C pump cut-out     LED and buzzer                   │
│  flow count, pump and air temperature        HTTPS API on port 43278          │
│        ▲  shared variables (SYMBOLS[]): read only     HEART Hub reports ─────────► heartcortex.ai
│        └── HEART writes nothing here                                          │      (outbound only)
└───────────────────────────────────────────────────────────────────────────────┘
Float switch contact 1 ── in series with the pump contactor's coil (no software in the way)
NVS (encrypted): device ID, claim code, key · owner key · TLS certificate and key · last Stop
```

- **One image.** HEART's files are compiled into the controller's ESP-IDF project. The starter runs HEART as the task `heart_tend` at `tskIDLE_PRIORITY + 1`, so the watering task, at a higher priority, always runs first.
- **HEART changes nothing.** The case study allows one change, the watering minutes of a zone, and only through a Trial that a person approved. The ESP32 starter has no Trials, so on this controller every signal is read-only, and the grower sets the schedule on the panel as before.
- **The interlocks are out of reach.** The float switch cuts the pump in hardware, and the firmware keeps its own limits: the tank interlock, 60 minutes a day per zone, and the pump's 80 °C cut-out. HEART reads the float switch, marked `"safety": true`, and never writes it.

---

## 3. The code

Copy the starter, then merge your controller firmware into it, or copy the starter's `main/` files into your existing project.

| File | What you do |
|---|---|
| `main/port_esp32.c` | Change the lines marked `CHANGE` (below) |
| `signals.json` | Write it with `heart_signals.py`, then export `main/heart_signals.h` |
| `main/heart_core.c`, `heart_core.h`, `heart_ed25519.c` | Copy unchanged |
| `main/Kconfig.projbuild`, `sdkconfig.defaults` | Copy; set values in `idf.py menuconfig` |
| Your controller code | Add as more source files or a component in `main/`, started from `app_main()` |

**Start the watering first.** The starter's `app_main()` returns early if HEART refuses to start (for example, with a signal table nobody sealed). Start your watering task **before** `heart_init()`, so a HEART that does not start never stops the watering. If your firmware already starts Wi-Fi, keep one of the two Wi-Fi start-ups, not both. The starter's `start_network()` uses `ESP_ERROR_CHECK`, which restarts the chip on an error: in a product, handle those errors instead.

### 3.1 Identity

```c
#define SYSTEM_ID "com.example.irrigation.gh-controller"   /* CHANGE, with signals.json */
#define NAME      "Greenhouse Controller"                  /* CHANGE */
#define VERSION   "1.0.0"                                  /* CHANGE */
```

The system ID is the one in the [case study, Step 2](/guides/Greenhouse-Irrigation.md#step-2-give-the-system-an-identity). It names the product, never the grower or the site.

### 3.2 The values your firmware shares with HEART

```c
/* CHANGE: the firmware variables your signal table names as sym:... sources. The controller task writes them. */
volatile float pump_temp = 20, air_temp = 20, air_rh = 50, flow_lpm = 0, water_today_l = 0,
               minutes_z1 = 30, minutes_z2 = 30, minutes_z3 = 30, zones_no_flow = 0, pump_running = 0, wifi_up = 0;
static const struct { const char *name; volatile float *value; } SYMBOLS[] = {
    {"pump_temp", &pump_temp}, {"air_temp", &air_temp}, {"air_rh", &air_rh}, {"flow_lpm", &flow_lpm},
    {"water_today_l", &water_today_l}, {"minutes_z1", &minutes_z1}, {"minutes_z2", &minutes_z2},
    {"minutes_z3", &minutes_z3}, {"zones_no_flow", &zones_no_flow}, {"pump_running", &pump_running},
    {"wifi_up", &wifi_up}, {0, 0}};
```

The controller task updates these every second. `zones_no_flow` is worked out for HEART's sake: the number of zones whose valve has been open for 30 seconds with no flow on the meter. With one zone open at a time, that points at a stuck valve or a blocked line. This is an **assumption** of this page: the case study spots a stuck valve from the soil not getting wetter after two waterings, which a finished core could also do. The flow meter is quicker and more direct.

HEART reads all of these and writes none.

### 3.3 The signal table (`signals.json`, the important lines)

```json
{"name": "com.example.irrigation.gh-controller.soil_z1", "title": "Soil moisture, zone 1", "type": "number", "unit": "%",
 "range": {"min": 25, "max": 45}, "source": {"protocol": "on-chip", "channel": 0, "scale": -0.05823, "offset": 192.3},
 "privacy": "internal",
 "alerts": [{"when": "outside-range", "code": "system.main-function-degraded", "severity": "warning"},
            {"when": "stuck", "afterSec": 90, "code": "sensors.stuck", "severity": "error"}]},
{"name": "com.example.irrigation.gh-controller.tank_pct", "title": "Water in the tank", "type": "number", "unit": "%",
 "range": {"min": 20, "max": 110}, "source": {"protocol": "on-chip", "channel": 4, "scale": 0.03785, "offset": -12.5},
 "privacy": "internal", "alerts": [{"when": "outside-range", "code": "system.main-function-degraded", "severity": "warning"}]},
{"name": "com.example.irrigation.gh-controller.tank_cutoff", "title": "Tank below the pump cut-off",
 "description": "1 = below the float, or its wire is broken. The float also cuts the pump in hardware. HEART only reads it.",
 "type": "boolean", "source": {"protocol": "on-chip", "channel": 6}, "privacy": "public", "safety": true},
{"name": "com.example.irrigation.gh-controller.pump_temp", "title": "Pump temperature", "type": "number", "unit": "Cel",
 "range": {"min": -10, "max": 70}, "source": {"protocol": "on-chip", "symbol": "pump_temp"}, "privacy": "internal",
 "alerts": [{"when": "outside-range", "code": "performance.too-hot", "severity": "warning"}]},
{"name": "com.example.irrigation.gh-controller.zones_no_flow", "title": "Open zones with no water flowing", "type": "integer",
 "range": {"min": 0, "max": 0}, "source": {"protocol": "on-chip", "symbol": "zones_no_flow"}, "privacy": "internal",
 "alerts": [{"when": "outside-range", "code": "actuators.not-responding", "severity": "error"}]},
{"name": "com.example.irrigation.gh-controller.minutes_z1", "title": "Watering, zone 1", "type": "integer", "unit": "min",
 "range": {"min": 10, "max": 60}, "source": {"protocol": "on-chip", "symbol": "minutes_z1"}, "privacy": "internal"},
{"name": "com.example.irrigation.gh-controller.door_open", "title": "Door open", "type": "boolean",
 "source": {"protocol": "on-chip", "channel": 7}, "privacy": "private"}
```

Zones 2 and 3 repeat the soil and minutes lines, on channels 1 and 3. The table also lists `air_temp`, `air_rh`, `flow_lpm`, `water_today_l`, `pump_running` and `wifi_up`, all `internal`, as in the [case study, Step 3](/guides/Greenhouse-Irrigation.md#step-3-list-what-the-system-knows-and-how-private-each-item-is). No signal is `writable`. The grower's name, address and crop contracts are not on the controller at all.

The float contact closes to ground while there is water above it, so the pin reads 0. A low tank **or a broken wire** reads 1, which is the safe way round. The door reed switch is wired the same way: 1 is open.

The `scale` and `offset` turn the raw ADC reading into per cent. The starter reads 12 bits at 12 dB attenuation: 0 to 4095 for about 0 to 3.1 V, so one step is about 3.1 / 4095 = 0.757 mV.

- **Soil:** say a probe gives 2.5 V in dry air (raw 2.5 / 0.000757 = 3302) and 1.2 V in water (raw 1585). Then per cent = (3302 − raw) / (3302 − 1585) × 100 = raw × −0.05823 + 192.3. Check: raw 3302 gives 0 %, raw 1585 gives 100 %. These end points are **examples**: measure your own probes in dry air and in water. The result is a per cent of the probe's span, not the water content of the soil, so set the band of 35 to 45 % (and the 25 % floor) from your own crops, in the same units. An unplugged probe reads 0 V, which is 192 %, so it shows as out of range.
- **Tank:** the sensor gives 0.5 V empty and 4.5 V full, which the 2:1 divider makes 0.25 to 2.25 V, raw 330 to 2972. So per cent = (raw − 330) / (2972 − 330) × 100 = raw × 0.03785 − 12.5. Check: raw 2972 gives 100.0 %. The range goes up to 110 % so a small calibration error on a full tank is not a fault.

**Calibrate both on your bench**, with a multimeter, a dry probe, a glass of water and a measured tank, because every chip's ADC differs a little.

**What the starter raises.** The starter's core (`heart_core.c`) raises each range alert with the code and severity that the signal's line in the table names, and the summary names the signal's title ("Water in the tank reads outside its normal range..."). It does not check for stuck readings (the starter's Known gaps), and it checks each reading every 30 seconds, not the 5-minute mean the case study uses: a reading just after watering may briefly go over 45 %. `system.main-function-lost` from Section 3.4 comes from the product code. Sections 6 and 7 show what the starter raises, and the code a finished product raises where it differs.

A table can also name only one code for each kind of alert. The case study has two for the soil: too dry (below 25 %) is `system.main-function-degraded`, too wet (a 5-minute mean above 45 %) is `performance.overshoot`. The table above names the first; a finished core needs its own rule, or a second signal, for the second.

Then seal it. The HEART refuses to start with a table nobody reviewed, or one that makes a safety signal writable:

```sh
python3 heart_signals.py check signals.json
python3 heart_signals.py seal signals.json --by "Your Name"
python3 heart_signals.py export signals.json --out main/heart_signals.h
```

### 3.4 The main job, escalation and lifecycle

```c
/* CHANGE: return 1 while the product does its main job: every zone can get the water its schedule says. */
int heart_port_main_job_ok(const char **why) {
    if (pump_locked_out) { *why = "The tank is empty: the pump is locked out, nothing can be watered."; return 0; }
    return 1;
}

/* CHANGE: tell a person, with no internet: blink the LED and sound the buzzer on the controller's box. */
void heart_port_escalate(const char *summary) {
    ESP_LOGE(TAG, "escalate: %s", summary);
    fault_blink = 1; buzz_count = 3;
    grower_queue_message(summary);         /* your code: app or SMS, sent by another task; never wait here */
}

/* CHANGE: the controller runs the schedule, so it never sleeps; a restart waits until no valve is open. */
int heart_port_lifecycle_ready(const char *operation) {
    if (!strcmp(operation, "sleep")) return 0;
    return !any_valve_open && !pump_running;
}
```

- **The main job** is the case study's check. `pump_locked_out` is your firmware's own flag, set from the float switch. Less than that, a low tank or dry soil, is **degraded**, and comes from the signal table.
- **Escalation** is called while the core holds its lock, so it only sets flags and queues the message.
- **A restart** waits until watering has finished. If that does not happen within `timeoutSec` (default 60), the restart fails and is never forced. A sleep request always fails, by design: a sleeping controller would miss its watering.

---

## 4. Build and install

You need ESP-IDF 5.2 or later, and a USB-C cable.

```sh
idf.py set-target esp32s3
idf.py menuconfig
```

In `menuconfig`, under **HEART**:

| Setting | Value for development |
|---|---|
| Wi-Fi name and password (`HEART_WIFI_SSID`, `HEART_WIFI_PASSWORD`) | Your test network. **Products get these from the setup app, never from `sdkconfig`.** |
| Report health to a HEART Hub site (`HEART_MONITOR`) | On |
| HEART Hub site (`HEART_MONITOR_URL`) | `https://heartcortex.ai`, or your own monitor site |
| Approvals on the monitor site (`HEART_MONITOR_APPROVALS`) | Off. Nothing on this controller is writable |
| AI brain (`HEART_BRAIN`) | Off, unless you want alerts explained by a model (Section 9) |
| Button GPIO, LED GPIO (`HEART_BUTTON_GPIO`, `HEART_LED_GPIO`) | 16 and 17 |
| Reasoning Gateway URL (`HEART_GATEWAY_URL`) | Empty |

Make a development certificate, then build and flash:

```sh
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 825 \
  -keyout main/certs/prvtkey.pem -out main/certs/servercert.pem -subj "/CN=greenhouse.local"
idf.py build flash monitor
```

At first start the console shows the **owner key** once, and the HEART Hub lines:

```text
==== HEART Hub ====
Register this device at https://heartcortex.ai/register?id=a41f6e2c9b07d358
Claim code: GH7PK-2MXQR-9TCWB-4NVZD
=======================
```

Check it from a computer on the same network (`heart-` and the last three bytes of the controller's MAC address):

```sh
curl -k https://heart-5e9a03.local:43278/heart/v1/pulse
```

**Installing it.** Mount the box out of direct sun and spray, with its cable glands at the bottom. An electrician connects the pump and its contactor, behind an RCD. Fit the float switch so its cut-off is above the pump's intake.

---

## 5. On the production line

For each controller:

1. **Flash** the release firmware, with flash encryption and secure boot turned on, so the keys in NVS cannot be read off the chip.
2. **Write its own TLS certificate and key** to NVS (namespace `heart`, blobs `cert` and `key`). Never ship the development certificate.
3. **Start it once** on the test jig. It makes its device ID, claim code and key, and stores them in NVS.
4. **Read the device ID and claim code** from the jig's console, and **print the label** for inside the box's lid: the claim code, and a QR code of `https://heartcortex.ai/register?id=<ID>`. In `port_esp32.c`, replace the console line with your jig's label printer (the `CHANGE` at `heart_port_monitor_load`).
5. **Check the outputs fail safe** on the jig: with the ESP32 held in reset, every valve relay and the pump relay must be off.
6. **Run the bench tests** of Section 7 on a sample from each batch.

---

## 6. What the grower sees

1. **Setup.** The maker's app joins the controller to the greenhouse Wi-Fi, as before. The grower sets the schedule on the panel, as before.
2. **Registration.** The grower scans the QR code inside the lid, signs in at heartcortex.ai with an emailed code, and types the claim code. The page shows **Healthy**. It is private until the grower shares it. See the [owner's manual](../HEART-Monitor-Owner-Manual.md).
3. **Every day.** Nothing. The controller checks itself every 30 seconds.
4. **When something goes wrong:**

| Real event | The HEART notices (with the starter) | Code a finished product raises | The grower sees |
|---|---|---|---|
| The refill fails and the tank runs dry | Below the float: the pump is locked out; `system.main-function-lost`: "The tank is empty..." | The same | The LED blinks and the buzzer sounds; the page shows **Failing** |
| The tank is getting low | Below 20 %: `system.main-function-degraded` on "Water in the tank" | `system.main-function-degraded` | A warning, before watering stops |
| A hot spell dries a zone | Below 25 %: `system.main-function-degraded` on "Soil moisture, zone 2" | `system.main-function-degraded` | A warning: plants will wilt |
| A zone stays too wet | Above 45 %: `system.main-function-degraded`, the table's one code for that signal | `performance.overshoot` | A warning: roots can rot. The grower changes the minutes on the panel |
| A valve sticks, or a line is blocked | Open with no flow: `actuators.not-responding` (an error) on "Open zones with no water flowing" | `actuators.not-responding` | A warning naming the check |
| A probe is knocked out of its cable | Reads about 192 %: `system.main-function-degraded`, the soil signal's code | `sensors.out-of-range` | A warning on that zone's probe |
| A probe's reading freezes | Nothing yet: the starter does not check for stuck readings | `sensors.stuck` | Nothing, until the core can do this |
| The pump's bearing fails and it runs hot | Above 70 °C: `performance.too-hot` on "Pump temperature" | The same | A warning; at 80 °C the firmware's own cut-out stops the pump |
| The greenhouse Wi-Fi goes down | Nothing can be sent. Watering goes on as scheduled | `connectivity.link-lost` | After 90 seconds the page shows **Not heard from**, and one email |

5. **Changing the watering.** The grower changes the minutes on the controller's panel, as before. A request to change a signal through the HEART API is refused with `409 conflict` (the signal is read-only), as the case study asks. Only the public Pulse and public alerts (health and fault codes) leave the greenhouse for HEART Hub: never a soil reading, the tank level, the schedule or a door visit.

---

## 7. Test it on the bench

Build a small test rig: a tank, the pump, one or two valves over a tray, and pots of soil for the probes. Cause every fault for real, and check the right code appears within a minute, on the controller's API and on its HEART Hub page.

| Fault | How to cause it on the bench | Expect with the starter | Expect from a finished product |
|---|---|---|---|
| Tank empty | Drain the test tank below the float | The pump does not start, even when the firmware asks; `system.main-function-lost` | The same |
| Tank low | Drain to 15 % | `system.main-function-degraded` | The same |
| Soil dry | Lift a probe out of its pot into dry air | `system.main-function-degraded` | The same |
| Soil wet | Stand a probe in a glass of water | `system.main-function-degraded` | `performance.overshoot` |
| Probe unplugged | Pull the probe's plug while it runs | `system.main-function-degraded` | `sensors.out-of-range` |
| Probe stuck | Cover a probe's pot with film, and water another pot on its zone | Nothing | `sensors.stuck` |
| Valve stuck | Close the hand valve before zone 1's solenoid, or unplug the solenoid, then run zone 1 | `actuators.not-responding` on "Open zones with no water flowing" | The same |
| Pump hot | Put the pump's DS18B20 in water at 75 °C | `performance.too-hot` | The same |
| Wi-Fi lost | Switch off the access point for 2 minutes | **Not heard from** on the page, then **Healthy** again. Watering goes on | The same |
| Power cut | Switch off the supply while a zone is watering | Every valve closes and the pump stops; after power returns the schedule carries on | The same |
| HEART cannot start | Flash a build whose signal table is not sealed | HEART refuses to start. **Watering must work exactly as before** | The same |
| No direct changes | `PUT` a new value for `minutes_z1` with the owner key | `409 conflict` (the signal is read-only); nothing changes | The same |
| Stop | Hold the button for 3 seconds | `GET /heart/v1/tending/autonomy` shows `"max": 2` and `"mode": "observe"`. Watering goes on: Stop halts HEART's own actions, not the controller | The same |

Then run the [HEART-Rating](/heart-rating/README.md) client against the controller, and claim only what passed.

---

## 8. Water, power and safety: what to expect

- **HEART never sits between the pump and its protection.** The float switch cuts the pump's contactor in hardware, the firmware's interlock does it again, and the pump has its own thermal protector. HEART reads the float switch, marked `"safety": true`; the starter refuses a table that makes such a signal writable. The case study puts the interlock in the firmware; the hardware contact on this page is an addition, so a firmware fault cannot run the pump dry either.
- **Everything fails closed.** The valves are normally closed and the relays are off at reset. A power cut, a crash or a restart stops the water; it never leaves a zone running.
- **A task is not a process.** A HEART that is slow or stuck cannot delay the watering, because its task has the lower priority. But a HEART that crashes the chip restarts the whole controller. The valves close, and the schedule carries on after the restart. For a guarantee that HEART can never interrupt watering, use two chips, or a Linux controller where `heartd` is a separate process.
- **Power, roughly.** Typical 24 V AC irrigation valves draw about 0.4 A when they open and 0.25 A to stay open (check your valve's datasheet). One valve opening is about 24 × 0.4 ≈ 10 VA. The ESP32-S3 with Wi-Fi on draws roughly 0.4 to 0.8 W, and a few relay coils about 0.4 W each, so the controller needs about 3 W at most. One zone at a time: about 13 VA, so a 20 VA transformer leaves margin; allow 40 VA if two zones may open together (2 × 10 + 3 = 23 VA).
- **Water rules.** A tank fed from the mains needs backflow prevention, and pumps in wet places need the local electrical rules. HEART conformance says nothing about either.

---

## 9. Going further

- **Explanations from an AI model.** Turn on **AI brain** in menuconfig. The monitor site then acts as the controller's Command Center and holds the model keys, under a brain lease that the owner can cut at any time. The controller asks it to explain an alert, shows the answer as a suggestion only, and never acts on it. Only `public` and `internal` values are sent, so the door never reaches a model. See the [starter's Section 5b](/heart-templates/templates/esp32-full/README.md#5b-an-ai-brain-on-a-lease-brain-module).
- **The brain proposes, the HEART decides.** The simulator's Trials ("water 20 % less, 30 → 27 → 24 min/day, in two steps") need HEART Full: Consultations, Proposals, Trials with their own undo, the owner's envelope, and learned Playbooks that stay proposed until a person adopts them. The ESP32 starter has none of these yet. A Linux controller, or a small Linux computer beside this one, can run [`heartd`](/heart-core-rs/README.md): its `consult.rs` and `trial.rs` implement them. Watering is physical, so every Trial still waits for a press at the greenhouse, whatever the envelope says.
- **Approving from away.** Once a Linux HEART offers Trials, `remoteSitePhysical` lets the grower approve on the monitor site with an emailed code. It is off by default, needs a second opt-in at the controller, and proves only that the grower could read their inbox, not that anyone looked at the plants. See the [owner's manual, Section 9](../HEART-Monitor-Owner-Manual.md#9-approvals-a-device-asks-you-first).
- **The guide's own fault codes.** Until the core raises the codes named in the table, a maker can add checks for a low tank, dry or wet soil, stuck probes and the valve check to the core, keeping the standard codes of Part 3. Report what you change.
- **A commercial controller you cannot change.** Put a small computer beside it, read it over Modbus or RS-485, and run the [Python template](/heart-templates/README.md) or `heartd` there, configured as in its [configuration guide](/heart-core-rs/docs/CONFIGURATION.md).
