HEART Embedded Systems logoHEART

Birdhouse camera: on a real device#

A nest-box camera with a HEART on its own ESP32-S3: the parts, the wiring, the code, how to build and flash it, the factory steps, and what its owner sees.

For illustration only. The hardware below is an assumption, chosen so the steps can be concrete. Your board, pins and parts will differ. This product has not been built. The ESP32 Full starter's core is tested on a computer, but its ESP32 port has not yet been compiled or run on a chip. Check every step on your own bench.

PlacementA: inside the product. The HEART runs on the camera's own chip, in the same firmware.
Start fromThe ESP32 Full starter (heart/embeddedstandard/esp32-full/v1.2611.3)
ClaimHEART Basic, Autonomy Level A1. The zoom Playbooks need HEART Full (see Section 9).
Try it firstThe simulator and the design case study

1. The device we assume#

PartAssumed choiceNotes
BoardAn ESP32-S3 camera board with 8 MB PSRAM, 8 MB flash and a microSD slot, such as the Seeed Studio XIAO ESP32S3 SenseThe S3 has spare ADC1 pins. On the older ESP32-CAM, the camera and card use most pins, and ADC2 cannot be read with Wi-Fi on.
CameraOV2640 or OV3660 on the board, infrared-sensitive (no IR-cut filter)Your firmware drives it with Espressif's esp32-camera component
MotionA PIR sensorWakes the camera to take pictures
Infrared lightTwo 850 nm IR LEDs on a logic-level MOSFET, dimmed by PWMBirds hardly see 850 nm
Box temperatureA TMP36 analogue sensor inside the boxToo hot is bad for chicks and for the camera
PowerA 3.7 V 3000 mAh Li-ion cell, a 2 W 5 V solar panel and a solar charger board; the battery voltage through a 2:1 dividerBig enough to stay awake (Section 8)
StorageA 32 GB microSD cardPictures wait here when Wi-Fi is down
ButtonA sealed push button on the outside of the box, wired to groundApproves physical changes; held 3 seconds it is Stop
Status lightA green LED outside the box, under the roofBlinks a fault with no internet, and blinks when a change waits for the button
NetworkThe home Wi-Fi, 2.4 GHzReports to HEART Hub, outbound only

Example pin map#

SignalPinIn signals.json
Battery voltage (2:1 divider)GPIO1, ADC1 channel 0"source": {"protocol": "on-chip", "channel": 0, ...}
Box temperature (TMP36)GPIO2, ADC1 channel 1"source": {"protocol": "on-chip", "channel": 1, ...}
PIRGPIO3Read by your firmware, not by HEART
ButtonGPIO4, internal pull-upCONFIG_HEART_BUTTON_GPIO=4
IR LED MOSFET (PWM)GPIO7Set by your firmware only, through ir_level
Status LEDGPIO8CONFIG_HEART_LED_GPIO=8

GPIO4 is an RTC pin on the S3, so the button can also wake the chip from deep sleep. Check your own board's pin map: camera and card pins differ from board to board.


2. Where everything runs#

┌────────────────── ESP32-S3: one firmware image ──────────────────┐
│ Your camera firmware              HEART (from the ESP32 starter)  │
│  camera task: PIR → picture → SD   heart_tend task: checks every  │
│  vision: birds, eggs, confidence   30 s, raises alerts, blinks LED │
│  IR light (PWM), zoom              HTTPS API on port 43278         │
│        ▲  shared variables (SYMBOLS[])   HEART Hub reports ──────────► heartcortex.ai
│        └── IR and zoom only through the gate (button for IR)     │      (outbound only)
└──────────────────────────────────────────────────────────────────┘
NVS (encrypted): device ID, claim code, key · owner key · TLS certificate and key · last Stop
  • One image. HEART is not a separate program. Its files are compiled into the camera's own ESP-IDF project and run as FreeRTOS tasks beside the camera task.
  • Pictures never pass through HEART. It sees only counts and confidences that the firmware publishes, such as "2 birds, eggs seen at 0.86".
  • The IR light is the only physical output it may touch, and only after a person presses the button.

3. The code#

Copy the starter, then merge your camera firmware into it, or copy the starter's main/ files into your existing project.

FileWhat you do
main/port_esp32.cChange the lines marked CHANGE (below)
signals.jsonWrite it with heart_signals.py, then export main/heart_signals.h
main/heart_core.c, heart_core.h, heart_ed25519.cCopy unchanged
main/Kconfig.projbuild, sdkconfig.defaultsCopy; set values in idf.py menuconfig
Your camera codeAdd as more source files or a component in main/, started from app_main()

3.1 Identity#

#define SYSTEM_ID "com.example.birdhouse"   /* CHANGE, with signals.json */
#define NAME      "Birdhouse Camera"        /* CHANGE */
#define VERSION   "1.0.0"                   /* CHANGE */

3.2 The values your firmware shares with HEART#

/* CHANGE: the firmware variables your signal table names as sym:... sources. The camera task writes them. */
volatile float sd_free_pct = 100, ir_level = 0, zoom = 1, birds_in_view = 0, egg_confidence = 0, pictures_today = 0;
static const struct { const char *name; volatile float *value; } SYMBOLS[] = {
    {"sd_free_pct", &sd_free_pct}, {"ir_level", &ir_level}, {"zoom", &zoom}, {"birds_in_view", &birds_in_view},
    {"egg_confidence", &egg_confidence}, {"pictures_today", &pictures_today}, {0, 0}};

The camera task updates these after each picture and each card check. HEART reads them; it writes only ir_level and zoom, and only through its gate.

3.3 The signal table (signals.json, the important lines)#

{"name": "com.example.birdhouse.battery_pct", "title": "Battery", "type": "number", "unit": "%",
 "range": {"min": 20, "max": 100}, "source": {"protocol": "on-chip", "channel": 0, "scale": 0.126, "offset": -250},
 "privacy": "public", "alerts": [{"when": "outside-range", "code": "power.battery-low", "severity": "warning"}]},
{"name": "com.example.birdhouse.box_temp", "title": "Temperature in the box", "type": "number", "unit": "Cel",
 "range": {"min": -10, "max": 40}, "source": {"protocol": "on-chip", "channel": 1, "scale": 0.0757, "offset": -50},
 "privacy": "internal", "alerts": [{"when": "outside-range", "code": "performance.too-hot", "severity": "warning"}]},
{"name": "com.example.birdhouse.sd_free_pct", "title": "Free space on the card", "type": "number", "unit": "%",
 "range": {"min": 10, "max": 100}, "source": {"protocol": "on-chip", "symbol": "sd_free_pct"},
 "privacy": "internal", "alerts": [{"when": "outside-range", "code": "storage.nearly-full", "severity": "warning"}]},
{"name": "com.example.birdhouse.birds_in_view", "title": "Birds in view", "type": "integer",
 "source": {"protocol": "on-chip", "symbol": "birds_in_view"}, "privacy": "private"},
{"name": "com.example.birdhouse.ir_level", "title": "Infrared light", "type": "number", "unit": "%",
 "range": {"min": 0, "max": 60}, "source": {"protocol": "on-chip", "symbol": "ir_level"},
 "privacy": "internal", "writable": true, "physical": true}

The scale and offset turn the raw ADC reading into a percentage or degrees. The starter reads 12 bits at 12 dB attenuation: 0 to 4095 for about 0 to 3.1 V, so one step is about 0.76 mV.

  • Battery: the cell is empty at 3.0 V and full at 4.2 V, which the 2:1 divider makes 1.5 to 2.1 V, raw 1981 to 2774. So percent = raw × 0.126 − 250. A Li-ion cell is not linear, so this is a rough guide: a fuel-gauge chip on I²C is better for a product.
  • Temperature: the TMP36 gives 0.5 V at 0 °C and 10 mV per °C. So °C = raw × 0.0757 − 50.

Calibrate both on your bench, with a multimeter and a thermometer, because every chip's ADC differs a little. The location is not in the table at all: it stays in the owner's app.

Then seal it. The HEART refuses to start with a table nobody reviewed:

python3 heart_signals.py check signals.json
python3 heart_signals.py seal signals.json --by "Your Name"
python3 heart_signals.py export signals.json --out main/heart_signals.h

3.4 The main job, escalation and lifecycle#

/* CHANGE: return 1 while the product does its main job: taking pictures when a bird comes in. */
int heart_port_main_job_ok(const char **why) {
    if (!camera_ready)          { *why = "The camera did not start."; return 0; }
    if (motion_without_picture) { *why = "Motion was seen, but no picture was taken."; return 0; }
    if (sd_write_failed)        { *why = "Pictures cannot be saved to the card."; return 0; }
    return 1;                   /* an empty box is not a broken camera */
}

/* CHANGE: tell a person, with no internet: blink the LED outside the box three times a second. */
void heart_port_escalate(const char *summary) { ESP_LOGE(TAG, "escalate: %s", summary); fault_blink = 1; }

/* CHANGE: before a restart or sleep, finish the picture being written and close the card. */
int heart_port_lifecycle_ready(const char *operation) { (void)operation; return !sd_write_in_progress; }

4. Build and flash#

You need ESP-IDF 5.2 or later, and a USB-C cable.

idf.py set-target esp32s3
idf.py menuconfig

In menuconfig, under HEART:

SettingValue for development
Wi-Fi name and passwordYour test network. Products get these from the setup app, never from sdkconfig.
HEART HubOn
HEART Hub URLhttps://heartcortex.ai, or your own monitor site
Approvals on the monitor siteOff. The button is the only way to approve the IR light.
Button GPIO, LED GPIO4 and 8
Gateway URL and AI brainOff, unless you want alerts explained by a model (Section 9)

Make a development certificate, then build and flash:

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=birdhouse.local"
idf.py build flash monitor

At first start the console shows the owner key once, and the HEART Hub line:

HEART Hub: register this device at https://heartcortex.ai/register?id=5c1e9a07d3b2f468 with claim code AB3CD-EF4GH-JK5LM-NP6QR

Check it from a computer on the same network:

curl -k https://heart-ab12cd.local:43278/heart/v1/pulse

5. On the production line#

For each camera:

  1. Flash the release firmware, with flash encryption and secure boot turned on, so the keys in NVS cannot be read off the chip.
  2. Write its own TLS certificate and key to NVS (namespace heart, blobs cert and key). Never ship the development certificate.
  3. Start it once on the test jig. It makes its device ID, claim code and key, and stores them in NVS.
  4. Read the device ID and claim code from the jig's console, and print the label for inside the lid: the claim code, and a QR code of https://heartcortex.ai/register?id=<ID>. In port_esp32.c, replace the console line with your jig's label printer (the CHANGE at heart_port_monitor_load).
  5. Run the bench tests of Section 7 on a sample from each batch.

6. What the owner sees#

  1. Setup. The maker's app joins the camera to the home Wi-Fi, as before.
  2. Registration. The owner scans the QR code inside the lid, signs in at heartcortex.ai with an emailed code, and types the claim code. The page shows Healthy. Its page is private until the owner shares it. See the owner's manual.
  3. Every day. Nothing. The camera checks itself every 30 seconds.
  4. When something goes wrong:
Real eventThe HEART notices (the code it should give)The owner sees
The card fills up after a busy springFree space below 10 %, within 30 s (storage.nearly-full)The LED blinks; the page shows a warning in plain words
The card fails, or pictures cannot be savedsystem.main-function-lost: "Pictures cannot be saved to the card."The page shows Failing
A cloudy week drains the batteryBattery below 20 % (power.battery-low)A warning, before the camera stops
Summer sun heats the box above 40 °CBox temperature out of range (performance.too-hot)A warning
The camera module stops answeringsystem.main-function-lost: "The camera did not start."Failing, and the LED blinks
The battery runs flat, or Wi-Fi is out of reachNothing can be sentAfter 90 seconds the page shows Not heard from, and one email

What the starter checks. The ESP32 starter raises each out-of-range signal with the code and severity its line in signals.json names (alerts[].code and severity), such as power.battery-low, and with sensors.out-of-range (a warning) where the line names none. It has no second check for one signal, such as "card completely full". The simulator shows the same codes.

  1. Changing the IR light. The owner asks in the app or the API. The LED blinks and a 6-character code appears in the app. The owner walks to the box, checks the code, and presses the button. Nobody can change it from afar, and no rule can.

7. Test it on the bench#

Cause every fault for real, and check that an alert appears within a minute, on the device's API and on its HEART Hub page. The "Expect" column gives what the starter raises with the signal table above.

FaultHow to cause it on the benchExpect
Card nearly fullFill a card with large files until less than 10 % is freestorage.nearly-full on sd_free_pct
No cardTake the card out while it runssystem.main-function-lost
Camera deadUnplug the camera ribbon (power off first)system.main-function-lost
Battery lowFeed GPIO1 from a bench supply, down to the voltage of 20 % (about 1.62 V at the pin)power.battery-low on battery_pct
Box too hotWarm the TMP36 with a hair dryer to over 40 °Cperformance.too-hot on box_temp
Wi-Fi lostSwitch off the access point for 2 minutes"Not heard from" on the page, then Healthy again
StopHold the button for 3 secondsEvery waiting action is cancelled; GET /heart/v1/tending/autonomy shows "max": 2 and "mode": "observe"
IR needs a personAsk for IR 45 % with the owner key202, waiting; it runs only after a short press

Then run the HEART-Rating client against the camera, and claim only what passed.


8. Power: what to expect#

The starter keeps Wi-Fi and its HTTPS server on all the time. An ESP32-S3 with Wi-Fi on draws roughly 0.4 to 0.8 W, about 2 to 4 Wh a day before pictures and the IR light. That is why this page assumes a 3000 mAh cell (about 11 Wh) and a 2 W panel: two to three days without sun.

The starter can also sleep on request (POST /heart/v1/lifecycle/sleep), woken by its timer or the button. While asleep it cannot answer or report, so HEART Hub will show Not heard from after 90 seconds. A camera that sleeps every night needs its report interval set longer than the sleep, or its owner will get an email every night.


9. Going further#

  • Explanations from an AI model. Turn on AI brain in menuconfig. The monitor site then acts as the camera's Command Center and holds the model keys. The camera asks it to explain an alert, shows the answer as a suggestion only, and never acts on it. See the starter's Section 5b.
  • The zoom Playbooks of the simulator ("zoom in on the eggs", "zoom out for a crowd") need HEART Full: a gate with an allowlist and an envelope, and a record of every Action. The ESP32 starter does not have Playbooks yet. A Linux camera can run heartd, which does.
  • A Raspberry Pi camera instead. Use the Python template, run it as a systemd service beside the camera software, and read the same values from it.