# 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

```sh
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:

```sh
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

```sh
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

```sh
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

```sh
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-Key` is 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).
