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.
| Placement | A: inside the product. The HEART runs on the camera's own chip, in the same firmware. |
| Start from | The ESP32 Full starter (heart/embeddedstandard/esp32-full/v1.2611.3) |
| Claim | HEART Basic, Autonomy Level A1. The zoom Playbooks need HEART Full (see Section 9). |
| Try it first | The simulator and the design case study |
1. The device we assume#
| Part | Assumed choice | Notes |
|---|---|---|
| Board | An ESP32-S3 camera board with 8 MB PSRAM, 8 MB flash and a microSD slot, such as the Seeed Studio XIAO ESP32S3 Sense | The 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. |
| Camera | OV2640 or OV3660 on the board, infrared-sensitive (no IR-cut filter) | Your firmware drives it with Espressif's esp32-camera component |
| Motion | A PIR sensor | Wakes the camera to take pictures |
| Infrared light | Two 850 nm IR LEDs on a logic-level MOSFET, dimmed by PWM | Birds hardly see 850 nm |
| Box temperature | A TMP36 analogue sensor inside the box | Too hot is bad for chicks and for the camera |
| Power | A 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 divider | Big enough to stay awake (Section 8) |
| Storage | A 32 GB microSD card | Pictures wait here when Wi-Fi is down |
| Button | A sealed push button on the outside of the box, wired to ground | Approves physical changes; held 3 seconds it is Stop |
| Status light | A green LED outside the box, under the roof | Blinks a fault with no internet, and blinks when a change waits for the button |
| Network | The home Wi-Fi, 2.4 GHz | Reports to HEART Hub, outbound only |
Example pin map#
| Signal | Pin | In 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, ...} |
| PIR | GPIO3 | Read by your firmware, not by HEART |
| Button | GPIO4, internal pull-up | CONFIG_HEART_BUTTON_GPIO=4 |
| IR LED MOSFET (PWM) | GPIO7 | Set by your firmware only, through ir_level |
| Status LED | GPIO8 | CONFIG_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.
| File | What you do |
|---|---|
main/port_esp32.c | Change the lines marked CHANGE (below) |
signals.json | Write it with heart_signals.py, then export main/heart_signals.h |
main/heart_core.c, heart_core.h, heart_ed25519.c | Copy unchanged |
main/Kconfig.projbuild, sdkconfig.defaults | Copy; set values in idf.py menuconfig |
| Your camera code | Add 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:
| Setting | Value for development |
|---|---|
| Wi-Fi name and password | Your test network. Products get these from the setup app, never from sdkconfig. |
| HEART Hub | On |
| HEART Hub URL | https://heartcortex.ai, or your own monitor site |
| Approvals on the monitor site | Off. The button is the only way to approve the IR light. |
| Button GPIO, LED GPIO | 4 and 8 |
| Gateway URL and AI brain | Off, 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:
- Flash the release firmware, with flash encryption and secure boot turned on, so the keys in NVS cannot be read off the chip.
- Write its own TLS certificate and key to NVS (namespace
heart, blobscertandkey). Never ship the development certificate. - Start it once on the test jig. It makes its device ID, claim code and key, and stores them in NVS.
- 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>. Inport_esp32.c, replace the console line with your jig's label printer (theCHANGEatheart_port_monitor_load). - Run the bench tests of Section 7 on a sample from each batch.
6. What the owner sees#
- Setup. The maker's app joins the camera to the home Wi-Fi, as before.
- 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.
- Every day. Nothing. The camera checks itself every 30 seconds.
- When something goes wrong:
| Real event | The HEART notices (the code it should give) | The owner sees |
|---|---|---|
| The card fills up after a busy spring | Free 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 saved | system.main-function-lost: "Pictures cannot be saved to the card." | The page shows Failing |
| A cloudy week drains the battery | Battery below 20 % (power.battery-low) | A warning, before the camera stops |
| Summer sun heats the box above 40 °C | Box temperature out of range (performance.too-hot) | A warning |
| The camera module stops answering | system.main-function-lost: "The camera did not start." | Failing, and the LED blinks |
| The battery runs flat, or Wi-Fi is out of reach | Nothing can be sent | After 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.jsonnames (alerts[].codeandseverity), such aspower.battery-low, and withsensors.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.
- 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.
| Fault | How to cause it on the bench | Expect |
|---|---|---|
| Card nearly full | Fill a card with large files until less than 10 % is free | storage.nearly-full on sd_free_pct |
| No card | Take the card out while it runs | system.main-function-lost |
| Camera dead | Unplug the camera ribbon (power off first) | system.main-function-lost |
| Battery low | Feed 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 hot | Warm the TMP36 with a hair dryer to over 40 °C | performance.too-hot on box_temp |
| Wi-Fi lost | Switch off the access point for 2 minutes | "Not heard from" on the page, then Healthy again |
| Stop | Hold the button for 3 seconds | Every waiting action is cancelled; GET /heart/v1/tending/autonomy shows "max": 2 and "mode": "observe" |
| IR needs a person | Ask for IR 45 % with the owner key | 202, 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
systemdservice beside the camera software, and read the same values from it.