SaeA-ICT
← News
2026.05.08TECH NOTE

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.

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 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 / runtimeESP-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 / timersesp_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 partitionsota_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.

View all newsGo to all News →