HEART Full starter for ESP32#
Template: heart/embeddedstandard/esp32-full/v1.2611.3 · Licence: Apache-2.0 (SPDX-License-Identifier: Apache-2.0)
This template turns an ESP32 into a small HEART server for its own product. It does four things:
| What it does | How it stays safe | |
|---|---|---|
| F1 | Serves the HEART API over HTTPS on port 43278, and announces itself on the local network as _heart._tcp | TLS only. The announcement repeats only what the public Card shows. |
| F2 | Knows every signal in the product, from the signal table signals.json: its unit, range and privacy | A signal not in the table is invisible. Public signals for anyone; internal and private ones only with the owner key; never-share ones never leave the chip. |
| F3 | Asks a cloud AI model to explain an alert, through a Reasoning Gateway, or with the brain module through a Command Center under a brain lease (5b) | No model API keys on the chip. Private signals are never sent. Every request leaves a receipt with a salted commitment. A daily budget. With the brain module, an answer is used only if its signature checks out. The model's words are shown to people as a suggestion and never acted on. |
| F4 | Takes commands from people: change a signal, Stop, Resume | Needs the owner key. Anything physical, and resuming after Stop, waits for a press of the button on the device itself. A key alone, or an AI that holds it, can never approve. |
It also restarts and sleeps on request (module lifecycle, API Standard 13.2 rule 3), with the owner key:
| Request | What happens |
|---|---|
POST /heart/v1/lifecycle/restart | Once heart_port_lifecycle_ready() says the product is ready, esp_restart(). If it is not ready by timeoutSec (default 60), the restart fails and is never forced |
... restart with {"mode": "force"} | Destructive: waits for a press of the button, like a physical change |
POST /heart/v1/lifecycle/sleep with {"durationSec": 600} or {"wakeAt": "..."} | Deep sleep (esp_deep_sleep_start()), woken by the timer or the button (wakeSources: ["timer", "button"]). It cannot answer while asleep: the Action's nextReachableAt says when it will |
The last restart or sleep is kept in RTC memory, so afterwards GET /heart/v1/actions/{id} says it succeeded, and a Stop still holds after a restart. Change heart_port_lifecycle_ready() in port_esp32.c to save the product's work first. On the ESP32-C3 and C6, the button must be on a pin that can wake the chip from deep sleep (GPIO 0 to 5 on the C3).
It works offline: noticing problems and raising alerts need no network (Autonomy Level A1).
Status: what is tested, and what is not#
| Part | Status |
|---|---|
main/heart_core.c (all four functions) | Tested on Linux through host/port_linux.c: the HEART Basic contract, schema checks on every reply, the HEART-Rating (8.9 Good, provisional), and 30 tests of F2–F4, lifecycle and the brain lease, also under AddressSanitizer. Also run against the real Command Center of heart-monitor/ by tools/test_brain.py. |
main/port_esp32.c and the ESP-IDF build files | Not yet compiled or run on a chip. They follow the ESP-IDF 5.2 APIs, but have not been proven. Please report what you find. |
Files#
| File | What it is | Change it? |
|---|---|---|
signals.json | The product's signals. Make it with heart_signals.py (below). | Yes |
main/heart_signals.h | The sealed table as C, generated from signals.json | Regenerate, never edit |
main/port_esp32.c | Wi-Fi, HTTPS, mDNS, ADC and GPIO, the button, the LED, the gateway | Yes: lines marked CHANGE |
main/heart_core.c, heart_core.h | Everything HEART does: portable C99, no heap | No |
main/Kconfig.projbuild | Settings shown in idf.py menuconfig | Through menuconfig |
host/port_linux.c | The same core on a computer, with simulated parts, for trying and testing | No |
1. Describe your product's signals#
python3 heart_signals.py draft --system com.yourco.product --header firmware_signals.h > signals.json
python3 heart_signals.py check signals.json # decide every open question
python3 heart_signals.py seal signals.json --by "Your Name"
python3 heart_signals.py export signals.json --out main/heart_signals.h
heart_signals.py is in the templates' tools/ folder. The HEART refuses to start with a table that is not reviewed, that makes a safety signal writable, or that names an alert code that is not a fault code.
A signal with an outside-range alert raises the code and severity its table names, such as power.battery-low and error. Without them it raises sensors.out-of-range as a warning. heart_signals.py checks each code against the fault-code pattern of Part 3, Section 5.
Each signal's source tells the port where to read it:
In signals.json | In the C table | Read by |
|---|---|---|
{"protocol": "on-chip", "channel": 3}, a number | adc:3 | ADC1 channel 3, then scale and offset |
{"protocol": "on-chip", "channel": 4}, a boolean | gpio:4 | GPIO 4 |
{"protocol": "on-chip", "symbol": "fan_rpm"} | sym:fan_rpm | The firmware variable listed in SYMBOLS[] in port_esp32.c |
| Modbus or CAN | modbus:..., can:... | Not built in: add esp-modbus or the TWAI driver to heart_port_read() |
2. Your own certificate#
Every device needs its own TLS key. Templates never include a private key. For development:
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=heart-device.local"
For products, write each device's own certificate and key to NVS (namespace heart, blobs cert and key) on the production line. The port uses them instead of the development files.
3. Build and flash#
idf.py set-target esp32 # or esp32s3, esp32c3, esp32c6
idf.py menuconfig # HEART: Wi-Fi, gateway URL, button and LED pins
idf.py build flash monitor
At first start, the console shows the owner key once. Write it down: the device keeps only its SHA-256 (the Basic credential profile, API Standard 8.2). To make a new one, hold the button while powering on, for 10 seconds; the old key stops working at once.
The Wi-Fi password and gateway token in menuconfig are for development. Products should get them through provisioning, never in a shared sdkconfig.
4. Use it#
KEY=... # the owner key
curl -k https://heart-ab12cd.local:43278/.well-known/heart-card.json
curl -k https://heart-ab12cd.local:43278/heart/v1/properties # public signals
curl -k -H "Authorization: Bearer $KEY" https://heart-ab12cd.local:43278/heart/v1/properties
curl -k -H "Authorization: Bearer $KEY" -X PUT -d '{"value": 60}' \
https://heart-ab12cd.local:43278/heart/v1/properties/org.example.fan-controller.fan_speed_target
# 202: waiting. The LED blinks and the console shows a 6-character code. Check it, then press the button.
curl -k -H "Authorization: Bearer $KEY" -X POST https://heart-ab12cd.local:43278/heart/v1/tending/stop
(-k skips the certificate check: only for a development certificate you made yourself.)
| Request | Who | What happens |
|---|---|---|
GET /.well-known/heart-card.json, /heart/v1/pulse, /heart/v1/arrhythmias | Anyone | Public parts only. The owner also sees summaries and AI explanations. |
GET /heart/v1/properties[/{name}] | Anyone: public signals. Owner: also internal and private. | A signal you may not see is "not found". |
PUT /heart/v1/properties/{name} {"value": ...} | Owner | Checked against the type and range. Non-physical: done at once. Physical: an Action waits for the button. A read-only or safety signal answers 409 conflict. |
GET /heart/v1/actions[/{id}], POST .../decision {"decision": "deny"} | Owner | deny and cancel need no proof. approve is refused: without presence with presence-required, with a proof that does not verify with presence-invalid, and with a valid remote-site proof (below) with capability-not-allowed, because the owner key always started the Action and never approves its own. Press the button, approve on the HEART Hub, or send the request again with actionId and presence. |
POST /heart/v1/lifecycle/restart, .../sleep | Owner | Needs an Idempotency-Key header. A repeat with the same key and body returns the first Action and runs nothing; the same key with another body answers 422 idempotency-conflict. actionId names the Action; with presence too, a verified proof runs it at once. |
POST /heart/v1/tending/stop | Owner, or hold the button 3 s (no login, no network) | Stop at once. Waiting actions are cancelled. |
GET /heart/v1/tending/autonomy | Anyone | The heart.control: the ceiling max, the current level (the lower of max and the promised level 4) and the mode that follows from it: act at 4, observe at 2 or below. Stop sets max to 2. |
PUT /heart/v1/tending/autonomy {"max": 4} | Owner, then the button | Raising max is destructive, so a person must be there. A max of 0 to 2 is Stop, at once. The device offers 0 to 2 and 4; any other value answers 400 invalid-request. Other control operations answer 501. The owner's Pulse shows autonomy with max, current, and limitedBy: stopped while max is below 4. |
GET /heart/v1/receipts | Owner | One receipt for every analysis request |
5. Analysis through a Reasoning Gateway#
Set the gateway URL (https only) and this device's gateway token in menuconfig. When an alert opens, the device sends one Reason request (API Standard, POST /heart/v1/reason) with the fault code and the public and internal signal values. Private and never-share signals are never included. The gateway holds the model provider's keys, chooses the model and keeps to its budget. Without a gateway, everything else still works.
5a. Watching it from anywhere: HEART Hub#
Turn on HEART Hub in menuconfig to report health to https://heartcortex.ai (or your own site). At first start the console shows a device ID and claim code. Register the device with them, and its page at https://heartcortex.ai/device/<ID> shows its health to you alone until you share it. Only the public Pulse and public alerts are sent, over HTTPS, outbound only: the site can never send the device a command. For products, print the claim code and a QR code on a label instead of the console.
With CONFIG_HEART_MONITOR_APPROVALS, the owner may also approve a waiting change on the site. The device accepts the site's signed decision (presence method remote-site) in the reply to its report, for a signal write. A restart or sleep request may carry the same proof as presence, with actionId, and then runs at once. Neither is accepted for a brain restore or a resume. It checks the signature with the key it pinned, the Action's ID and digest, and that the decision is at most 300 s old. With its clock not set (before SNTP answers) it accepts no approval from the site, but a signed deny still counts.
5b. An AI brain on a lease (brain module)#
Turn on HEART Hub and then AI brain (HEART_BRAIN) in menuconfig. The monitor site is then also the device's Command Center (API Standard 7.4, spec/HEART-Command-Center-1.0.md): it holds the model keys, and the site key the device pinned at its first hello signs every lease, answer and signal. On the device's page there, the owner chooses the model and turns the brain on.
| What | How it works |
|---|---|
| The lease | The device asks for one every few seconds until it has one, then renews when a third is left. It checks the signature, that it is for this device, and that it is no longer than 300 s, counted on its own clock. It is kept in RAM only: after a restart or deep sleep, the device is brainless until it renews. |
| Analysis (F3) | Only while the lease allows explain. The request goes to /cc/v1/devices/{id}/reason, and the answer is shown only if it is signed for that request, under that lease, by a model the lease names. A forged answer is rejected, recorded as ai.answer-rejected, and three in an hour cut the brain. |
| Brainless | When the lease runs out (even with the link blocked), or a signed brain-off arrives (in a renewal reply, or at POST /heart/v1/brain/signal), the device stops asking at once and keeps working without AI. GET /heart/v1/brain shows state and why to anyone; the owner's Pulse shows autonomy.limitedBy: brain-cut. |
| Signals | brain-off, revoke-model, revoke-use and autonomy-stop (which is Stop) only lower risk. A signal not signed with the pinned key, for another device, over 300 s old or seen before is refused. Before SNTP sets the clock, the device cannot check a signal's age. It still acts on a signal whose signature and system check out, and refuses a repeat by its ID. |
| Auto mode | This device has none. PUT /heart/v1/brain/offline with {"enabled": false} answers the brain view, and with {"enabled": true} 409 conflict. The body may also carry maxSec, and actionId with presence; anything else answers 400. |
| Restoring | After a cut, a new lease waits (why: restore-pending) until the owner asks POST /heart/v1/brain/restore with the owner key and presses the button. A cut that waits for the owner is kept through a restart (in RTC memory, so not through a power loss: the Command Center still refuses until the owner restores it there too). |
On a computer: HEART_BRAIN=1 HEART_MONITOR_URL=http://127.0.0.1:8787 ./heart, with a local site started as HEART_TEST_CC=1 node heart-monitor/test/serve.mjs 8787.
6. Try it on a computer first#
cc -O2 -std=c99 -Wall -Imain -o heart host/port_linux.c main/heart_core.c main/heart_ed25519.c -lm
./heart # http://127.0.0.1:43278
touch fault.flag # the main job "fails"
echo 99 > sim.room_temp # a signal goes out of range
touch button.press # a short press; button.hold: a long press (Stop)
HEART_GATEWAY_PORT=8099 ./heart # send analysis to a gateway on 127.0.0.1:8099
HEART_MONITOR_URL=http://127.0.0.1:8787 ./heart # report to a local monitor site (heart-monitor/test/serve.mjs)
Known gaps#
- The brain module is tested on Linux only. On the chip it uses the same HTTPS client as HEART Hub, and a larger task stack (16 KB) for its checks; neither has been run on a chip.
- The ESP-IDF port is untested (above).
- The owner key is a local secret, not a HEART token. For HEART Connected (tokens with audience and key binding), put a HEART Gateway in front of the device.
- The device does not yet sign its Card, or check a gateway's signed caller headers.
- The clock shows 1970 until SNTP answers.
- Signals are checked against their range, but not yet for being stuck.
- An
Idempotency-Keyis kept with the Action it created, so only the keys of the last 8 Actions are known, not every key of the last 24 hours (API Standard 5.5).