
Edge AI with .NET, Part 2: Arduino Firmware That Sleeps Most of Its Life
Most of your firmware's job is doing nothing, efficiently. This article covers the complete Arduino firmware for the ESP32 based M5Stack Timer Camera X: dual mode operation, NVS config storage, HTTPS posting, and getting back to deep sleep in under five seconds.
The Core Constraint That Changes Everything
When you deploy a battery-powered camera to a remote location, the firmware's main job is not capturing images, it is sleeping. The M5Stack Timer Camera X draws around 150 to 240 mA when active with Wi-Fi on. In deep sleep it drops to roughly 10 µA. That difference of four orders of magnitude is what turns a "replaces batteries every day" device into a "check it quarterly" device.
Every design decision in this firmware flows from that constraint. No persistent connections. No polling loops. No idle delays. Wake, do the minimum work, sleep.
This is Part 2 of a series on edge AI with .NET. Part 1 covered the server-side inference pipeline in ASP.NET Core. Here we focus entirely on the ESP32 firmware written in Arduino C++.
Two Firmware Modes
The firmware boots into one of two modes, selected by a flag stored in NVS:
Config mode is for initial setup and camera aiming. The device stays awake, performs a USB handshake over serial, accepts Wi-Fi credentials and an endpoint URL, and streams framed JPEG previews so a human can point the lens before deploying the hardware.
Production mode is everything else. On every wake cycle the device reads config from NVS, initialises the camera, connects to Wi-Fi, captures a JPEG, POSTs it over HTTPS to the .NET inference endpoint, then calls esp_deep_sleep_start(). The whole active window targets three to five seconds.
The mode switch is triggered by holding the reset button during a special boot sequence. In practice you write a CLI tool, covered in Part 3, that sets the NVS flag over USB before deployment.
Storing Config in NVS Instead of Hardcoding
Hardcoding Wi-Fi credentials in firmware is a deployment anti-pattern. When the password rotates you reflash every device. NVS solves this: it's a separate flash partition with built-in wear levelling, it survives reboots and sketch reflashes, and the Preferences.h Arduino wrapper is simple enough to use correctly on the first attempt.
Key constraints: namespace names and key names must each be 15 characters or fewer. That is not boilerplate in the docs. Go over it and writes fail silently.
// nvsConfig.h, NVS read/write helpers
#include <Preferences.h>
static Preferences prefs;
struct DeviceConfig {
char ssid[64];
char password[64];
char endpoint[128];
uint32_t intervalSeconds;
bool productionMode;
};
bool loadConfig(DeviceConfig& cfg) {
prefs.begin("camcfg", /*readOnly=*/true);
bool valid = prefs.isKey("ssid") && prefs.isKey("endpoint");
if (valid) {
prefs.getString("ssid", cfg.ssid, sizeof(cfg.ssid));
prefs.getString("pass", cfg.password, sizeof(cfg.password));
prefs.getString("endpoint", cfg.endpoint, sizeof(cfg.endpoint));
cfg.intervalSeconds = prefs.getUInt("interval", 900);
cfg.productionMode = prefs.getBool("prodmode", false);
}
prefs.end();
return valid;
}
void saveConfig(const DeviceConfig& cfg) {
prefs.begin("camcfg", /*readOnly=*/false);
prefs.putString("ssid", cfg.ssid);
prefs.putString("pass", cfg.password);
prefs.putString("endpoint", cfg.endpoint);
prefs.putUInt("interval", cfg.intervalSeconds);
prefs.putBool("prodmode", cfg.productionMode);
prefs.end();
// No explicit commit needed, Preferences handles it.
}Note the key name "pass" rather than "password". That is the 15 character ceiling in action.
For state that only needs to survive across deep-sleep cycles (not across reflashes), prefer RTC_DATA_ATTR. It's faster, doesn't wear flash, and is the right tool for a retry counter or a frame sequence number:
RTC_DATA_ATTR uint8_t failureStreak = 0;
RTC_DATA_ATTR uint32_t frameCounter = 0;These are declared at file scope. On the first power-on they initialise to zero. On every subsequent wake from deep sleep their values are preserved. On a hard reset or power loss, they reset.
Deep Sleep Timer Configuration
Configuring the wakeup timer is two lines, but placement matters. Call them immediately before esp_deep_sleep_start(), not at the top of setup():
void goToSleep(uint32_t intervalSeconds) {
uint64_t wakeupUs = (uint64_t)intervalSeconds * 1000000ULL;
esp_sleep_enable_timer_wakeup(wakeupUs);
Serial.println("Entering deep sleep.");
Serial.flush(); // Drain UART before power rail drops
esp_deep_sleep_start();
// Execution never reaches here. On wake, setup() runs again.
}The critical mental model: deep sleep is not suspend. There is no resume. When the BM8563 RTC fires the wakeup signal, the ESP32 boots from the beginning. setup() runs. loop() runs. Every wakeup is a cold boot of user code. Everything you allocated in the previous cycle is gone.
GPIO wakeup sources have their own gotcha on the base ESP32: GPIO 12 is a strapping pin that affects flash voltage at boot. On the Timer Camera X it is already used for the BSP I2C bus, so do not attach external wakeup triggers to it. The safe external wakeup pins on the base ESP32-D0WDQ6-V3 are 4, 13, 14, 15, 25, 26, and 27.
Capture and POST Over HTTPS
The Timer Camera X has 8 MB of PSRAM, which makes it less likely than a bare ESP32 to run out of heap during a TLS handshake. But the TLS stack still operates out of main DRAM (520 KB total), and a full handshake costs roughly 40 to 60 KB there. Be conservative: capture the image first, then bring Wi-Fi up, then open the TLS connection.
Always pin a root CA certificate. client.setInsecure() exists, is useful in development, and must never reach production, because it disables chain validation completely.
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include "esp_camera.h"
// Root CA for your inference endpoint (PEM format, trimmed for brevity)
static const char* ROOT_CA = \
"-----BEGIN CERTIFICATE-----\n"
"MIIDdzCCAl+gAwIBAgIEAgAAuTAN...\n"
"-----END CERTIFICATE-----\n";
bool captureAndPost(const DeviceConfig& cfg) {
// 1. Initialise camera (config omitted, pin assignments are Timer Camera X specific)
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Camera capture failed");
return false;
}
// 2. Connect Wi-Fi, with a hard timeout
WiFi.begin(cfg.ssid, cfg.password);
unsigned long wifiStart = millis();
while (WiFi.status() != WL_CONNECTED && millis() - wifiStart < 12000) {
delay(200);
}
if (WiFi.status() != WL_CONNECTED) {
esp_camera_fb_return(fb);
return false;
}
// 3. POST the JPEG
WiFiClientSecure client;
client.setCACert(ROOT_CA);
HTTPClient http;
http.begin(client, cfg.endpoint);
http.addHeader("Content-Type", "image/jpeg");
http.addHeader("X-Frame-Id", String(frameCounter));
int httpCode = http.POST(fb->buf, fb->len);
esp_camera_fb_return(fb);
http.end();
WiFi.disconnect(true);
return (httpCode == 200 || httpCode == 204);
}The brown-out detector is worth mentioning here. The Timer Camera X docs explicitly recommend disabling it in setup() via WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0) because the combined current spike from camera initialisation and Wi-Fi association can trip it on a partially discharged battery. It's an uncomfortable thing to disable, but the alternative is random reboot loops in the field.
What Happens When the POST Fails
The device is about to sleep. The POST returned a 5xx, or Wi-Fi never associated, or the TLS handshake timed out. What now?
Three options, in order of battery cost:
-
Retry immediately. Spend another association cycle and another TLS handshake. Reasonable if
failureStreakis zero, because transient errors happen. One retry, bounded by the same 12-second Wi-Fi timeout. -
Flag and sleep. Increment
failureStreakin RTC memory, sleep for the normal interval. On the next wake, check the streak. After three consecutive failures, double the sleep interval (exponential backoff) to avoid hammering a dead endpoint on a slow battery drain. -
Drop the frame. When
failureStreakexceeds a threshold (say, 10), accept the data loss and reset the counter. A camera trap in the field with a dead upstream is not going to recover by staying awake.
The worst outcome is a firmware that retries Wi-Fi indefinitely on a dead network at 150+ mA. The battery will be flat in under a day. Always sleep on failure.
Config Mode: Serial Protocol Framing
In config mode the device listens for simple framed commands over USB serial and streams JPEG previews back. Framing matters, because raw bytes from the JPEG stream contain any byte value including 0xFF 0xD8 (JPEG SOI) and 0xFF 0xD9 (EOI), so you cannot use a single-byte delimiter. A minimal approach: a four-byte length-prefixed frame with a fixed magic header.
The host-side .NET tool (Part 3) sends CFG\n<key>=<value>\n commands and reads FRAME\n<4-byte big-endian length><jpeg bytes> responses. The firmware loop runs esp_camera_fb_get() every 200 ms, writes the frame header, writes the JPEG buffer, returns the frame buffer, and polls Serial.available() for config commands. No deep sleep, no HTTPS, just a tight preview loop until the host sends PRODMODE=1 and the device writes NVS and reboots into production mode.
Power Budget in Practice
With a 140 mAh battery and a 15-minute capture interval:
- Active window: ~4 seconds at ~180 mA average = 0.2 mAh per cycle
- Sleep window: ~896 seconds at ~10 µA = ~0.0025 mAh per cycle
- Total per cycle: ~0.2025 mAh
- Cycles per day: 96
- Daily consumption: ~19.4 mAh
- Battery life on internal battery alone: roughly 7 days
That's the internal 140 mAh cell. Add a 3000 mAh USB power bank and you get five months on a 15-minute interval. Stretch to hourly and you're looking at years. The arithmetic is just as unforgiving in the other direction. Leave Wi-Fi up for 30 seconds instead of 4 and battery life drops by a factor of seven.
Measure actual current with a dedicated power analyser (Nordic PPK2 or similar). Ordinary multimeters introduce enough burden voltage to affect ESP32 behaviour near its minimum operating voltage, and you'll get misleading readings in deep sleep due to the high dynamic range between active and sleep current.
What's Next
Part 3 covers the .NET configuration tool, a small console app that handles the USB serial handshake, renders the JPEG preview stream to the terminal (using Spectre.Console's image support), accepts Wi-Fi credentials, and writes the production config to the device over the framed serial protocol described above. The firmware is deliberately dumb; the host tool is where the UX lives.
Sources
- ESP32 Power Consumption & Sleep Modes [All Variants]
- WiFi Module Power Consumption: ESP8266 vs ESP32 in Deep Sleep - Zbotic
- ESP32 Deep Sleep Mode: Extend Battery Life in IoT Projects - Zbotic
- Current Consumption Measurement of Modules - ESP32 - — ESP-IDF Programming Guide v6.1 documentation
- How to Implement ESP32 Deep Sleep Mode for Battery-Efficient IoT Monitoring | MyEmbeddedSystems
- ESP32 Deep Sleep Guide: Extend Battery from Days to Months | SolderHub
- Power Management and Deep Sleep | SiliconWit
- ESP32 Deep Sleep Tutorial: How to Reduce Power Consumption - Blog - Ampheo
Keep reading

October 5, 2026 · 7 min
Edge AI with .NET, Part 1: Integrating a 1975 Power Meter That Has No API
A 50-year-old Ferraris disc meter has no network port, no pulse output, and no protocol. Here's how to build a complete integration stack around a camera, a .NET 10 minimal API, and TimescaleDB when the only interface is a photograph.
Read
October 5, 2026 · 7 min
Edge AI with .NET, Part 4: Time Series That Outlives the Hardware
Sensor readings are more valuable than the devices that produce them. This instalment shows how to model IoT meter data in TimescaleDB 2.29, map it with EF Core 10, and run a .NET anomaly-detection worker that survives meter swaps, tariff changes, and dead cameras.
Read
October 5, 2026 · 6 min
Edge AI with .NET, Part 3: Vision OCR You Can Actually Trust
Reading utility meter digits with a vision model is a solved demo and an unsolved production problem. This article covers prompt design, image detail costs, the documented failure modes that matter, and the validation layer that makes a GPT-6-Astra reading safe to persist.
Read