Greenhouse irrigation simulator: a HEART-embedded system you can run and watch#
A demonstration only. It simulates a greenhouse, its irrigation controller and its HEART. It waters no real plants and drives no real pump. The "brain" in it is a mock: a few fixed rules standing in for an AI model, clearly labelled, so you can watch the safe loop of the Core Standard (Section 7: the brain proposes, the HEART decides) from start to finish.
One Python file, greenhouseSimulator.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 day: sunrise, clouds, noon heat, a hot spell, soil drying and being watered in three zones, a water tank that runs low and empties, a stuck soil probe, a stuck valve, an overheating pump and a lost Wi-Fi link.
It also shows the update function: a zone stays too wet, the HEART asks the mock brain, the brain proposes "water 20% less, in two steps", and the change waits for one press of the button at the greenhouse. Then it runs as a Trial: step by step, inside the owner's limits, watched, and kept or undone on its own.
The case study behind it, step by step for a real product, is guides/Greenhouse-Irrigation.md.
On a real device: Greenhouse Irrigation, 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 |
|---|---|
greenhouseSimulator.py | The simulator: greenhouse, controller, HEART, mock brain, Trials, local HEART API, HEART Hub reporting |
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 greenhouseSimulator.py --scenario all
It starts at once and prints the claim code, the local API, and one line every 15 seconds:
HEART Hub: register this simulated greenhouse
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=3f9c0a71b2d4e865
3. Enter the claim code K7Q2M-9XMBA-3RTVW-HP6DZ
4. Watch it at https://heartcortex.ai/device/3f9c0a71b2d4e865 (private to you until you share it)
10:26:01 HEART API on http://127.0.0.1:43278/heart/v1/pulse (owner key for private views: ...)
10:26:01 Brain key for POST /heart/v1/tending/proposals (scope heart.propose only): ...
10:26:16 06:15 · 15.8 °C · soil z1 48.5%/30m z2 52.7%/30m z3 41.0%/30m · tank 90% · pump 32 °C · link up · mode suggest · HEART degraded
z1 48.5%/30m is zone 1: soil moisture 48.5 %, watered 30 minutes a day. A greenhouse day is compressed: it lasts 1440 simulated seconds, so one greenhouse minute passes in one second (24 minutes for a day at --speed 1).
Zones 1 and 2 start too wet. Within seconds the HEART says so, asks the mock brain, and asks you:
10:26:11 HEART warning performance.overshoot: Zone 1 soil is wetter than its band. Zone 1 soil moisture averages 48.5 %, above the 35 to 45 % band: roots can rot.
10:26:11 HEART consults the MOCK brain about zone 1: zone 1 soil is wetter than its band
10:26:11 HEART asks a person: Zone 1 watering, 30 → 27 → 24 min/day, in 2 steps; undo to 30. Approval code D58683. To approve at the greenhouse: touch button.press
You are the person at the greenhouse. In the folder where the simulator runs:
touch button.press # approve the waiting change (one press approves every step and the undo)
touch button.hold # Stop: undo a running Trial, stop all automatic action
Nothing changes until you press. Then the Trial runs and tells you how it ended:
10:26:30 HEART: approved by a person (device-button). Trial started on set_watering_zone1
10:26:30 HEART Trial step 1/2 set_watering_zone1: 30 → 27 min/day
10:28:30 HEART Trial step 2/2 set_watering_zone1: 27 → 24 min/day
10:34:30 HEART Trial kept set_watering_zone1: Every expect held and the undo watch passed
10:34:30 HEART proposes a learned Playbook learned-set_watering_zone1-9760bf42. It stays proposed: it never runs until a person adopts it.
Stop it with Ctrl-C. The device ID, claim code and key are kept in greenhouse-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, or wait for the staged Wi-Fi loss at
--speed 1: 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.
Until you register it, the site stores nothing. After registering, the first report can take up to five minutes, 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 soil reading, the schedule, the tank level, a Proposal, a Trial or a door visit.
Approving from afar (optional, off by default). With --monitor-approvals --remote-site-physical, the waiting change is also offered on your device page ("Zone 1 watering: 30 → 27 → 24 min/day, in 2 steps; undo to 30"), and you can approve it there with a code emailed to you. The simulator acts only on a decision signed by the site key it pinned at its first hello, for the exact digest it is waiting on, at most 300 seconds old: the checks copied from heart-templates/templates/python/heart.py. Watering is physical, so without --remote-site-physical the site is never asked. A press at the greenhouse is the stronger proof; the remote site proves only that you could read your inbox.
3. The staged faults and Trials#
With --scenario, each fault starts at a set time, lasts a while, and is then fixed. Times are in simulated seconds (at --speed 1, real seconds; a greenhouse hour is 60 s).
| Scenario | Starts | Lasts | What you see |
|---|---|---|---|
valve | 1 min | 200 s | actuators.not-responding (error): zone 3 is watered and its soil does not get wetter |
link | 2.5 min | 150 s | connectivity.link-lost (warning). No reports while the Wi-Fi is down (at --speed 1 long enough for a Flatline) |
sensor | 3 min 20 s | 150 s | sensors.stuck (error): zone 3's probe reading freezes |
hot | 10 min | 260 s | A hot, dry spell (no alert of its own). It dries every zone, and undoes the running Trial on zone 2 |
pump | 16 min 40 s | 150 s | performance.too-hot (warning): a failing bearing. At 80 °C the firmware's own cut-out stops the pump |
tank | 18 min 20 s | 250 s | The refill fails and a hose splits: system.main-function-degraded (warning) below 20 %, then system.main-function-lost (critical) below 5 %, where the firmware locks the pump out so it never runs dry |
all | All of the above |
Without any scenario, the same things still happen now and then on random timers, but rarely (a hot spell every 2 to 5 greenhouse days, a stuck probe or valve every 5 to 12).
Always, with or without a scenario: zones 1 and 2 start too wet (performance.overshoot, warning), so the HEART consults the mock brain about zone 1, then, once that Trial has ended, about zone 2. With --scenario all and prompt presses of the button:
| Trial | What happens | Ends |
|---|---|---|
| Zone 1: 30 → 27 → 24 min/day | The soil comes back into its 35 to 45 % band | kept, and a learned Playbook is proposed (it never runs) |
| Zone 2: 30 → 27 → 24 min/day | The hot spell dries the soil below 30 % while the Trial is watched | undone: 30 min/day restored automatically |
Other alerts follow from the weather, as in life: after the hot spell the zones may be too dry (system.main-function-degraded, warning).
4. Options#
| Option | Default | Meaning |
|---|---|---|
--scenario | none | valve, link, sensor, hot, pump, tank (comma-separated) or all |
--speed | 1 | Simulated seconds per real second. --speed 5 runs everything in under 5 minutes (too fast for Flatline emails, which need real minutes) |
--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 | |
--monitor-approvals | off | Also offer waiting changes for approval on the monitor site |
--remote-site-physical | off | With --monitor-approvals: accept the site's approval for physical changes (remoteSitePhysical) |
--state-file | greenhouse-monitor.json | Where the device ID, claim code and key are kept |
--port | 43278 | The local HEART API (falls back to 7685; 0 for none) |
--seed | random | Repeat the same simulated weather |
--duration | until Ctrl-C | Stop after this many real seconds |
--command-center | off | The brain switch (Section 8): get the brain only through this Command Center, on a signed lease |
--mock-gateway | off | Put the mock brain behind a small upstream Reasoning Gateway on this port, for a local Command Center |
5. The local HEART API#
curl http://127.0.0.1:43278/.well-known/heart-card.json # the Card (capabilities only with the owner key)
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: tank status only
KEY=... # the owner key it printed
H="Authorization: Bearer $KEY"
curl -H "$H" http://127.0.0.1:43278/heart/v1/properties # every value: soil, schedule, tank, pump, door
curl -H "$H" http://127.0.0.1:43278/heart/v1/tending/consultations # what the HEART sent the brain: features, never raw samples
curl -H "$H" http://127.0.0.1:43278/heart/v1/tending/proposals # what the brain answered
curl -H "$H" http://127.0.0.1:43278/heart/v1/tending/trials # every Trial: plan, previous values, steps, outcome
curl -H "$H" http://127.0.0.1:43278/heart/v1/tending/rules # learned Playbooks, status proposed
curl -H "$H" http://127.0.0.1:43278/heart/v1/actions # every Action, with its digest and approval
curl -H "$H" http://127.0.0.1:43278/heart/v1/rhythm # Tending events (internal) and door visits (private)
curl -H "$H" -X POST http://127.0.0.1:43278/heart/v1/tending/stop # Stop, like holding the button
curl -H "$H" -X POST -d '{"decision":"approve"}' http://127.0.0.1:43278/heart/v1/actions/<id>/decision # 403 presence-required
curl -X PUT http://127.0.0.1:43278/heart/v1/properties # 403: no direct changes
A remote brain can deliver its own Proposal with the brain key (scope heart.propose only, never the owner's key):
curl -H "Authorization: Bearer $BRAIN" -H "Content-Type: application/json" -X POST \
http://127.0.0.1:43278/heart/v1/tending/proposals -d '{"proposal": {
"id": "7d0c1c1e-4f7a-4a51-9d7e-0f6f3a2b1c90", "target": "set_watering_zone3",
"arguments": {"minutes_per_day": 27}, "basis": "Zone 3 looks wet.", "createdAt": "2026-11-12T10:00:00Z",
"expect": [{"signal": "soil_moisture_zone3", "feature": "mean", "op": "<=", "value": 45, "windowSec": 240, "withinSec": 360}],
"undoIf": {"any": [{"signal": "soil_moisture_zone3", "op": "<", "value": 30, "withinSec": 360}]}}}'
It answers 202 with the waiting Action, or refuses the first rule it breaks, in the order of Core Standard 7.3: 400 invalid-request (no expect or undoIf, unknown signal), 409 autonomy-stopped, 404 not-found, 403 caller-not-allowed (for example flush_lines, which only a person at the panel may run), 422 out-of-range (outside 10 to 60, or outside the owner's envelope of 15 to 45 with steps of at most 3), 422 cannot-undo, 409 conflict (a Trial is already live on that zone), and 429 rate-limited (the envelope's maxPerDay or cooldownSec).
Every message it serves is a valid HEART message: test_e2e.py checks each one against the official schemas, and each Consultation against consultation.schema.json.
6. How the code is built#
Greenhouse ──► Controller ──(reads)──► Heart ──► local HEART API (127.0.0.1)
sun, air, tank, pump, valves, watches, └► HEART Hub (outbound only)
soil schedule, own limits consults ──► MockBrain (Consultation in, Proposal out)
▲ │
└── set_minutes() ─────┘ only in a Trial a person approved; the firmware still checks it
| Part | In the file | In a real product |
|---|---|---|
Greenhouse | Sun and temperature over a day, clouds, humidity, hot spells; soil moisture in three zones that falls with evaporation and plant uptake and rises with watering; door visits | The real greenhouse |
Controller | The firmware: tank and float valve, pump, three valves, the schedule (minutes per zone per day, spread over the day, more at noon), and its own limits: tank interlock, 60 minutes a day per zone, thermal cut-out | The irrigation controller and its firmware. Never part of HEART |
Probes | The soil probes as the controller reads them (one can stick) | The real probes |
Heart | RULES turn readings into alerts; escalate() tells a person; consult(), accept(), run_trials(), learn() and stop() are the safe loop of Core Standard 7 | The code you add, on the controller or a small computer beside it |
MockBrain | Fixed rules dressed as a model: reads the Consultation, answers a Proposal | A model in the Model Slot, on a HEART Gateway, or in the cloud |
monitor() | Hello, then reports; follows nextSec; stops for a wrong key; with the flags, offers waiting changes and checks signed decisions | The same, unchanged |
handler() | The HEART API: Card, Pulse, alerts, properties, Rhythm, Tending, Trials, Actions; Proposals with the brain key; Stop; approvals over the network refused | The same, behind TLS on a network |
The Trial in detail (Core Standard 7.3 to 7.6):
- Detect, reflex first.
performance.overshooton a zone. No adopted Playbook covers it, so the HEART consults, about one problem at a time. - Consultation. Features of the zone's soil moisture, the air and the tank over 10 minutes (value, min, max, mean, trend), each with its privacy level; the one candidate,
set_watering_zoneN, with its current value (read by the HEART), declared range, envelope, guards andphysical: true; and earlier Trials on it. - Proposal. The mock brain answers: steps 27 then 24 with
waitSec120;expectthat the 4-minute mean ends up ≤ 45 % and ≥ 35 % within 6 minutes of the last step;undoIfthe reading falls below 30 % within 6 minutes. It sends no current value. - Check. In the order of 7.3, plus the owner's envelope. The HEART reads the value to restore (
previous) from the controller. - One approval. The first Action (initiator
ai, riskdestructivebecause a physical change started by an AI counts as destructive) has a digest over its arguments, every step (plan) and the restore values (restore). One press approves all of it. Later steps and the undo are Actions whoseapprovedBynames the first: they pass the guards, not the gate. - Try and watch. Each step passes the guard (the controller answers), stays inside the envelope, and goes through the firmware's own check.
undoIfis watched from the first step. - Verify.
keptonly when everyexpectheld and the longestundoIf.withinSechas passed. Otherwise undo: restoreprevious. If the undo cannot run,undo-failed, a critical alert, and automatic action stops. - Remember. A kept Trial becomes a learned Playbook with
status: proposed,enabled: false, the opposite of the firstexpectas its trigger (mean > 45), and an envelope from what the Trial set. Adopting it (PUT /heart/v1/tending/rules/{id}) is not part of this demo (501): it stays proposed and never runs.
States move only along awaiting-approval → running | refused | stopped, running → kept | undoing | refused, undoing → undone | stopped | undo-failed; anything else raises an error in the simulator. Each Trial's ext records its state history and the simulated second of each step.
Two clocks. Durations in Proposals and Trials (waitSec, withinSec, windowSec, cooldownSec, maxPerDay) count simulated seconds. Timestamps are the computer's clock. At --speed 5, a waitSec of 120 is 24 real seconds.
7. What code to add to make a real product HEART-embedded#
For a real irrigation system, HEART runs on the controller itself (if it is an embedded Linux board or an ESP32) or on a small computer beside it that already talks to it (Modbus, RS-485, its local API). Either way it reads the controller and, for the one capability it offers, writes only the schedule value, through the same path the controller's own panel uses. Pick a starting point, then add four things.
Starting points
| Your controller | Start from |
|---|---|
| Embedded Linux, or a small computer beside the controller | This file's Heart, monitor() and handler(); or the Python template heart-templates/templates/python (with its ask_owner() remote approvals); or the Rust Core heartd (heart-core-rs), whose crates/heart-core/src/consult.rs is a full implementation of Consultations, Proposals and Trials, for HEART Full |
| A microcontroller with Wi-Fi | The ESP32 Full starter heart-templates/templates/esp32-full: its signal table, main-job check, button for approvals and Stop, and Monitor reporting |
| A Node.js controller app | The JavaScript template heart-templates/templates/javascript-node |
The four things you add
- Read the controller. Replace
Greenhouse,ControllerandProbeswith a reader for what your controller already knows. The only write is the schedule value, through the controller's own interface, which still checks its limits.
``python class ControllerLink: """CHANGE: fill these from your controller. Read only, except set_minutes().""" def refresh(self): c = your_controller.status() # your code: Modbus, serial, local API self.minutes = c.watering_minutes_per_zone self.tank, self.pump_temp, self.cutout = c.tank_percent, c.pump_celsius, c.thermal_cutout self.valve, self.pump_on = c.valves_open, c.pump_running self.online, self.link_down = c.answers, not c.internet_up def set_minutes(self, zone, minutes): your_controller.set_schedule(zone, minutes) # the firmware refuses what is outside its limits ``
- Your rules and thresholds. Edit
RULES,BAND,TANK_LOWandPUMP_HOTfrom your crops and your controller's specification. Keep the standard fault codes (Part 3). - Your escalation route, and your button. Change
escalate()to reach the grower (app, SMS, a light on the controller). Wirebutton()to a real button on the controller: a press approves the waiting change, a long hold is Stop. A press over the network is never accepted as presence. - Your brain and your privacy table. Replace
MockBrainwith a real model (in the Model Slot, a Gateway, or the cloud), and keep its answer a Proposal that passesaccept(). Go throughproperties()and give every value its level (Step 3 of the case study).
Then keep monitor() as it is, store the device ID, claim code and key in the controller's secure storage, print the claim code on a label, serve the API over TLS (or keep it on loopback), run the HEART-Rating client and Part 9's tests, and claim HEART Basic, Autonomy Level A1. The Trial loop is a demonstration of Core Standard 7, not a conformance claim: see the case study, Step 10.
8. The brain switch#
A model can be tricked, poisoned or simply wrong. So the brain can be taken away from outside, even when the greenhouse cannot be reached, and without the brain's agreement (API Standard 7.4, Core Standard 7.8, HEART Command Center). It is off by default. Turn it on with --command-center. From the project's top folder:
# a Command Center on this computer (heart-monitor), whose model is the mock brain behind the simulator's gateway
node heart-monitor/tools/build-worker.mjs
HEART_TEST_CC=1 HEART_TEST_CC_MODELS='[{"id":"mock/greenhouse","version":"1","uses":["explain","propose"]}]' \
HEART_TEST_CC_PROVIDER=http://127.0.0.1:8805 node heart-monitor/test/serve.mjs 8787
python3 greenhouseSimulator/greenhouseSimulator.py --monitor-url http://127.0.0.1:8787 \
--command-center http://127.0.0.1:8787 --mock-gateway 8805
Register the greenhouse as in Section 2, then on its Command Center page (/commandcenter/device/<ID>) give it a brain: the model mock/greenhouse 1, the uses explain, propose and plan (the mock brain answers in two steps, which the HEART counts as a plan), and a lease of 30 seconds.
What changes:
- The lease is the switch. The greenhouse asks the brain only while it holds a brain lease: which models, for what, until when, signed with the key it pinned at its first hello. It renews when a third is left. It counts the lease on its own clock, so a wrong clock can only shorten it. To cut the brain, the Command Center simply stops renewing.
- No keys here. The mock brain moves out of the HEART, behind the gateway. The Command Center asks it, checks the answer and signs it for exactly the Consultation that was sent, under the current lease.
- Every answer is checked before it is used: signed with the pinned key, for a request this HEART sent and has not had answered, under the current lease, from a model the lease names. Anything else is rejected, counted (
rejectedAnswers) and raised asai.answer-rejected. Three in an hour and the HEART cuts its own brain. - Brainless is safe. When the lease ends, or a signed
brain-offarrives, the HEART stops asking at once, cancels changes still waiting for approval, and undoes a running Trial the brain planned. The controller keeps watering on its schedule, the alerts keep coming, and what a person approved carries on. The Pulse saysautonomy.limitedBy: "brain-cut", and an alert says why:ai.brain-revoked(cut) orai.lease-expired(ran out). - Cutting is easy, restoring is hard. The owner cuts with one click; the greenhouse hears
brain-offat its next renewal. Restoring needs an emailed code on the Command Center and the owner at the greenhouse: until then every new lease is refused (why: "restore-pending"). A lease that merely ran out, for example while the link was down, is not a cut: the next lease is taken without anyone.
curl http://127.0.0.1:43278/heart/v1/brain # public: {"state": "brainless", "why": "brain-off"}
curl -H "$H" http://127.0.0.1:43278/heart/v1/brain # owner: the lease, rejectedAnswers, restoreNeedsOwner
curl -H "$H" -X POST http://127.0.0.1:43278/heart/v1/brain/restore # after a cut: then touch button.press
curl -X POST -d @signal.json http://127.0.0.1:43278/heart/v1/brain/signal # a signed signal: it can only lower risk
The Card lists the brain module. Its brain block names the Command Center only when it is reached over https, as the Card schema asks.
9. Test it#
From the project's top folder, with Node 20+ and Python 3 with pip install --user "jsonschema[format]>=4.18" referencing:
python3 greenhouseSimulator/test_e2e.py
It starts a HEART Hub site on this computer (port 8803), runs the simulator with every staged fault at 5x speed (API on port 43303), signs in, registers the device through the site's own form, and for about four and a half minutes checks that: every message is valid, and every Consultation; public views hold only public data; the brain's Proposal waits for the button and nothing runs before it; an approval over the network is refused; the digest covers every step and the undo; bad Proposals are refused with the right problem; Trial A is kept and its learned Playbook stays proposed; the hot spell undoes Trial B and restores 30 min/day; Stop undoes a running Trial and then refuses new Proposals; every Trial moved only along allowed transitions and every step stayed in the envelope and waited its waitSec; every staged fault reaches the owner's page with its fault code; no reading reaches the site; and the page stays private.
Beside it, on its own ports (8804 to 8806, API 43304), the brain switch scenario runs at 6x speed with a 30-second lease, in about two minutes. A small proxy plays the network between the greenhouse and the Command Center. It checks that: with no lease the greenhouse asks no brain; the owner turns the brain on and the lease is signed with the pinned key; an answer forged on the path and signed with another key is rejected, counted and not used, and so is an unsigned Proposal; the next Proposal is signed for exactly the Consultation sent; after the button, its Trial runs; (a) the owner's cut reaches the greenhouse within one lease, the running Trial is undone, no brain is asked while a zone is still too wet, and watering goes on; (b) after the Command Center restores, the greenhouse refuses every new lease until the owner presses the button, then takes the next one, once; signals are acted on only when signed, fresh and new; (c) with the Command Center silent, the greenhouse goes brainless by itself when the lease runs out (ai.lease-expired), and takes the next lease without its owner; and every message stays valid. python3 greenhouseSimulator/test_e2e.py --brain-only runs this scenario alone.
Licence: Apache-2.0. Part of HEART, the open standard at heartcortex.ai.