Boot and Main Loop¶
This page walks through what the regulator does from power-on to steady-state: the startup sequence (setup()), the structure of the repeating control pass (loop()), the background tasks that run alongside it, and the built-in timing instrumentation to consult first when diagnosing a slowdown.
How the firmware is organized¶
The Arduino build concatenates every .ino file in the sketch folder into one program. Xregulator.ino holds the global state, the constants, and the two Arduino entry points — setup() and loop(). The numbered files (2_functions.ino through 7_functions.ino) hold everything else, grouped roughly by subsystem. When this page says "see SendWifiData() in 3_functions.ino", that is a grep anchor: search the named function in the named file.
Boot sequence (setup() in Xregulator.ino)¶
Startup is strictly ordered — storage must be readable before settings load, settings must be loaded before WiFi starts, and the safety watchdog is armed only once everything else is up. The conceptual stages, in the order they run:
1. Serial console and big-buffer allocation. The debug serial port comes up first, then every large buffer in the system — sensor history rings, telemetry payload buffers, the console message queue, IMU sample buffers, tuning logs — is allocated out of the large external RAM chip (PSRAM) via ps_malloc(). Doing all of this up front, in one place, keeps the small internal RAM free for the things that genuinely need it (see Memory and Persistence).
2. Identity and version. The device reads its unique hardware ID (initializeDeviceId()) and parses the firmware version string into a comparable integer for update checks.
3. Key-value storage init. The ESP32's built-in key-value flash store (NVS) is brought up (initializeNVS()). This is where user settings and lifetime counters live.
4. Filesystem mount. The on-flash filesystem for larger data (LittleFS) is mounted by ensureLittleFS() in 5_functions.ino. This filesystem is deliberately treated as expendable: it mounts with format-on-fail, so a corrupted filesystem is wiped and recreated rather than bricking the device. After the mount, any ring-buffer backups left by the previous shutdown are restored into RAM (restoreSensorRingFromLittleFS(), restoreLongTermRing()).
5. Crash forensics and boot-slot check. The reason for the last reset is captured (captureResetReason()) and the firmware confirms it is running from the intended flash slot (ensurePreferredBootPartition()).
6. Lifetime counters load. Accumulated values the firmware writes on its own — energy totals, engine hours, extrema, power-cycle counts — are loaded from key-value storage (loadNVSData()), and a shadow cache is seeded so later saves can skip unchanged values (initNVSCache()). A synchronous save (saveNVSDataFull()) then locks in boot-time adjustments before the loop starts.
7. One-time legacy settings import. Older firmware stored each user setting as its own small text file on the filesystem. importLegacySettingsFromLittleFS() in 2_functions.ino sweeps for any of those legacy files, copies each value into key-value storage if it is not already there, and deletes the old file. On a device that has already migrated (or shipped on current firmware) this is a no-op. It deliberately runs before anything that reads settings.
8. Settings load. InitSystemSettings() in 4_functions.ino reads every user-configurable setting from key-value storage; any key that does not exist yet is created with its hard-coded default. Related loaders follow for weather-mode settings, tuning-test logs, and the interface password.
9. WiFi bring-up. setupWiFi() in 3_functions.ino chooses one of three modes:
- Configuration mode (
MODE_CONFIG) — a captive-portal hotspot for entering WiFi credentials. Entered on first boot (no configuration has ever been saved), when no usable credentials exist, or when forced by a hardware strap pin. In this mode the alternator is intentionally disabled — the loop exits early every pass. - Access-point mode (
MODE_AP) — the regulator hosts its own WiFi network with full alternator operation. Selected by a hardware strap pin; this is the emergency path when the boat's router is down. - Client mode (
MODE_CLIENT) — normal operation: join the boat's WiFi using stored credentials (connectToWiFi()). If the join fails, the firmware keeps retrying client mode rather than silently falling back to a hotspot.
The full dashboard web server (setupServer()) is started in client and AP modes; the stripped-down credentials portal (setupWiFiConfigServer()) is started in configuration mode.
10. Background task creation. The two helper tasks (described below) are created and pinned to the second CPU core, plus the queue used to hand them work.
11. Hardware and final arming. Clock sync over the network where available, then sensor hardware initialization (initializeHardware()), the hardware watchdog (a multi-second timeout that hard-reboots the chip if the loop ever hangs — a reboot drops all output pins, so the field fails safe), the learned field-control tables (loadLearningTableFromNVS()), a couple of priming sensor reads, and the loggers. Then the main loop takes over.
Main loop (loop() in Xregulator.ino)¶
The loop is cooperative and non-blocking: one pass normally completes in single-digit milliseconds, and nothing in it is allowed to sit and wait. Instead of delay(), every periodic job uses the same pattern — remember the last time it ran, and run again only when enough milliseconds have elapsed:
if (now - lastRunTime >= interval) {
lastRunTime = now
do the job
}
This shows up dozens of times across the loop: battery state-of-charge and engine-runtime updates on a slow cadence, telemetry on its own cadences, WiFi health checks on another. The loop never blocks on any of them.
A single pass, conceptually:
- Feed the watchdog and read the ignition input and WiFi wake button.
- Mode gate. In configuration mode the pass services the captive portal and returns — no alternator control runs. Access-point mode falls through into the same full path as client mode.
- Read sensors.
ReadAnalogInputs()in 5_functions.ino is the entry point for the analog/I2C sensor set (battery voltage, alternator current, RPM, temperatures, pressure). It is built around small non-blocking state machines — it triggers a conversion on one pass and collects the result on a later pass, never waiting in place. Optional inputs (a Victron serial feed viaReadVEData(), the NMEA2000 network) are polled here too. - Compute derived values and check alarms.
calculateDerivedMetrics()(wind, VMG, duty-cycle math) andCheckAlarms(). - Adjust the field.
AdjustFieldLearnMode()in 6_functions.ino is the field-control entry point — despite the legacy "LearnMode" name it runs in every mode and makes all field decisions: target selection, the control loops, safety overrides, ramps and shutdown sequencing. See the field-control documentation for the internals. - Send telemetry via
SendWifiData()(below). - Housekeeping. Deferred flash writes requested by dashboard buttons are drained here (the
pendingSave*flags near the middle ofloop()), so slow flash work happens on the loop's terms rather than inside a network callback. Heavy key-value commits are deliberately deferred to moments when the field is off, so a flash sector erase can never stall the voltage control loop. Loop-time statistics are updated at the bottom of every pass.
Priority-gated telemetry dispatch¶
Live data reaches the browser as a server-sent event stream with several channels. SendWifiData() in 3_functions.ino enforces a strict rule: at most one channel transmits per loop pass, checked in priority order —
- CSV1 — high-rate live numbers (voltage, current, RPM, duty), many times per second
- Console — queued human-readable messages
- CSV2 — slower status and diagnostics, every few seconds (gated behind CSV1 so the two never double-send in one pass)
- CSV3 — the settings echo, sent when a setting changes (a
settingsDirtyrising edge) with a slow heartbeat fallback - Timestamps — per-sensor freshness stamps so the dashboard can grey out stale fields
Because only one channel fires per pass, adding a field to a payload does not add per-pass CPU cost — it only makes that channel's eventual packet a little longer.
Background tasks (FreeRTOS)¶
The Arduino loop runs on one CPU core; everything that must block goes to a small set of dedicated tasks pinned to the other core (created with xTaskCreatePinnedToCore() in setup()):
TempTask(2_functions.ino) — owns the digital one-wire alternator temperature sensor, whose conversions involve real waiting that would wreck loop timing. It updates a heartbeat every cycle; the loop side watches that heartbeat (checkTempTaskHealth()) and treats silence as a fault.httpsTask(2_functions.ino) — the secure-upload worker. The loop side never performs network uploads itself; it builds a payload in PSRAM and posts a small request onto a shallow queue (httpsQueue), and this task drains the queue and performs the slow TLS work — cloud telemetry uploads, update checks, weather fetches.
The WiFi stack and the asynchronous web server library run their own internal tasks as well; the firmware's design goal is that nothing on the loop's core ever blocks on I/O.
Performance instrumentation — the first tool for slowdowns¶
Every significant function call in the loop is wall-clock timed. The pattern is a tiny struct plus a wrapper macro, both defined in Xregulator.ino:
FuncTimingholds, per function: the duration of the last call, the worst duration in a short rolling window (reset every few seconds), and the worst duration of the whole session.TIMED_CALL(ft_SomeFunction, SomeFunction())stamps a microsecond timer around the call and updates those three numbers.
All of the ft_* values are shipped over the slow telemetry channel and shown in the dashboard's Function Timing table, with a "Reset Peak Values" button that zeroes the session worsts on demand. If loop timing degrades, this table answers the question "which function spiked, and when" without attaching a debugger — if a loop-time spike appears that no timed row explains, the usual cause is an untimed flash write, and the fix is to wrap it in TIMED_CALL so it shows up next time.