HEART Embedded Systems logoHEART

Smart thermostat: on a real device#

A thermostat with a HEART on the hub at the boiler, beside the heating-control firmware: the parts, the wiring, the code, how to build and install it, the factory steps, and what its owner sees.

For illustration only. The hardware below is an assumption, chosen so the steps can be concrete. Your board, pins, radio and boiler interface 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 gas or electrical safety, or a route to any appliance approval. Check every step on your own bench, and have a qualified heating installer connect anything to a boiler.

PlacementA: inside the product. The HEART runs on the hub's ESP32-S3, in the same firmware as the heating control, in its own low-priority task. The wall unit and the boiler have no HEART.
Start fromThe ESP32 Full starter (heart/embeddedstandard/esp32-full/v1.2611.3)
ClaimHEART Basic, Autonomy Level A1. Owner changes wait for the button. The mock AI's suggestions and any Playbooks need HEART Full (see Section 9).
Try it firstThe simulator and the design case study

1. The device we assume#

The product is three boxes, as in the case study, Step 1: a wall unit on AA cells, a hub at the boiler, and the boiler with its own controls.

PartAssumed choiceNotes
Hub boardAn ESP32-S3 module with 8 MB flash and 8 MB PSRAM, such as the ESP32-S3-WROOM-1Wi-Fi to the home, Bluetooth LE to the wall unit, and spare ADC1 pins. PSRAM gives room for Wi-Fi, Bluetooth and TLS at once.
Boiler interfaceAn isolated OpenTherm adapter board (several open designs exist, such as Ihor Melnyk's OpenTherm Adapter or the DIYLESS OpenTherm shield)For modulating boilers. The firmware is the OpenTherm master; it reads flame on, flow temperature and the fault flags
Boiler interface, on/off boilersA relay with volt-free contacts for the call for heat, and a DS18B20 clamped to the flow pipeThe flow pipe warming up shows that the burner fired
Boiler fault outputThe boiler's own volt-free fault relay, if it has one, through an optocouplerA safety signal: HEART reads it, never writes it
Hub temperatureA TMP36 analogue sensor inside the hubBoiler cupboards get warm
Hub supplyA certified, enclosed mains-to-5 V module; the 5 V rail through a 2:1 dividerMains wiring is done by the installer
Hub buttonA push button on the hub, wired to groundApproves changes, and held 3 seconds is Stop, even when the wall unit is out of reach
Hub lightA green LED on the hubBlinks a fault with no internet, and blinks when a change waits
Wall unitA Nordic nRF52840 module on two AA cells, with an SHT40 temperature sensor, a segment LCD or e-paper display, and a buttonRuns the maker's own firmware, not a HEART. It sends the room reading, its battery state and button presses to the hub over Bluetooth LE
NetworkThe home Wi-Fi, 2.4 GHzReports to HEART Hub, outbound only

Example pin map (the hub)#

SignalPinIn signals.json
Hub supply, 5 V rail (2:1 divider)GPIO1, ADC1 channel 0"source": {"protocol": "on-chip", "channel": 0, ...}
Hub temperature (TMP36)GPIO2, ADC1 channel 1"source": {"protocol": "on-chip", "channel": 1, ...}
OpenTherm adapter, in and outGPIO4 and GPIO5Read and driven by your firmware, not by HEART
Call-for-heat relay (on/off boilers)GPIO6Driven by your firmware only
Boiler fault contact (optocoupler, low = fault)GPIO7, external 10 kΩ pull-up"source": {"protocol": "on-chip", "channel": 7}, a boolean with "safety": true
Flow-pipe DS18B20 (on/off boilers)GPIO8Read by your firmware, not by HEART
Hub buttonGPIO10CONFIG_HEART_BUTTON_GPIO=10
Hub LEDGPIO11CONFIG_HEART_LED_GPIO=11

On the ESP32-S3, ADC1 channel n is GPIO n+1. Avoid the strapping pins (GPIO0, 3, 45, 46) and the pins your module uses for flash and PSRAM. Check your own board's pin map.


2. Where everything runs#

 Wall unit (nRF52840, 2 × AA)        Hub at the boiler (ESP32-S3: one firmware image)                    Boiler
 SHT40, display, button   ──BLE──►   heating task (high priority)     HEART (from the ESP32 starter)     own flame,
 sends: room reading,                 control loop, frost 5 °C,  ◄──   heart_tend task (low priority):    overheat and
 battery, button presses              max 28 °C, anti-short-cycle      checks every 30 s, alerts, LED     lockout
 shows: target, faults,               OpenTherm master  ──────────────────────────────────────────────►  controls
 approval codes                             ▲  shared variables (SYMBOLS[])                               (read only)
                                            └── setpoint, night setpoint, mode: only through the gate (button)
                                      HTTPS API on port 43278 · HEART Hub reports ──────────► heartcortex.ai (outbound only)
NVS (encrypted): device ID, claim code, key · owner key · TLS certificate and key · wall-unit pairing keys
  • One image, two tasks. HEART's files are compiled into the hub's ESP-IDF project. The starter runs HEART as the task heart_tend at tskIDLE_PRIORITY + 1, so the heating task, at a higher priority, always runs first.
  • HEART is not in the control loop. The heating task decides each second whether to call for heat. HEART changes only the targets the loop works to, and the firmware clamps them to its own limits.
  • The boiler's own safety is out of reach. Flame supervision, the overheat stat and lockout stay in the boiler. HEART reads the fault contact and the OpenTherm status, and never resets a lockout.
  • The wall unit's button is the proof that a person is in the house. Its presses reach HEART over the radio. The hub's own button does the same job at the boiler.

3. The code#

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

FileWhat you do
main/port_esp32.cChange the lines marked CHANGE (below), and the three port functions in Section 3.5
signals.jsonWrite it with heart_signals.py, then export main/heart_signals.h
main/heart_core.c, heart_core.h, heart_ed25519.cCopy unchanged
main/Kconfig.projbuild, sdkconfig.defaultsCopy; set values in idf.py menuconfig
Your hub firmwareThe heating task, OpenTherm, the Bluetooth link to the wall unit. Add as more source files or a component in main/, started from app_main()

Start the heating first. The starter's app_main() returns early if HEART refuses to start (for example, with a signal table nobody sealed). Start your heating task before heart_init(), so a HEART that does not start never stops the heating. 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#

#define SYSTEM_ID "com.example.heating.thermostat"   /* CHANGE, with signals.json */
#define NAME      "Smart Thermostat"                 /* CHANGE */
#define VERSION   "1.0.0"                            /* CHANGE */

The system ID is the one in the case study, Step 2. It names the product, never the household.

3.2 The values your firmware shares with HEART#

/* CHANGE: the firmware variables your signal table names as sym:... sources. The heating task writes them. */
volatile float room_temp = 20, setpoint = 20, night_setpoint = 18, mode = 1, call_for_heat = 0, boiler_firing = 0,
               flow_temp = 0, burner_starts_10min = 0, overshoot_c = 0, sensor_battery_days = 365,
               sensor_battery_low = 0, wifi_up = 0, occupied = 0;
static const struct { const char *name; volatile float *value; } SYMBOLS[] = {
    {"room_temp", &room_temp}, {"setpoint", &setpoint}, {"night_setpoint", &night_setpoint}, {"mode", &mode},
    {"call_for_heat", &call_for_heat}, {"boiler_firing", &boiler_firing}, {"flow_temp", &flow_temp},
    {"burner_starts_10min", &burner_starts_10min}, {"overshoot_c", &overshoot_c},
    {"sensor_battery_days", &sensor_battery_days}, {"sensor_battery_low", &sensor_battery_low},
    {"wifi_up", &wifi_up}, {"occupied", &occupied}, {0, 0}};

The heating task updates these every second. Two are worked out by the firmware for HEART's sake:

  • burner_starts_10min: the number of times the flame came on in the last 10 minutes.
  • overshoot_c: how far the room is above its current target, or 0 for a while after the target was lowered (the case study, Step 5 rule).

HEART reads all of them. It writes only setpoint, night_setpoint and mode, and only after a person's press. The starter cannot write text, so mode is a number: 0 off (frost protection only), 1 heat, 2 eco.

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

{"name": "com.example.heating.thermostat.room_temp", "title": "Room temperature", "type": "number", "unit": "Cel",
 "range": {"min": -10, "max": 50}, "source": {"protocol": "on-chip", "symbol": "room_temp"}, "privacy": "internal",
 "alerts": [{"when": "outside-range", "code": "sensors.out-of-range", "severity": "error"},
            {"when": "stuck", "afterSec": 90, "code": "sensors.stuck", "severity": "error"}]},
{"name": "com.example.heating.thermostat.setpoint", "title": "Setpoint", "type": "number", "unit": "Cel",
 "range": {"min": 10, "max": 28}, "source": {"protocol": "on-chip", "symbol": "setpoint"},
 "privacy": "internal", "writable": true, "physical": true},
{"name": "com.example.heating.thermostat.night_setpoint", "title": "Night temperature", "type": "number", "unit": "Cel",
 "range": {"min": 10, "max": 28}, "source": {"protocol": "on-chip", "symbol": "night_setpoint"},
 "privacy": "private", "writable": true, "physical": true},
{"name": "com.example.heating.thermostat.mode", "title": "Mode (0 off, 1 heat, 2 eco)", "type": "integer",
 "range": {"min": 0, "max": 2}, "source": {"protocol": "on-chip", "symbol": "mode"},
 "privacy": "internal", "writable": true, "physical": true},
{"name": "com.example.heating.thermostat.burner_starts_10min", "title": "Burner starts in 10 minutes", "type": "integer",
 "range": {"min": 0, "max": 3}, "source": {"protocol": "on-chip", "symbol": "burner_starts_10min"}, "privacy": "internal",
 "alerts": [{"when": "outside-range", "code": "actuators.short-cycling", "severity": "warning"}]},
{"name": "com.example.heating.thermostat.overshoot_c", "title": "Room above its target", "type": "number", "unit": "Cel",
 "range": {"min": -50, "max": 1.5}, "source": {"protocol": "on-chip", "symbol": "overshoot_c"}, "privacy": "internal",
 "alerts": [{"when": "outside-range", "code": "performance.overshoot", "severity": "warning"}]},
{"name": "com.example.heating.thermostat.sensor_battery_days", "title": "Wall unit battery, days left", "type": "integer",
 "unit": "d", "range": {"min": 30, "max": 3650}, "source": {"protocol": "on-chip", "symbol": "sensor_battery_days"},
 "privacy": "internal", "alerts": [{"when": "outside-range", "code": "power.battery-low", "severity": "warning"}]},
{"name": "com.example.heating.thermostat.sensor_battery_low", "title": "Wall unit battery low", "type": "boolean",
 "source": {"protocol": "on-chip", "symbol": "sensor_battery_low"}, "privacy": "public"},
{"name": "com.example.heating.thermostat.boiler_ok", "title": "Boiler fault contact: no fault",
 "description": "1 = no fault. From the boiler's own fault relay, through an optocoupler. HEART only reads it.",
 "type": "boolean", "source": {"protocol": "on-chip", "channel": 7}, "privacy": "internal", "safety": true},
{"name": "com.example.heating.thermostat.hub_supply", "title": "Hub supply voltage", "type": "number", "unit": "V",
 "range": {"min": 4.75, "max": 5.25}, "source": {"protocol": "on-chip", "channel": 0, "scale": 0.001514, "offset": 0},
 "privacy": "internal", "alerts": [{"when": "outside-range", "code": "power.supply-unstable", "severity": "warning"}]},
{"name": "com.example.heating.thermostat.hub_temp", "title": "Temperature in the hub", "type": "number", "unit": "Cel",
 "range": {"min": 0, "max": 50}, "source": {"protocol": "on-chip", "channel": 1, "scale": 0.0757, "offset": -50},
 "privacy": "internal", "alerts": [{"when": "outside-range", "code": "performance.too-hot", "severity": "warning"}]},
{"name": "com.example.heating.thermostat.occupied", "title": "Room occupied", "type": "boolean",
 "source": {"protocol": "on-chip", "symbol": "occupied"}, "privacy": "private"}

The table also lists call_for_heat, boiler_firing, flow_temp and wifi_up (all internal), as in the case study, Step 3. Frost protection (5 °C) and the 28 °C maximum stay as constants in the heating task. They are not in the table, so nothing can ask to change them. The night hours and the address are not in it either.

The scale and offset turn the raw ADC reading into volts or degrees. 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.

  • Hub supply: the 2:1 divider turns 5.0 V into 2.5 V, raw 2.5 / 0.000757 = 3302. So volts = raw × 0.000757 × 2 = raw × 0.001514. Check: 3302 × 0.001514 = 5.00 V.
  • Hub temperature: the TMP36 gives 0.5 V at 0 °C and 10 mV per °C. So °C = (raw × 0.000757 − 0.5) / 0.01 = raw × 0.0757 − 50. Check: 50 °C is 1.0 V, raw 1321, and 1321 × 0.0757 − 50 = 50.0.

Calibrate both on your bench, with a multimeter and a thermometer, because every chip's ADC differs a little.

What the starter raises today. The fault codes in the table are the ones the case study uses. But the starter's core (heart_core.c) does not yet use them: it raises every range alert as sensors.out-of-range, severity warning, and the summary names the signal's title ("Burner starts in 10 minutes reads outside its normal range..."). It does not check for stuck readings yet (the starter's Known gaps). system.main-function-lost from Section 3.4 is the one product fault it raises with its proper code. Sections 6 and 7 show both: what the starter raises today, and the code a finished product should raise. Keep the codes in the table, so nothing changes in it when the core catches up.

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

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#

/* CHANGE: return 1 while the product does its main job: when the room needs heat, the heating delivers it. */
int heart_port_main_job_ok(const char **why) {
    if (call_for_heat && seconds_since_call_or_flame >= 120) {
        *why = "Called for heat for over 2 minutes, but the burner has not fired. The house will get cold."; return 0; }
    if (wall_unit_silent_s >= 1800) {
        *why = "The wall unit has not been heard for 30 minutes. The heating keeps to frost protection only."; return 0; }
    return 1;
}

/* CHANGE: tell a person, with no internet: blink the hub's LED, and show the fault on the wall unit's display. */
void heart_port_escalate(const char *summary) {
    ESP_LOGE(TAG, "escalate: %s", summary);
    fault_blink = 1;
    wall_unit_queue_message(summary);      /* your code: queue it for the radio task; never wait here */
}

/* CHANGE: the hub runs the heating, so it never sleeps; a restart waits until the boiler is not being called. */
int heart_port_lifecycle_ready(const char *operation) {
    if (!strcmp(operation, "sleep")) return 0;
    return !call_for_heat && !boiler_firing;
}
  • The main job is the case study's check. The 120 s allows for the firmware's 90 s anti-short-cycle rest. The second line is an assumption of this page: the guide does not say what the hub does when the wall unit goes quiet. Here the firmware falls back to frost protection, and HEART says so.
  • Escalation is called while the core holds its lock, so it only sets a flag and queues the message. Your existing app notification, through the maker's own cloud, can be fed from the same queue.
  • A restart waits for a quiet moment. If none comes within timeoutSec (default 60), the restart fails and is never forced. A sleep request always fails, by design.

3.5 Three more port changes: the wall unit's button, its display, and the firmware's guards#

These functions are in port_esp32.c but are not marked CHANGE. A thermostat needs them changed.

/* The later of a press on the hub and a press on the wall unit, in heart_port_ms() time. */
int64_t heart_port_button_last_press_ms(void) {
    return wall_unit_press_ms > last_press_ms ? wall_unit_press_ms : last_press_ms;
}

/* Show the 6-character code on the wall unit's display too, and clear it when code is NULL. */
void heart_port_indicate(const char *code) {
    blinking = code != 0;
    if (code) ESP_LOGW(TAG, "Press the button to approve. Code: %s", code);
    wall_unit_queue_code(code);            /* your code */
}

int heart_port_write(const heart_signal_t *s, double value) {
    if (!thermostat_accepts(s->source + 4, value)) return -1;   /* added: the firmware's guards decide */
    /* ... the starter's code, unchanged ... */
}
  • The wall unit's button. The radio task stores the time of a short press in wall_unit_press_ms. For a 3-second hold, it calls heart_stop_local(), which takes the core's lock itself. The radio link must be paired and encrypted (Bluetooth LE Secure Connections, keys made at the factory, Section 5). An unauthenticated press could be faked by anyone in range, and would then count as a person in the house.
  • The guards are the firmware's, as in the case study: no change to the same setting within 5 minutes, and none while the room reading is not good. thermostat_accepts() is your code. When it refuses, the Action ends failed ("The device could not write the signal"), and nothing changes. The heating task still clamps every value to 10 to 28 °C when it uses it.
  • How long a change waits. The starter waits 60 seconds for the button (the 60 in the heart_config_t cfg line of app_main()). A person may have to walk to the wall unit, and the wall unit may only wake every few seconds to hear the code. Consider a few minutes.

4. Build and install#

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

idf.py set-target esp32s3
idf.py menuconfig

In menuconfig, under HEART:

SettingValue 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. Every thermostat change is physical, so the button is the way to approve (see Section 9)
Also physical actions (HEART_MONITOR_APPROVALS_PHYSICAL)Off
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)10 and 11
Reasoning Gateway URL (HEART_GATEWAY_URL)Empty

Make a development certificate, then build and flash:

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=thermostat.local"
idf.py build flash monitor

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

==== HEART Hub ====
Register this device at https://heartcortex.ai/register?id=7d2a4c91e0b35f86
Claim code: TQ4RM-8WZ2C-KD6HN-3PXVA
=======================

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

curl -k https://heart-c4d2e1.local:43278/heart/v1/pulse

Installing it. Develop on a bench rig, never on a boiler in a lived-in home. In a home, a qualified heating installer fits the hub, makes the mains connection and connects it to the boiler's OpenTherm terminals or its switched-live input, following the boiler maker's instructions.


5. On the production line#

For each hub and wall unit:

  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 the hub's own TLS certificate and key to NVS (namespace heart, blobs cert and key). Never ship the development certificate.
  3. Pair the wall unit with its hub on the jig, and store the pairing keys on both. A press is trusted only from the paired wall unit.
  4. Start the hub once on the test jig. It makes its device ID, claim code and key, and stores them in NVS.
  5. Read the device ID and claim code from the jig's console, and print the label for the box and for the hub: 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).
  6. Run the bench tests of Section 7 on a sample from each batch, on a test rig.

6. What the owner sees#

  1. Setup. The installer fits the hub. The maker's app joins it to the home Wi-Fi, as before.
  2. Registration. The owner scans the QR code on the label, signs in at heartcortex.ai with an emailed code, and types the claim code. The page shows Healthy. It is private until the owner shares it. See the owner's manual.
  3. Every day. Nothing. The hub checks itself every 30 seconds.
  4. When something goes wrong:
Real eventThe HEART notices (today, with the starter)Code a finished product raisesThe owner sees
The boiler locks out on a cold night, or its gas is offCalled for heat for 2 minutes, no flame: system.main-function-lost, within 30 s moreThe sameThe wall unit shows a fault symbol; the hub's LED blinks; the page shows Failing. HEART never resets the boiler: the advice is to check the boiler's display or call the installer
Air in the radiators cuts the flame4 starts in 10 minutes: sensors.out-of-range on "Burner starts in 10 minutes"actuators.short-cyclingA warning in plain words
The wall unit's cells run downUnder 30 days left: sensors.out-of-range on "Wall unit battery, days left"power.battery-lowA warning, about a month ahead; the public battery flag turns low
Strong sun heats the roomMore than 1.5 °C above target: sensors.out-of-range on "Room above its target"performance.overshootA warning that says HEART cannot cool the room
The room sensor fails and reads -40 °Csensors.out-of-range on "Room temperature"The same, severity errorA warning; the firmware ignores the reading and keeps the last good one
The room sensor freezes at one valueNothing yet: the starter does not check for stuck readingssensors.stuckNothing, until the core can do this
The wall unit goes silent (cells out, out of range)system.main-function-lost: "The wall unit has not been heard for 30 minutes..."The sameFailing; the hub's LED blinks
The hub's Wi-Fi dropsNothing can be sent. The heating goes on as setconnectivity.link-lost, shown on the wall unitAfter 90 seconds the page shows Not heard from, and one email
  1. Changing the heating. The owner asks for a new setpoint, night temperature or mode in the app or the API. The hub answers 202 with an Action. The wall unit shows the change and a 6-character code. The owner checks the code and presses the wall unit's button (or the hub's). Only then does the firmware get the new value, and its own guards and limits still apply. An approval sent over the network with the owner key alone is refused with 403 presence-required, so neither the key nor an AI agent holding it can change the heating. A value outside 10 to 28 °C is refused with 400 invalid-request (the simulator answers 422 out-of-range).

7. Test it on the bench#

Use a test rig: a boiler installed for testing by a qualified engineer, or an OpenTherm boiler emulator. Cause every fault for real, and check the right code appears within a minute (two and a half for the boiler), on the hub's API and on its HEART Hub page.

FaultHow to cause it on the benchExpect todayExpect when the core raises the guide's codes
Boiler does not fireHave the engineer shut the rig boiler's gas valve, or set the emulator's flame to off, then call for heatsystem.main-function-lost after 2 to 2.5 minutesThe same
Short-cyclingMake the emulator light and drop the flame 4 times in 10 minutessensors.out-of-rangeactuators.short-cycling
Room reading impossiblePut the wall unit in a freezer at -18 °Csensors.out-of-rangeThe same, severity error
OvershootWarm the wall unit's sensor with a hair dryer to 2 °C over its targetsensors.out-of-rangeperformance.overshoot
Cells lowRun the wall unit from a bench supply, lowered to the voltage your firmware maps to 30 dayssensors.out-of-rangepower.battery-low
Wall unit silentTake its cells out for 30 minutessystem.main-function-lostThe same
Hub supplyFeed the 5 V rail from a bench supply at 4.6 Vsensors.out-of-rangepower.supply-unstable
Wi-Fi lostSwitch off the access point for 2 minutesNot heard from on the page, then Healthy again. The rig keeps heatingThe same
HEART cannot startFlash a build whose signal table is not sealedHEART refuses to start. The heating must work exactly as beforeThe same
A change needs a personPUT setpoint 21 with the owner key, then approve with the key202, then 403 presence-required; it runs only after a press on the wall unitThe same
A fake pressSend a button message from an unpaired radioIgnored; the change keeps waitingThe same
StopHold the wall unit's button for 3 secondsEvery waiting change is cancelled; GET /heart/v1/tending/autonomy shows "max": 2 and "mode": "observe"The same

Then run the HEART-Rating client against the hub, and claim only what passed.


8. Heating safety: what to expect#

  • HEART never sits between the boiler and its protection. Flame supervision, the overheat stat and lockout are in the boiler. Frost protection, the 28 °C maximum and anti-short-cycle timers are in the heating task. HEART reads the boiler's fault contact, which is marked "safety": true; the starter refuses a table that makes such a signal writable.
  • A task is not a process. On one ESP32, a HEART that is slow or stuck cannot delay the heating, because its task has the lower priority. But a HEART that crashes the chip (a bad pointer, a failed ESP_ERROR_CHECK) restarts the whole hub, heating task included. The heating comes back within seconds of the restart, and the boiler's own controls keep it safe meanwhile. The case study asks for a HEART crash never to stop the heating (Part 6, Section 9, rule 6): for that guarantee, use two chips (the heating on its own microcontroller, the HEART on the ESP32), or a Linux hub where heartd is a separate process.
  • What the boiler does when the hub goes quiet is the boiler's business. Check in the boiler maker's documentation what it does when OpenTherm messages stop, or when the switched live opens. Most stop heating; frost protection then depends on the boiler's own frost setting, if it has one.
  • The wall unit's battery is not touched by HEART. HEART runs on the mains-powered hub. The wall unit only shows the odd code or fault, and sends button presses it would send anyway.
  • A power cut stops everything. The hub, its HEART and usually the boiler go off together. The page shows Not heard from, and returns to Healthy when the power does.

9. Going further#

  • Explanations from an AI model. Turn on AI brain in menuconfig. The monitor site then acts as the hub's Command Center and holds the model keys. The hub 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 occupancy and the night temperature never reach a model. See the starter's Section 5b.
  • Approving from away. HEART_MONITOR_APPROVALS lets the owner approve a waiting change on the monitor site with an emailed code. Every thermostat change is physical, so it also needs HEART_MONITOR_APPROVALS_PHYSICAL, the second opt-in. That proves the owner could read their inbox, not that anyone is in the house. In the starter both are build settings; the case study wants the second one turned on at the device, so a product should offer it on the wall unit, confirmed with its button. See the owner's manual, Section 9.
  • The energy-saving suggestions of the simulator's mock AI ("night temperature 21 to 18 °C") and Playbooks (for example, reconnecting Wi-Fi by itself, at A4) need HEART Full: Proposals that enter the gate as ai Actions, adopted rules, and a record of every Action. The ESP32 starter has none of these yet. A Linux hub can run heartd, configured as in its configuration guide; its examples/ac-controller is a close relative (see the AC controller guide). Any physical change a model proposes still waits for a person.
  • The guide's own fault codes. Until the core raises the codes named in the table, a maker can add checks for short-cycling, battery and overshoot to the core, keeping the standard codes of Part 3. Report what you change.
  • A cloud service beside the thermostat. The Python template, with ask_owner() for site approvals, can watch a thermostat from the maker's cloud. It cannot stand in for the button at home.