# Smart thermostat simulator: a HEART-embedded system you can run, watch and ask to change things

**A demonstration only.** It simulates a house, a smart thermostat, a gas boiler and the thermostat's HEART. It switches no real heating and must never be connected to a boiler or heat pump.

One Python file, `thermostatSimulator.py`, with only the standard library. Run it, register it on [HEART Hub](https://heartcortex.ai/monitor) with the code it prints, and watch its health on your own private page while it lives a simulated life: day and night, cold and mild spells, sunshine, windows opened now and then, people coming and going, a boiler that cycles on and off. Faults come and go: strong sun overheating the room, a lost Wi-Fi link, a frozen room sensor, an impossible reading, a boiler that will not light, a boiler that short-cycles, and flat batteries in the wall unit.

Unlike the [pacemaker simulator](../pacemakerSimulator/README.md), this HEART also **takes changes**. The owner can change the setpoint, the night temperature and the mode through the local HEART API. Heating acts on the physical world, so every change waits for a person to press the **thermostat's own button**. A clearly labelled **mock AI** suggests ways to save energy, and its suggestions wait for the same yes. They never run on their own.

The case study behind it, step by step for a real product, is [`guides/Thermostat.md`](../guides/Thermostat.md) (Guide 5).

**On a real device:** [Thermostat, for real](https://heartcortex.ai/docs/real-devices/Thermostat.md) shows the hardware we assume, where the HEART runs, the code to change, how to build and install it, the factory steps and what the owner sees. For illustration only.


| File | What it is |
|---|---|
| `thermostatSimulator.py` | The simulator: house, thermostat and boiler firmware, HEART with its gate, mock AI, local HEART API, HEART Hub reporting and approvals |
| `test_e2e.py` | End-to-end test against a monitor site on your own computer |
| `README.md` | This file |

---

## 1. Download and run

You need Python 3.8 or newer. Nothing to install.

```sh
python3 thermostatSimulator.py --scenario all
```

It starts at once and prints three things:

```
  HEART Hub: register this simulated thermostat
    1. Sign in at https://heartcortex.ai/login with your email (a 6-digit code is sent to you).
    2. Open https://heartcortex.ai/register?id=5b1e0c93a7d2f418
    3. Enter the claim code  Q4M7T-ZX2KD-9RBVA-H3NPW
    4. Watch it at https://heartcortex.ai/device/5b1e0c93a7d2f418  (private to you until you share it)

10:50:09 HEART API on http://127.0.0.1:43278/heart/v1/pulse  (owner key for private views: ...)
10:50:24 room  20.3 C · target 20.0 (heat) · CALL · boiler ON  flow 61.8 C starts 1 · outdoor   5.4 C · battery 2.98 V · wifi up · HEART healthy
```

Every 15 seconds it prints one line of what the simulated thermostat knows. When something goes wrong, its HEART says so:

```
10:52:21 HEART critical system.main-function-lost: The boiler does not heat when the thermostat calls for heat.
         Called for heat for over 2 minutes, but the burner has not fired (the boiler reports a lockout). The house
         will get cold. Check the boiler's display and gas supply, or call the installer. No fix HEART can make: it
         never resets a boiler.
10:53:42 HEART resolved   system.main-function-lost: the boiler does not heat when the thermostat calls for heat
```

And when a change is waiting for a yes, the thermostat's "display" shows it with a short code:

```
10:51:10 THERMOSTAT DISPLAY  Night temperature: 20 to 18 °C at night?  code 01ED72  (press the button within 900 s: touch button.press)
```

Stop it with Ctrl-C. The device ID, claim code, key and the monitor site's signing key are kept in `thermostat-monitor.json` (readable only by you), so the same simulated device comes back next time. Delete that file to make a new device.

## 2. Register it and watch it on HEART Hub

1. **Sign in.** Open `https://heartcortex.ai/login` and enter your email address. A 6-digit code arrives; type it in. There are no passwords.
2. **Register.** Open the `register?id=...` link the simulator printed, and enter the **claim code**. The claim code is the proof that you hold the device: it is shown only on the simulator's console, and the site holds only its hash.
3. **Watch.** Your device's page, `https://heartcortex.ai/device/<ID>`, shows its status (healthy, degraded or failing) and its open and recent alerts, each with the plain-language meaning of its fault code. All your devices are at `https://heartcortex.ai/devices`.
4. **Share, if you want.** The page is private until you make a share link or make it public.
5. **Silence.** Stop the simulator: after three missed Pulse intervals (90 seconds) the page shows a **Flatline**, and you get one email. The site checks for silent devices every 5 minutes, so the email can take a few minutes more. (The staged Wi-Fi loss lasts 200 simulated seconds: at `--speed 1` that is long enough for a Flatline, at `--speed 5` it is not.)

Until you register it, the site stores nothing. After registering, it can take up to five minutes for the first report to arrive, or less as soon as something changes.

**What the site sees.** Only the public Pulse and public alerts, and it keeps only the status and timing and each alert's fault code, severity and times. Never a temperature, the boiler's state, the schedule, or whether anyone is home. The one exception is opt-in: see approvals on the site, below.

### Approvals on the site (optional, off by default)

With `--monitor-approvals`, the HEART also offers a waiting change to its owner on the monitor site, as the Python template's `ask_owner()` does (HEART Hub, Section 7.1). The site sees only a **title** and a **change** in plain words, such as "Night temperature: 21 to 18 °C at night", never the hours of the night schedule or anything about who is home. The owner turns approvals on for the device on its page, presses **Approve…**, and types the code the site emails them. The device acts only after it has checked the site's **Ed25519 signature** on the decision with the key it pinned at its first hello, that the digest is the waiting Action's (API Standard 6.3), and that the decision is at most 300 seconds old. It records the approval as presence method `remote-site`.

Every change on a thermostat is physical, and `remote-site` proves only that the owner could read their inbox, not that anyone stood in the house. So physical changes are offered on the site only with a second flag, `--monitor-approvals-physical` (the owner's `remoteSitePhysical` setting, Part 6 Section 3.2). Without it, the site never sees a thermostat change, and the button is the only way to say yes. Resuming after Stop is never offered on the site.

**Note for heartcortex.ai.** Approvals need the site to have its signing key. The live `heartcortex.ai` must be redeployed with `SITE_SIGNING_KEY` set before approvals work there; until then the owner's page says approvals are not available, and everything else works. The local test site (`heart-monitor/test/serve.mjs`) has a test key, and `test_e2e.py` uses it.

## 3. The staged faults

With `--scenario`, each fault starts at a set time, lasts a while, and is then "fixed by the installer", so you can watch an alert open, escalate and resolve. Times are in simulated seconds: at `--speed 1` they are real seconds.

| Scenario | Starts | Lasts | What you see |
|---|---|---|---|
| `sun` | 30 s | 2 min | `performance.overshoot` (warning): strong sun drives the room more than 1.5 °C above its target. HEART cannot cool; it says so |
| `link` | 1 min | 200 s | `connectivity.link-lost` (warning): the hub loses Wi-Fi. Heating goes on as set; no reports until it is back |
| `battery` | at once | to the end | `power.battery-low` (warning) within two minutes: the wall unit's AA cells, aged 1500 times faster |
| `stuck` | 280 s | 3 min | `sensors.stuck` (error): the room reading freezes, so the thermostat controls to a stale value |
| `range` | 470 s | 80 s | `sensors.out-of-range` (error): the sensor reads -40 °C. The firmware ignores it and keeps the last good reading |
| `boiler` | 560 s | 400 s | `system.main-function-lost` (critical): called for heat, the boiler does not light. The demo opens a window for the first two minutes, so heat is soon needed |
| `cycling` | 980 s | 400 s | `actuators.short-cycling` (warning): with air in the radiators the flame cuts out after 20 s; the firmware's anti-short-cycle timer restarts it 90 s later, 4 starts in 10 minutes |
| `ai` | 5 min | | The mock AI makes its first suggestion early, and asks again 100 s after a suggestion lapses (otherwise after 30 minutes, then 6 hours) |
| `all` | | | All of the above. At `--speed 5` it takes about 4.5 minutes |

Without `--scenario`, the house still has random weather, sun, open windows and Wi-Fi losses (every 3 to 9 hours), as in life. A long run with no scenario raises only those.

## 4. Options

| Option | Default | Meaning |
|---|---|---|
| `--scenario` | none | `sun`, `link`, `battery`, `stuck`, `range`, `boiler`, `cycling`, `ai` (comma-separated) or `all` |
| `--speed` | 1 | Simulated seconds per real second. `--speed 5` runs the staged faults in about 4.5 minutes |
| `--approval-timeout` | 900 | Real seconds a change waits for a yes before it expires (`approvalTimeoutSec`, at least 60; the standard's default is 24 hours) |
| `--monitor-approvals` | off | Offer waiting changes to the owner on the monitor site (signed decisions, `remote-site`) |
| `--monitor-approvals-physical` | off | Also offer physical changes there (`remoteSitePhysical`). Needs `--monitor-approvals` |
| `--monitor-url` | `https://heartcortex.ai` | Any HEART Hub site, including your own. `http` only for `127.0.0.1` |
| `--no-monitor` | | Report to no site: local only |
| `--state-file` | `thermostat-monitor.json` | Where the device ID, claim code, key and pinned site key are kept |
| `--port` | 43278 | The local HEART API (falls back to 7685; `0` for none) |
| `--seed` | random | Repeat the same simulated life |
| `--duration` | until Ctrl-C | Stop after this many real seconds |

## 5. The local HEART API

The simulator serves the HEART API on your own computer, as a real thermostat's hub would:

```sh
curl http://127.0.0.1:43278/.well-known/heart-card.json         # the Card: what it is
curl http://127.0.0.1:43278/heart/v1/pulse                       # public health
curl http://127.0.0.1:43278/heart/v1/arrhythmias                 # public alerts: codes only
curl http://127.0.0.1:43278/heart/v1/properties                  # public values: battery status only
curl http://127.0.0.1:43278/heart/v1/tending/autonomy            # max 3, mode suggest; max 2, mode observe after Stop
KEY=...   # the owner key it printed
A="Authorization: Bearer $KEY"
curl -H "$A" http://127.0.0.1:43278/heart/v1/properties          # all values, internal and private
curl -H "$A" http://127.0.0.1:43278/heart/v1/arrhythmias         # alerts with explanations, cause and confidence
curl -H "$A" http://127.0.0.1:43278/heart/v1/actions             # every change asked for, and what became of it
curl -H "$A" http://127.0.0.1:43278/heart/v1/rhythm              # private history: actions, occupancy, windows
```

**Changing things.** Every change needs the owner key, and every one acts on the heating, so it is **physical**:

```sh
P=http://127.0.0.1:43278/heart/v1/properties/org.heartaispec.demo.thermostat
curl -X PUT -H "$A" -d '{"value": 21}' $P.setpoint             # 202: an Action waits; the display shows a code
curl -X POST -H "$A" -d '{"decision":"approve"}' http://127.0.0.1:43278/heart/v1/actions/<id>/decision
                                                                 # 403 presence-required: a key is not a person
touch button.press                                               # a press on the thermostat: it runs
curl -X PUT -H "$A" -d '{"value": "eco"}' $P.mode              # heat, eco (setpoint - 3 °C) or off (frost protection only)
curl -X PUT -H "$A" -d '{"value": 18}' $P.night_setpoint       # 23:00 to 06:00; null to turn it off
curl -X POST -H "$A" -d '{"decision":"deny"}' http://127.0.0.1:43278/heart/v1/actions/<id>/decision   # no proof needed
curl -X POST -H "$A" http://127.0.0.1:43278/heart/v1/tending/stop   # Stop: at once; or touch button.hold
curl -X PUT -H "$A" -d '{"max":3}' http://127.0.0.1:43278/heart/v1/tending/autonomy   # resume: waits for the button
```

| Request | Answer |
|---|---|
| A setpoint or night temperature outside 10 to 28 °C, or a mode other than heat, eco, off | `422 out-of-range`. Frost protection (5 °C) stays on in every mode, whatever is set |
| `frost_protection`, `max_setpoint`, or anything the firmware or boiler owns | `409 conflict`: they are read-only, and HEART never overrides them |
| The same setting again within 5 simulated minutes, or any change while the room sensor's reading is not good | `409 guard-failed`. The guards are the product's, checked when asked and again just before the change is applied, whoever asked |
| A valid change | `202` with an Action, `awaiting-approval`, until the button is pressed (it runs), it is denied or cancelled, Stop is pressed (cancelled), a newer request replaces it, or `--approval-timeout` passes (expired, nothing changes) |
| `approve` with the key, or any `presence` field in a request | `403 presence-required` or `403 presence-invalid`. Only the button (or a signed site decision, if allowed) proves a person |
| `PUT /heart/v1/tending/autonomy` with a `max` other than 0 to 2 or 3 | `400 invalid-request`: this HEART promises level 3 (mode `suggest`) and never acts on its own |

**Autonomy.** A person sets the ceiling `max`; the current level is the lower of `max` and the promised level 3, and the mode follows from it: 3 is `suggest`, 2 or below is `observe`. Stop sets `max` to 2, and so does `PUT /heart/v1/tending/autonomy` with a `max` of 2 or below, at once. Raising it back to 3 is destructive and waits for the button. The owner's Pulse shows `autonomy` with `max`, `current`, and `limitedBy: stopped` while `max` is below 3.

Only one change waits at a time, so one press can never approve the wrong thing. The **mock AI** (`MockAI` in the file, plain rules over the private occupancy log, no model and no network) waits until nothing else is waiting, then offers its suggestion as an `ai` Action with `onBehalfOf: owner`. Because it is physical and started by an AI, the gate treats it as **destructive**: it always waits for a person, whatever is configured, and after Stop it does not suggest at all.

Every message it serves is a valid HEART message: `test_e2e.py` checks each one against the official schemas. Two simplifications: the simulator honours an `Idempotency-Key` header but does not insist on one, as API Standard 5 requires of a real system; and the API is plain HTTP on loopback only.

## 6. How the code is built

```
House ──► Thermostat (firmware: control loop, limits) ──► Boiler (its own firmware)
             ▲  │ read                                       │ read
   apply()   │  ▼                                             ▼
   (clamps)  └── Heart ◄─── MockAI (suggests only)
                  │  watches, gates changes, tells a person
                  ├─► local HEART API    (127.0.0.1; owner key; button for physical changes)
                  └─► HEART Hub      (outbound only; approvals only with the flags)
```

| Part | In the file | In a real product |
|---|---|---|
| `House` | Outdoor temperature (day and night, spells), the room's thermal mass and radiators, sun, windows, occupancy, each on its own random timer | The real home |
| `Thermostat` | Wall unit sensor, batteries and Wi-Fi hub; the control loop (hysteresis around the target); frost protection, the 28 °C maximum; `guard()` and `apply()` | The thermostat's own firmware. **Never part of HEART** |
| `Boiler` | Fires when called, within its anti-short-cycle timers; can fail to light, or cut its flame for low flow | The boiler and its certified controls. **Never part of HEART** |
| `Heart` | `RULES` turn readings into alerts with fault codes; `escalate()` tells a person; the gate turns change requests into Actions that wait for presence; Stop; the Rhythm | **The code you add**, on the thermostat's hub |
| `MockAI` | Suggests a lower night temperature. Labelled a mock: a few rules, no model | A model behind the Reasoning Gateway, or your own rules; its output still goes through the gate as `ai` |
| `monitor()` | Hello, reports, `nextSec`, 404 means hello again, 401 stops; offers waiting changes and checks signed decisions only with the flags | The same |
| `handler()` | The HEART API: Card, Pulse, alerts, properties, actions, autonomy, Stop, Rhythm | The same, behind TLS on the home network |

HEART is not in the control loop (Part 6, Section 9): the firmware decides each second whether to call for heat. HEART changes only the **targets** the loop works to, only through `Thermostat.apply()`, which keeps the firmware's limits, and only after a person's yes.

## 7. What code to add to make a real product HEART-embedded

For a real thermostat, HEART runs on the **hub**: the part with Wi-Fi, beside the boiler-control firmware, never inside it. Pick a starting point, then add four things.

**Starting points**

| Your thermostat | Start from |
|---|---|
| A microcontroller with Wi-Fi (most thermostats) | The ESP32 Full starter `heart-templates/templates/esp32-full`: its signal table, owner key, physical writes that wait for the device's button, Stop by a long press, and Monitor reporting with signed approvals. Its `host/port_linux.c` uses the same `button.press` file as this simulator |
| An embedded Linux hub | The Rust Core `heartd` (`heart-core-rs/crates/heartd`) for HEART Full, with guards and interlocks in its configuration; `heart-core-rs/examples/ac-controller` is a close relative (see `guides/AC-Temperature-Controller.md`). Or this file's `Heart`, `monitor()` and `handler()` |
| A cloud service or app beside the thermostat | The Python template `heart-templates/templates/python` (`ask_owner()` for site approvals) or `heart-templates/templates/javascript-node` |

**The four things you add**

1. **Read the thermostat.** Replace `House`, `Thermostat` and `Boiler` with reads of values your firmware already has: room reading, target, call for heat, the boiler's OpenTherm status (firing, flow temperature, fault flags), battery voltage, Wi-Fi state.

   ```python
   class Readings:
       """CHANGE: fill these from your firmware. Read only."""
       def refresh(self):
           f = your_firmware.status()                 # your code
           self.raw, self.reading, self.call = f.room_raw, f.room_filtered, f.call_for_heat
           self.firing, self.flow, self.lockout = f.ot_flame_on, f.ot_flow_temp, f.ot_fault
           self.months, self.wifi_up = f.battery_days / 30.44, f.wifi_connected
   ```

2. **Your rules, thresholds and guards.** Edit `RULES` and `EXPLAIN` with your product's limits, keeping the standard fault codes (Part 3). Keep the guards (`guard()`) and limits (`apply()`) **in the firmware**, where HEART cannot remove them: HEART asks, the firmware decides.

3. **Your presence and escalation routes.** Wire the "button" to a real press on the wall unit (and show the 6-character code on its display), and Stop to a long press. Change `escalate()` to notify the owner's app, and to show the problem on the thermostat itself, with no internet needed.

4. **Your privacy table.** Go through `PROPS` and give every value its level (Step 3 of the case study). Occupancy, windows and the schedule are `private`; only status and fault codes are `public`.

Then keep `monitor()` as it is, store the device ID, claim code, key and pinned site key in the hub's secure storage, and print the claim code and a QR code of `https://<your monitor site>/register?id=<ID>` on a label in the box. Serve the API over TLS, run the HEART-Rating client (`heart-rating/client/heart_rating.py`) and Part 9's tests, and claim what passed. The simulator's Card promises **Autonomy Level A1**: it demonstrates the A3 gate ("suggests and waits for a yes"), but Part 2 Section 8 says a claim of A3 needs HEART Full, which this one-file simulator does not implement in full. Case study, Step 10.

## 8. Test it

From the project's top folder, with Node 20+ and Python 3 with `pip install --user "jsonschema[format]>=4.18" referencing`:

```sh
python3 thermostatSimulator/test_e2e.py
```

It starts a HEART Hub site on this computer (port 8802), runs the simulator with every staged fault at 5x speed (API on port 43302) with approvals on the site allowed, signs in, registers the device through the site's own form, and checks for five minutes that:

- every message is valid against the official schemas, and public views hold only public data;
- out-of-range setpoints are refused `422`, the firmware's limits `403`, and a second change too soon `409 guard-failed`;
- a setpoint change waits: approving with the key is refused `403 presence-required`, it stays waiting with the setpoint unchanged, and runs after `touch button.press`;
- a waiting change can be denied without the button;
- the mock AI's suggestion is refused with the key, then expires, and nothing changes; its next one runs only after the owner approves it on the site, with an emailed code, and the device checks the signature (`remote-site`);
- Stop cancels a waiting change at once, and resuming needs the button;
- every staged fault reaches the owner's page with its fault code, no temperature, occupancy or schedule reaches the site, and the page stays private.

---

Licence: Apache-2.0. Part of HEART, the open standard at [heartcortex.ai](https://heartcortex.ai).
