Boot and Main Loop¶
This page walks through what the XREG-010 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 8_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. Settings load, then the schema migration chain. 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 key-value loaders follow for the alternator-health class scaling, the battery-health settings, and the solar-forecast (Defer to Solar) settings. runSettingsMigrations() runs immediately after that last key-value settings loader — deliberately, so its numbered steps see the fully seeded key set — and walks the stored settings-schema version forward to the compiled-in one. The filesystem-backed restores (the tuning, CV-tuning and plant-delay logs, the commissioning ledger) come after the migration chain; they are not settings and nothing in a migration step reads them. See Memory and Persistence for the rules that govern adding a step.
8. 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 hardware strap pins that force these modes are tabulated in Troubleshooting → Forced recovery. The full dashboard web server (setupServer()) is started in client and AP modes; the stripped-down credentials portal (setupWiFiConfigServer()) is started in configuration mode.
9. Background task creation. The helper tasks (described below) are created and pinned to the second CPU core, plus the queue and semaphore used to hand them work.
10. 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, read the ignition input and WiFi wake button, run the slow-cadence bookkeeping (state of charge, engine runtime), and drain any deferred flash writes requested by dashboard buttons (the
pendingSave*flags) — so that slow flash work happens on the loop's terms rather than inside a network callback. All of this runs ahead of the mode gate. - 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. 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 fixed priority order — the channel list, cadences, and priority ladder are canonical in the Telemetry Pipeline.
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 one-wire bus and every DS18B20 on it (alternator, and optionally battery and extra probes), 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.faDetTask(2_functions.ino) — the fast alternator-current fault detector's analysis pass. It is woken by notification and returns its verdict through a semaphore (faResultSem); running it here keeps the once-a-minute computation off the control core, which used to freeze in lockstep with it.cpuLoadTask— the CPU-load sampler. It runs a task-state scan every 2 s at priority 0, below every network and upload task, so it yields rather than adding to Core-0 spikes.
The last two are created with xTaskCreatePinnedToCoreWithCaps(..., MALLOC_CAP_SPIRAM) so their stacks live in external RAM: their combined 12 KB of internal stack was a large part of the regression that pushed the TLS handshake's contiguous-memory floor out of reach.
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), the worst duration of the whole session, and the loop-pass number that last call belongs to (passSeq, stamped from a counter bumped at the top of every pass).TIMED_CALL(ft_SomeFunction, SomeFunction())stamps a microsecond timer around the call and updates those four 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.
The passSeq stamp is what makes attribution automatic. Whenever a field-on pass sets a new session worst, captureLoopBlame() ranks the timed calls whose stamp matches the current pass and keeps the top three; those names and durations ride the slow telemetry channel and appear as the "Worst Field-On Pass — Top Consumers" line under the field-on loop worst. The registry it ranks (ftBlameReg[] in Xregulator.ino) deliberately holds only loop-resident top-level timers — nested sub-blocks would double-bill their parent's microseconds, and a Core-0 task's timer stamps whatever pass number happens to be current when it fires, so its stamp means nothing — and its order must stay in step with FT_BLAME_NAMES in script.js, because the wire carries indices into it, not names.