Loading 3D model… large boards can take a moment.
BirdNest is a battery-aware ESP32-CAM wildlife and bird nest monitoring project built with PlatformIO and Arduino. It captures photos on schedule or command, sends images and status updates to Telegram, and supports MQTT telemetry for smart home and IoT integrations. The firmware includes deep sleep power saving, OTA remote updates, WiFi recovery logic, DS18B20 temperature monitoring, and calibrated battery voltage reporting for reliable long-term outdoor operation.
Single firmware binary model. All cameras share one firmware build. Device-specific identity (label, OTA hostname, AP name) is stored in NVS at runtime, with a MAC-address-based fallback for first boot. Identity can be changed remotely via Telegram commands without reflashing.
GitHub OTA. Starting from v0.1.0, firmware releases are published to GitHub automatically via CI. Each device can check for and install updates over HTTPS, with SHA-256 verification and optional A/B rollback. Updates are controlled via Telegram commands at runtime.
The hardware design assets are stored in hardware/BirdNest_pcb/.
hardware/BirdNest_pcb/BirdNest_pcb.kicad_schhardware/BirdNest_pcb/BirdNest_pcb.kicad_pcbhardware/BirdNest_pcb/BirdNest_pcb.kicad_prohardware/BirdNest_pcb/BirdNest_pcb.kicad_prlhardware/BirdNest_pcb/BirdNest_pcb-backups/| Function | Pin | Notes |
|---|---|---|
| XCLK | GPIO0 | 20 MHz camera clock |
| Camera D0 (Y2) | GPIO5 | Data pin |
| Camera D1 (Y3) | GPIO18 | Data pin |
| Camera D2 (Y4) | GPIO19 |
| Data pin |
| Camera D3 (Y5) | GPIO21 | Data pin |
| Camera D4 (Y6) | GPIO36 | Data pin |
| Camera D5 (Y7) | GPIO39 | Data pin |
| Camera D6 (Y8) | GPIO34 | Data pin |
| Camera D7 (Y9) | GPIO35 | Data pin |
| HREF | GPIO23 | Control line |
| VSYNC | GPIO25 | Control line |
| PCLK | GPIO22 | Control line |
| SDA (I2C) | GPIO26 | Camera I2C data |
| SCL (I2C) | GPIO27 | Camera I2C clock |
| OneWire (DS18B20) | GPIO13 | OneWire bus |
| ADC (Battery) | GPIO12 | Battery voltage measurement |
| Flash LED | GPIO4 | PWM control |
On the ESP32CAM the AMS1117 has been replaced with AP7361C-33ER-13 which consumes less.
OTA_STARTUP_WINDOW_SEC, or OTA_RECOVERY_WINDOW_SEC when recovery is armed)
5b. ghOtaInit() — initialize GitHub OTA module after the ArduinoOTA window settles; confirm post-install rollback health if ESP_OTA_IMG_PENDING_VERIFY; run auto-check and install if otaAuto=trueesp_deep_sleep_start() when sleep interval is enabled and OTA is not activefb_count=2, otherwise fallback profile with fb_count=1ESP_ERR_INVALID_STATE with deinit+retry, plus second init retry after 500 ms/mirror and /flip), and light-based OV2640 register ladderCAMERA_WARMUP_FRAMES frames (current active profile: 4), with frame-grab retries (CAMERA_FB_GET_RETRIES)sendPhoto request with chunked JPEG write (8 KB chunks) over WiFiClientSecure"ok":true as success; store detailed failure reason otherwisecaptureAndSendPhoto() with WiFi reconnect and progressive backoff (PHOTO_SEND_RETRY_BACKOFF_MS * attempt)/status, /help, /photo, /sleepXX, /maint_on, /maint_off, /netdiag, /reboot, /reboot_ota, /setlabel, /sethostname, /bootstrap_prepare, /reset_config, /debug0|1|2, /mirror0|1, /flip0|1, /battcal, /battcalset, /battcalclear, /mqtt, /mqttset, /mqtttopic, /mqtttopic_reset, /mqttoff, /otastatus, /otaupdate_check, /otaupdate_now, /otaupdate_auto_on, /otaupdate_auto_off, /otachannel, /otatoken_set, /otatoken_clear/status returns status only; full command listing is in /helpesp_sleep_get_wakeup_cause()fromSleep flag behavior: sets fromSleep to true on wake, false on normal bootbotToken, chatId, debugChatId) before connect flowWIFI_FAIL_FAST_SEC) when captive portal is disabledwifiFail) in NVS; reset to 0 on successful connectwifiInit()).WIFI_BOOT_CONNECT_RETRIESWIFI_BOOT_RETRY_BACKOFF_SECWIFI_FAIL_FAST_SEC timeout.WIFI_ENABLE_CAPTIVE_PORTAL != 0, portal path is always allowed.WIFI_RESCUE_FAIL_COUNT failures.WIFI_RESCUE_PORTAL_INTERVAL_FAILS.WIFI_RESCUE_PORTAL_TIMEOUT (rescue) or CONFIG_PORTAL_TIMEOUT (normal).WIFI_RETRY_BACKOFF_SEC.otaGetRecoverySleepSeconds), including low-battery protection.WIFI_RUNTIME_RECONNECT_INTERVAL_SEC/netdiag)WIFI_RUNTIME_REBOOT_AFTER_SEC.otaLoop()).OTA_STARTUP_WINDOW_SEC, or extended OTA_RECOVERY_WINDOW_SEC when recovery mode is armed.OTA_STALL_TIMEOUT_SEC./netdiag.lastMsgId tracking for message updates/help provides the full command list, while /status stays focused on runtime stateCAMERA_FRAMESIZE_*, CAMERA_JPEG_QUALITY_*, fb_count)CAMERA_WARMUP_FRAMES, active profile uses 4)WiFiClientSecure and writeAll() helper0x2f)0x2fCAMERA_DAY_THRESHOLD separates lowlight and daylight branchesset_gain_ctrl(0)), gain is manual (set_agc_gain(0)), and exposure is controlled by register ladder writes0x47, 0x2a, and 0x2b for very low to medium lightCAMERA_DYNAMIC_EXPOSURE_PROFILE, CAMERA_DAY_*, or CAMERA_LOWLIGHT_* flag familiesPHOTO_SEND_MAX_RETRIESPHOTO_SEND_RETRY_BACKOFF_MSCAMERA_FB_GET_RETRIESCAMERA_FB_GET_RETRY_DELAY_MSCommon remote error reasons (cameraGetLastError):
empty_chat_id - no chat ID was providedmissing_bot_token - Telegram bot token is missing/emptywifi_not_connected_before_upload - WiFi dropped before upload starttelegram_tls_connect_failed - TLS connection to api.telegram.org:443 failedcamera_fb_get_failed_final - frame grab failed after warm-up and final fallback attemptupload_failed_headers_or_prefix - multipart request header/prefix write failedupload_chunk_write_failed_offset_N - JPEG data upload interrupted at byte offsetupload_multipart_tail_failed - multipart tail write failedtelegram_response_not_ok_empty_body - Telegram returned non-OK with empty bodytelegram_response_not_ok_... - Telegram API responded with ok:false and body snippetTEMP_SAMPLE_COUNT samples averaged (1–2)bCalA/bCalBbatteryRefresh() in setup), then reused during active WiFi on ADC2 hardware./status reports that cached value on ADC2+WiFi. This is typically the most recent wake-time sample./status is sent while the device is in deep sleep, it is processed after wake, so battery data in the reply reflects the new measurement from that wake cycle.All cameras run the same firmware binary. Each device's identity is stored in NVS and loaded at every boot.
| NVS key | Default | Description |
|---|---|---|
devLabel | BirdNest-XXXXXXXX (MAC) | Human-readable device name |
otaHost | birdnest-xxxxxxxx (MAC) | ArduinoOTA mDNS hostname |
| AP name | derived from devLabel + -cfg | Captive portal AP name (not stored separately) |
On first boot the label and hostname are generated from the lower 32 bits of the chip MAC address, so multiple devices with the same factory firmware never collide on the same LAN.
Telegram commands:
/setlabel <name> — change device label (persisted in NVS, affects MQTT topics and photo captions)/sethostname <name> — change ArduinoOTA hostname (reboot required to apply mDNS)/status and welcome message show current label and OTA hostnameOTA_STARTUP_WINDOW_SEC (default 8 s)OTA_RECOVERY_WINDOW_SEC (default 120 s) when recovery latch is armedOTA_STALL_TIMEOUT_SECOTA_RECOVERY_SLEEP_SEC, with low-battery fallback OTA_RECOVERY_LOW_BATTERY_SLEEP_SEC below OTA_RECOVERY_MIN_BATTERY_VotaArmed and otaCycles/reboot_ota arms recovery and reboots — opens an extended OTA window for PlatformIO uploadStarting from v0.1.0, every v* git tag triggers a CI build that publishes a single firmware binary and ota-manifest.json to GitHub Releases. Devices can check and install these releases over HTTPS without physical access.
api.github.com/repos/VorosEgyes/BirdNest/releases, finds the best candidate by SemVer and channel (stable/beta), downloads ota-manifest.json.manifest.min_battery_v), WiFi stable..bin via HTTPS, verifies SHA-256 incrementally, writes to the inactive OTA partition, reboots.api.github.com/rate_limit up to 3 times; if any succeed, marks the image valid and cancels rollback.GitHub OTA install requires an A/B OTA partition layout (partitions_ota_4m.csv). Devices previously flashed with min_spiffs.csv (single app partition) will run all GitHub OTA code normally but install will be blocked with reason no_update_partition until the device receives a full flash.
| Scenario | ArduinoOTA upload | GitHub OTA install |
|---|---|---|
Device with partitions_ota_4m.csv | ✅ | ✅ |
Device with min_spiffs.csv (old layout) | ✅ | ❌ blocked |
To migrate an existing camera from min_spiffs to OTA partitions:
pio run -e cam1 -t upload --upload-port /dev/cu.usbserial-XXXX
# or cam2, cam3, cam4 — all envs produce the same binary
For cameras only reachable via ArduinoOTA, use /bootstrap_prepare before the USB flash session to ensure maintenance mode is on and sleep is off.
/bootstrap_prepare — run this before any first-install or partition migration session:
BOOTSTRAP_MIN_BATTERY_V (default 3.80 V)BOOTSTRAP_MIN_RSSI_DBM (default −82 dBm)Reports READY or NOT READY with per-check detail.
| Command | Description |
|---|---|
/otastatus | JSON status: current version, channel, auto mode, pending target, last reason |
/otaupdate_check | Query GitHub releases; report available version or no-update |
/otaupdate_now | Check and immediately install if update found |
/otaupdate_auto_on | Enable automatic check+install on every boot |
/otaupdate_auto_off | Disable automatic updates (manual only) |
/otachannel stable|beta | Switch release channel |
/otatoken_set <token> | Set GitHub API token (private repos; message auto-deleted) |
/otatoken_clear | Clear token; drops pending private target |
FW_VERSION is injected at build time from the nearest git tag via scripts/fw_version_from_git.py.0.0.0-dev as fallback.release-ota.yml) triggers on any v* tag push:
[env:release] (same binary as camX envs)birdnest-esp32cam-vX.Y.Z.bin and ota-manifest.json to GitHub ReleasesGH_OTA_DAILY_CHECK_SEC)./otaupdate_check and /otaupdate_now bypass daily interval and backoff.birdnest_gh)| Key | Type | Description |
|---|---|---|
otaAuto | bool | Auto-update enabled |
otaChannel | string | stable or beta |
otaToken | string | GitHub API token (private repos) |
otaLastChk | uint32 | Unix timestamp of last successful check |
otaWifiFail | uint16 | Consecutive WiFi stability failures |
otaChkFail | uint16 | Consecutive check failures |
otaWifiOk | uint8 | Consecutive stable cycles (for backoff reset) |
otaBackoff | uint32 | Backoff-end timestamp (0 = no backoff) |
otaTarget | string | JSON-serialized pending install target |
otaLastReason | string | Last OTA block/fail reason code |
otaRebootFlg | bool | Set before install reboot, cleared on next boot |
otaLastTgt | string | Version string of last attempted install |
MQTT_STATUS_INTERVAL_SEC) and immediate publish on boot/config changeonline / offline, retained)schema_version)mqttPublishOtaEvent() to the event topicIf no custom topic is set, these topics are used:
birdnest/<deviceLabel>/statusbirdnest/<deviceLabel>/eventbirdnest/<deviceLabel>/availabilityWhere <deviceLabel> is the runtime device identity loaded from NVS (see Runtime Device Identity section). The default is BirdNest-XXXXXXXX based on MAC address.
If you set a custom topic with /mqtttopic:
/status, event and availability are derived as sibling topics./event and /availability are appended./mqtt - show current MQTT config and active channel/mqttset <ip-or-host> <port> <user|-> <pass|-> - set broker and auth in one message/mqtttopic <topic> - set custom publish channel/topic/mqtttopic_reset - reset topic to default (birdnest/CAMERA_LABEL/status)/mqttoff - disable MQTT and clear broker credentials/mqtt now reports:
/mqttset 192.168.1.100 1883 mqtt_user mqtt_pass/mqttset 192.168.1.100 1883 - -/mqtttopic birdnest/BirdNestCam1/status/mqtttopic_resetState payload (status) is JSON and includes:
schema_versiondevicereason (boot, periodic, manual, config_updated, ...)iprssiuptime_stemp_cbattery_vbattery_pctmaintEvent payload (event) is JSON and includes:
schema_versiondeviceeventdetailuptime_sGitHub OTA event payload additionally includes:
reason — machine-readable reason code (e.g. no_update, battery_low, sha256_mismatch)current_version — running firmware versiontarget_version — target version being installed (empty if check-only)channel — stable or betaOTA event types: ota_check_start, ota_check_no_update, ota_update_available, ota_install_blocked, ota_update_start, ota_update_progress, ota_update_ok, ota_update_fail, ota_check_fail
Availability payload (availability) values:
online (retained on successful connect)offline (retained via LWT or explicit MQTT disable)All keys live in the birdnest namespace unless otherwise noted.
| Key | Type | Description |
|---|---|---|
devLabel | string | Runtime device label (default: BirdNest-XXXXXXXX) |
otaHost | string | ArduinoOTA mDNS hostname (default: birdnest-xxxxxxxx) |
| Key | Type | Description |
|---|---|---|
botToken | string | Telegram bot token |
chatId | string | Telegram main chat ID |
debugChatId | string | Telegram debug chat ID |
sleepSec | uint32 | Deep sleep interval in seconds |
maintMode | bool | Maintenance mode |
dbgVerb | uint8 | Debug verbosity (0–2) |
camMirror | bool | Camera mirror |
camFlip | bool | Camera vertical flip |
lastMsgId | int32 | Last processed Telegram update ID |
| Key | Type | Description |
|---|---|---|
bCalEn | bool | Two-point calibration enabled |
bCalA / bCalB | float | Calibration slope / offset |
bCalRV1/MV1/RV2/MV2 | float | Calibration reference points |
| Key | Type | Description |
|---|---|---|
otaArmed | bool | Recovery mode armed |
otaCycles | uint16 | Recovery cycles remaining |
| Key | Type | Description |
|---|---|---|
wifiFail | uint16 | Consecutive WiFi boot failure counter |
| Key | Type | Description |
|---|---|---|
mqttHost | string | Broker host/IP |
mqttPort | uint16 | Broker port |
mqttUser | string | Username (optional) |
mqttPass | string | Password (optional) |
mqttTopic | string | Custom state topic (optional) |
birdnest_gh)| Key | Type | Description |
|---|---|---|
otaAuto | bool | Auto-update enabled |
otaChannel | string | stable or beta |
otaToken | string | GitHub API token |
otaLastChk | uint32 | Unix timestamp of last check |
otaWifiFail | uint16 | WiFi stability fail streak |
otaChkFail | uint16 | Check fail streak |
otaWifiOk | uint8 | Stable cycles counter (backoff reset) |
otaBackoff | uint32 | Backoff end timestamp |
otaTarget | string | Pending install target (JSON) |
otaLastReason | string | Last block/fail reason code |
otaRebootFlg | bool | Set before install reboot |
otaLastTgt | string | Last attempted install version |
Example: 2000 mAh battery with 5-min photo interval = 100+ hours of operation Night sleep (12 hours OFF) saves 50%+ battery
No comments yet. Be the first to ask about this board.