From a single Arduino sketch to ESP-IDF v5.5.4 multi-component firmware: a second-generation rewrite of our office IoT
We rewrote the IoT firmware that drives the three AC channels at our office entrance — image-wall light, ventilation fan, and aspirator — from a first-generation Arduino sketch to a native ESP-IDF v5.5.4 stack. The rewrite folded in zero-cross phase calibration, ECDSA-signed OTA, TLS SMTP alerts, and local Korean-holiday handling in one pass.

We rewrote the IoT firmware that drives three AC channels at the office entrance — image-wall light, ventilation fan, and aspirator — as a native ESP-IDF v5.5.4 multi-component application. This is not a port of the first-generation Arduino sketch — it is a second-generation rewrite that folds in everything we now want for production IoT in one pass. We started on May 5 and shipped the first OTA on May 6.

What is new — the technologies we adopted in this rewrite
• Build / runtime — ESP-IDF v5.5.4 + Xtensa GCC 14.2.0 (crosstool-NG esp-14.2.0_20260121) + CMake 3.30 / Ninja 1.12. From a single Arduino-on-ESP32 .ino to native ESP-IDF multi-component.
• Architecture — eight independent components (app_config / zerocross / relay_ctrl / scheduler / wifi_mgr / web_ui / ota_mgr / notifier), each with its own NVS namespace and event surface.
• RTOS — explicit Task Watchdog + Interrupt Watchdog registration on top of FreeRTOS (relay_task, scheduler_task).
• Persistence — a schema-versioned NVS blob pattern (blob → blob2) that prevents post-OTA misreads after a struct field is added.
• Events / timers — esp_event_loop + esp_timer, replacing the Arduino-style millis() polling loop.
① Zero-cross phase calibration
Comparator and opto-coupler delays mean the AC pulse always arrives a fixed time later than the real 0 V crossing. At boot the firmware now measures 500 pulses on a GPIO34 ANYEDGE ISR, learns the average phase offset, and caches it in NVS. Relay switching is then queued through esp_timer at the true 0 V instant — quieter arcing, lower EMI, and far more deterministic than the Arduino "toggle now" code.
When the device is powered over USB only (no AC), no zero-cross arrives. We added a queue-drain on timeout fallback so manual toggles still work in that case.
② Local-first scheduler + Korean holidays
The schedule must work even when the internet is down, so all decisions happen locally on the device.
• Per-port auto / manual mode, workday window (Mon–Fri 07:00–19:00), and a 24-cell hourly override grid.
• Korean holidays excluded automatically — eight solar holidays plus 70 lunar entries for 2026–2035, sourced from KASI lunar data and embedded in firmware.
• Manual override TTL — when a human toggles a relay, the scheduler stays silent for one hour, so cleaning crews don’t get cut off mid-task.
③ Self-healing Wi-Fi + NTP / KST
Five station retries, then automatic SoftAP fallback (saea-iot-XXXX) so a dying access point still leaves a recovery path. NTP locks to KST (UTC+9).
④ esp_http_server + live WebSocket push
The Korean-language UI is fully embedded under web_ui/assets/ (index.html, settings.html, style.css, app.js). 13 REST endpoints + a /ws WebSocket pushing state at 1 Hz, all behind BasicAuth.
• /api/status — live snapshot (relays, time, Wi-Fi, zero-cross)
• /api/relay/{0..2} · /api/schedule/{0..2} — per-channel toggles and schedule
• /api/diag — uptime, heap, RSSI, OTA slot, task list
• /api/logs — last 128 UART lines from the RAM ring buffer
• /ws — 1 Hz live state push
⑤ OTA — signed, A/B, anti-rollback, 60-second stability window
This is the big new piece in this rewrite.
• ECDSA-P256 signature verification — every built .bin gets a signature attached; the bootloader verifies it against an embedded public key and refuses to boot on a mismatch.
• A/B partitions — ota_0 and ota_1, 1.6 MB each. A new firmware lands on the inactive slot, then the boot slot is flipped after verification.
• Anti-rollback + 60-second stability window — after OTA we wait 60 s, and only then call esp_ota_mark_app_valid_cancel_rollback(). A panic inside that window auto-rolls back to the previous slot.
• The signed-app bootloader is 33 KB, over the default 28 KB limit, so the partition table moved from 0x8000 to 0xE000.
Result: only two USB flashes ever — the next ten OTA round-trips were all wireless, 18–20 seconds each, fired from a curl one-liner on the same LAN.
⑥ TLS SMTP — direct SMTPS from the device
Mail goes out directly over SMTPS from the ESP32, with no relay box in between (Synology Mail Plus or a generic SMTP server both work).
• esp-tls + crt_bundle_attach for an embedded CA bundle, plus skip_common_name for in-house certs.
• Auto-fires on panic, OTA rollback, AP fallback, daily-health, with duplicate suppression in the queue.
• A Postfix-strict bare-LF rejection (521) bit us — fixed by normalising \n → \r\n.
⑦ Remote diagnostics — RAM ring buffer + metrics
UART logs are mirrored into a RAM ring buffer and exposed at /api/logs so we can read the last 128 lines from the LAN without plugging USB. /api/diag returns heap, task list, RSSI, and OTA slot state in one call.
Eight traps we hit — recorded into permanent memory
These bugs are likely to recur on future ESP-IDF projects, so we wrote each into our shared memory.
• httpd_req_get_hdr_value_str()’s return value mistaken for a length → all BasicAuth rejected → fixed by comparing != ESP_OK.
• Bootloader 33 KB > 28 KB default → build fails → partition table offset moved 0x8000 → 0xE000.
• A bool Kconfig set to n leaves the macro undefined → #ifndef … #define X 0 normalisation.
• Adding a field to an NVS blob mismatched old data → schema-versioned key (blob → blob2), now the standard pattern.
• httpd_config_t.max_uri_handlers=16 silently drops extra handlers → OTA /update returned 404 → bumped to 32.
• Without AC there is no zero-cross, and the relay queue stalled → queue-drain on timeout fallback.
• esp-tls on v5.5 with NULL CA returns SSL_SETUP_FAILED → crt_bundle_attach + skip_common_name.
• Synology Postfix strict mode rejected bare LF with 521 → CRLF normalisation.
Verified metrics — production-ready
• Boot: ~3 s / NTP sync: ~5.5 s / Wi-Fi associate: ~1.8 s
• OTA round-trip: 1.18 MB image in 18–20 s + 2 s reboot
• Heap: idle ~137 KB / worst ~92 KB / leak after 200 mixed API calls: ±40 B (effectively zero)
• Firmware size: 1,152 KB / 1,664 KB (69.2 %)
• NVS persistence verified across reboots
• End-to-end SMTP and anti-rollback + 60-second window both proven on hardware
Open work and what is next
• Daily-health email (08:00 KST) — code on disk, build / OTA queued for the next session
• LittleFS persistent logs on the storage partition — extend the UART ring buffer to disk
• Auto-substitute holidays when a solar holiday falls on Sat/Sun
• Low-RSSI early warning
• Disable BT Classic to recover ~150 KB of flash
• NVS encryption (eFuse-keyed)
• Secure Boot V2 (one-time eFuse, just before mass production)
The point of this round of work was not to "port Arduino to IDF" — it was to land all the pieces that production IoT firmware actually needs in the field at once: signing, rollback, schema versioning, local fallback, and remote diagnostics. The next IoT projects in-house will start from these eight components and these eight pinned-down traps.
Do you have an idea?
From device design to firmware, content integration, and mass production — SaeA-ICT is with you.