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 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, 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 (Guide 5).
On a real device: Thermostat, for real 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.
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#
- Sign in. Open
https://heartcortex.ai/loginand enter your email address. A 6-digit code arrives; type it in. There are no passwords. - 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. - 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 athttps://heartcortex.ai/devices. - Share, if you want. The page is private until you make a share link or make it public.
- 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 1that is long enough for a Flatline, at--speed 5it 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:
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:
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
- Read the thermostat. Replace
House,ThermostatandBoilerwith 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 ``
- Your rules, thresholds and guards. Edit
RULESandEXPLAINwith 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. - 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. - Your privacy table. Go through
PROPSand give every value its level (Step 3 of the case study). Occupancy, windows and the schedule areprivate; only status and fault codes arepublic.
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:
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 limits403, and a second change too soon409 guard-failed; - a setpoint change waits: approving with the key is refused
403 presence-required, it stays waiting with the setpoint unchanged, and runs aftertouch 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.