Watchdogs, Brownout & Robustness Patterns¶
A device deployed in the field has no keyboard, no debugger, and no one
watching. When it locks up, corrupts its own filesystem, or gets stuck
in a boot loop, the only thing standing between "runs for years
unattended" and "someone drives out to power-cycle it" is the
robustness patterns in this module. Reviewed against machine.WDT's
documented API and general embedded resilience practice; the
supervisor/safe-mode/crash-loop logic below is verified with python3.
machine.WDT — the hardware watchdog¶
from machine import WDT
# Must be fed within 5000ms of each feed() call, or the hardware
# resets the whole chip unconditionally — WDT.feed() cannot be
# cancelled or paused once started on most ports.
wdt = WDT(timeout=5000)
def main_loop():
while True:
do_work()
wdt.feed() # proves this loop is still alive and making progress
The watchdog is a piece of hardware, independent of the CPU running your Python interpreter, that resets the chip if not "fed" within the configured timeout. Its entire value is that it works even when the interpreter itself is wedged — an infinite loop, a deadlock, code stuck waiting on a peripheral that never responds — none of these prevent the watchdog hardware from firing, because it doesn't depend on Python execution continuing correctly at all.
The trap: feeding the watchdog from a place that doesn't actually prove the application is healthy defeats its entire purpose.
# WRONG — feeds unconditionally from a timer IRQ, regardless of
# whether the actual application logic is still functioning
from machine import Timer
def feed_regardless(t):
wdt.feed()
Timer(0).init(period=1000, callback=feed_regardless)
# Now the watchdog can never fire, even if main_loop() above has
# deadlocked completely — the timer keeps feeding it forever
wdt.feed() should only ever be called from a point in the code that
genuinely proves forward progress on the actual application logic —
typically once per iteration of the main loop, after real work
completed, never from an independent timer that runs regardless of
what the rest of the program is doing.
Supervisor patterns — process-level self-healing¶
For applications structured with the state machine from the production architecture module, a supervisor wraps the whole run loop and reacts to failures without needing the watchdog's blunt full-chip-reset for every kind of problem:
import time
def supervised_run(app_step, max_consecutive_errors=5):
consecutive_errors = 0
while True:
try:
app_step()
consecutive_errors = 0
wdt.feed()
except Exception as e:
consecutive_errors += 1
print("app_step failed:", e, "count:", consecutive_errors)
if consecutive_errors >= max_consecutive_errors:
raise # let it propagate — time for a full reset, not a retry
time.sleep_ms(200 * consecutive_errors) # backoff
Catching Exception broadly here is a deliberate, unusual choice —
normally too broad — because the supervisor's whole job is staying
alive through unexpected failures in application code, while still
giving up and triggering a real reset once errors are frequent enough
to suggest something more serious than a transient glitch (a stuck
sensor, one dropped network packet) is happening.
Surviving brownouts¶
A brownout — the supply voltage dipping below what the chip needs, often from an under-sized power supply under load spikes (radio transmit being a common culprit) — can corrupt in-progress flash writes specifically, since flash writes are the one operation genuinely vulnerable to power interruption mid-operation.
def safe_write_file(path, data):
tmp = path + ".tmp"
with open(tmp, "wb") as f:
f.write(data)
f.flush()
import os
os.rename(tmp, path) # atomic — a brownout here leaves the old file intact
This is the same write-to-temp-then-rename pattern from the OTA module, and it applies everywhere a file is written on a device that might lose power mid-write, not just during updates — any config save, any log rotation. The failure mode being defended against specifically is: power dies during the direct-write version, leaving a half-written file that's neither the old nor the new valid content, which is worse than losing the update entirely.
Safe-mode boots¶
When main.py itself is what's crashing (a bad config value, a
corrupted file, logic that throws before ever reaching the point where
it could recover gracefully), the device needs an escape hatch that
doesn't depend on the very code that's broken:
# boot.py runs before main.py on most ports — this is the natural
# place for safe-mode detection, since it executes even if main.py
# itself is what's unable to run
import os
def crash_count():
try:
with open("crash_count.txt") as f:
return int(f.read())
except (OSError, ValueError):
return 0
def record_crash():
with open("crash_count.txt", "w") as f:
f.write(str(crash_count() + 1))
def clear_crash_count():
try:
os.remove("crash_count.txt")
except OSError:
pass
if crash_count() >= 3:
print("repeated crashes detected — entering safe mode, main.py skipped")
# Safe mode: no main.py import, drop into a minimal REPL-reachable
# state so the device can still be recovered remotely (module 07)
# rather than boot-looping forever.
else:
record_crash()
# main.py runs next as normal; on success, application code should
# call clear_crash_count() once it's confirmed healthy (compare to
# the OTA module's confirm-or-rollback pattern)
This mirrors the OTA module's "pending, not confirmed until healthy" pattern for exactly the same reason: a crash counter that only resets on confirmed success, checked before the risky code runs, is what turns an infinite boot loop into a bounded number of attempts followed by a recoverable safe state.
Designing for unattended recovery over years¶
- Every persistent write goes through the safe-write pattern — no exceptions, because the cost of getting this wrong (a corrupted config or crash-counter file) can itself cause the very crash loop the mechanism exists to prevent.
- The watchdog timeout should be generous enough that legitimate slow operations (a sensor with a genuinely long conversion time, a congested network write) don't trigger spurious resets, but tight enough that a real hang doesn't leave the device unresponsive for an unacceptable stretch — this number should come from measuring actual worst-case legitimate operation time, not a guess.
- Crash-loop detection (the
crash_count.txtpattern) and the watchdog are complementary, not redundant: the watchdog catches hangs; the crash counter catches fast, repeated crashes that never hang long enough for the watchdog to matter.
How It Actually Works¶
Every pattern here works by having something outside the thing that might fail be the mechanism that detects and recovers from the failure — software cannot reliably supervise itself once it's the thing that's stuck.
machine.WDTis a hardware countdown timer, wired directly to the chip's reset line, exactly like the RP2040/ESP32 discussion in Level 2 module 9 — the crucial detail here is that its countdown logic runs on a clock and comparator circuit entirely separate from the CPU pipeline executing your Python bytecode.wdt.feed()is a single register write reloading the countdown value; if that write doesn't happen before the counter reaches zero, dedicated logic asserts a physical reset signal to the CPU core. This is precisely why it catches failures no Python-level exception handler ever could: a truly wedged interpreter (spinning in a C-level blocking call, or stuck because the GC's mark phase hit a corrupted pointer) has stopped executing Python bytecode altogether, so there's no exception-handling code running anywhere to catch anything — only a mechanism living outside the interpreter can intervene.- This is also exactly why feeding from an independent
Timercallback defeats the watchdog: the timer peripheral and the main loop are two separate schedulable things, and a hang in the main loop doesn't stop the timer's hardware-driven interrupt from firing on its own schedule. TheTimercallback executes regardless of whethermain_loop()ever reaches itswdt.feed()line — it has no path back to checking that fact, so a watchdog fed this way degenerates into "always resets the reload register on schedule," providing zero information about whether the actual application is alive. - Write-to-temp-then-rename works for brownout survival for the same copy-on-write reason it works for OTA (module 2) and for the crash counter here — LittleFS's directory-entry update is a single small write a partial power interruption can only leave in one of two states: the old entry (rename didn't complete) or the new one (it did), never a hybrid. A brownout specifically threatens flash writes because flash program/erase operations require a stable, sufficiently high voltage to correctly set the floating-gate transistor charge state — a voltage dip mid-write can leave a memory cell in an indeterminate charge level, corrupting exactly the bytes being written at that instant; data already committed to flash before the dip is unaffected, which is why "the old file stays intact" is a real hardware guarantee, not just a filesystem convention.
boot.pyrunning the crash-count check beforemain.pymatters because MicroPython's boot sequence executesboot.pyandmain.pyas two separate, sequential file-level imports — a crash inmain.pycannot preventboot.py's code from running on the next boot, because it's a completely separate execution of the interpreter from scratch. This is the same "every wake/boot restarts fresh" fact from Level 2 module 3's deep-sleep material, applied to crash recovery: since nothing in RAM survives a reset regardless of cause, the only place a crash-loop counter can live to be checked on the next attempt is persistent storage (the filesystem), read by code (boot.py) that runs unconditionally before the potentially-broken code gets a chance to run again.
Cheat sheet¶
| Mechanism | Catches |
|---|---|
machine.WDT |
Hangs/deadlocks — anything that stops forward progress entirely |
| Supervisor with bounded retry | Transient exceptions in application logic, without a full reset |
| Write-to-temp-then-rename | Brownout/power-loss corruption of persisted files |
boot.py crash counter |
Fast, repeated crashes a watchdog timeout wouldn't catch |
Safe mode (skip main.py) |
Recovering a device whose own application code can't run at all |
Exercise¶
In plain Python, implement the crash_count()/record_crash()/
clear_crash_count() trio using a temp file in place of the device
filesystem, plus safe_write_file() using the temp-then-rename
pattern. Write a short simulation: call record_crash() three times
(simulating three failed boots), confirm crash_count() reports 3 and
your safe-mode threshold logic would trigger, then call
clear_crash_count() and confirm crash_count() is back to 0. Add a
supervised_run-style loop (from this module) around a fake
app_step() function that raises on its first two calls and succeeds
on the third, and confirm it recovers without exceeding a
max_consecutive_errors of 5.