PinGuard is a framework-owned compile-time pin validation layer that prevents invalid GPIO assignments from crashing or silently corrupting hardware initialisation on ESP32 targets.
Several ESP32 GPIOs are permanently unusable for certain purposes:
- Flash-reserved GPIOs — wired internally to the SPI flash chip. Writing to them causes an immediate watchdog reset or flash corruption.
- Input-only GPIOs — silicon-level limitation; using them as outputs silently fails or hangs the peripheral driver (e.g. SPI).
- Strapping / UART0 GPIOs — held at specific logic levels during boot; driving them during initialisation can prevent the board from booting at all.
Without a guard these errors are invisible: the config command returns ok, the value is saved to EEPROM, and the board enters a watchdog reset loop on the next boot with no actionable serial output.
PinGuard catches these problems at the point the pin is set, not when the board crashes.
PinGuard is included automatically via BoardConfig.h — no include is needed in consumer files. The correct pin table is selected at compile time using the IDF-provided macros CONFIG_IDF_TARGET_ESP32S3 and CONFIG_IDF_TARGET_ESP32, which are emitted by the Arduino-ESP32 toolchain based on the board selected in the IDE. No manual defines are required in Local.h.
When a guarded pin-setting command is received, the setter calls:
PinGuardResult result = PinGuard::validate(pin, PinUse::SpiSck);If the result is HardBlocked or AdvisoryBlocked the setter returns ConfigResult::InvalidPin / RelayResult::InvalidPin, the command handler sends back ACK:<cmd>=Invalid pin:<params>, and a serial debug message names the blocked pin and the reason.
Not all pin-setting commands call PinGuard — see Guarded Commands below for the exact coverage.
PinGuard checks are applied at the setter level for the following commands only.
| Command | Description | Pins validated | PinUse |
|---|---|---|---|
C4 |
SPI bus pins | SCK, MOSI | SpiSck, SpiMosi |
C4 |
SPI bus pins | MISO | SpiMiso |
C6 |
XpdzTone buzzer pin | pin | Output |
C8 |
HW-479 RGB LED pins | R, G, B | Output |
C18 |
RTC DS1302 pins | data, clock, reset | Output |
C32 |
SD card chip-select pin | CS | SpiCs |
N3 |
Nextion display RX pin | RX | Input |
N4 |
Nextion display TX pin | TX | Output |
R11 |
Relay output pin | pin | Relay |
| Command | Description | Status |
|---|---|---|
S4 |
Sensor pin slot assignment | No PinGuard check — any GPIO value is accepted |
| Tier | Result | Meaning | Overrideable? |
|---|---|---|---|
| Hard | HardBlocked |
Flash-reserved or input-only used as output — will crash or silently fail | No |
| Advisory | AdvisoryBlocked |
Strapping or UART0 pin — risky but sometimes legitimate | Yes — via F14 AllowAdvisory mode |
| Safe | Safe |
No restriction | — |
| Disabled | Disabled |
Pin == 0xFF (PinDisabled) — not fitted, no check performed |
— |
The advisory and bypass behaviour is controlled by the persistent pinGuardFlags system setting — see PinGuard System Mode below.
Input-only GPIOs (e.g. ESP32 GPIO 34–39, ESP32-S3 GPIO 45–46) are handled as follows:
- When the requested
PinUserequires output (see table below): the pin is hard-blocked regardless of PinGuard mode — this is a silicon-level limitation. - When the requested
PinUsedoes not require output: the advisory gate is bypassed andvalidate()returnsSafe.
Input-only GPIOs are stored as PinCategory::Advisory in the pin table. The promotion to HardBlocked for output uses is applied inside validate() via the _inputOnlyMin/_inputOnlyMax range check. For non-output uses the pin is returned directly as Safe without consulting the advisory mode flag.
PinUse value |
Requires output-capable GPIO? | Typical caller |
|---|---|---|
Output |
Yes | Generic digital output, buzzer |
Input |
No | Generic digital / analogue input |
SpiSck |
Yes | C4 SPI clock |
SpiMosi |
Yes | C4 SPI MOSI |
SpiMiso |
No | C4 SPI MISO |
SpiCs |
Yes | C32 SD card chip-select |
Relay |
Yes | R11 relay pin |
Sensor |
No | Sensor signal pin |
Selected when CONFIG_IDF_TARGET_ESP32 is defined by the toolchain.
| GPIO | Reason |
|---|---|
| 6, 7, 8, 9, 10, 11 | Connected to internal SPI flash — any access causes immediate crash |
| GPIO | Reason |
|---|---|
| 34, 35, 36, 37, 38, 39 | Input-only silicon — cannot drive output |
| GPIO | Reason |
|---|---|
| 0 | Strapping pin — held low during flash download mode |
| 1 | UART0 TX — used by serial debug output |
| 2 | Strapping pin — must be low at boot for flash download |
| 3 | UART0 RX — used by serial debug input |
| 5 | Strapping pin — controls SDIO slave timing |
| 12 | Strapping pin — selects flash voltage; held low at boot |
| 15 | Strapping pin — controls UART0 log output during boot |
14, 16, 17, 21, 22, 25, 26, 27, 32, 33
| Signal | GPIO |
|---|---|
| SCK | 18 |
| MOSI | 23 |
| MISO | 19 |
| CS | 5 (advisory — prefer 15 or 27) |
Selected when CONFIG_IDF_TARGET_ESP32S3 is defined by the toolchain.
| GPIO | Reason |
|---|---|
| 26, 27, 28, 29, 30, 31, 32 | Connected to internal Octal flash/PSRAM — any access causes immediate crash |
| GPIO | Reason |
|---|---|
| 45, 46 | Input-only silicon on S3 |
| GPIO | Reason |
|---|---|
| 0 | Strapping pin |
| 3 | UART0 RX |
| 19 | USB D− |
| 20 | USB D+ |
| 45 | Boot-mode strapping |
| 46 | Boot-mode strapping |
PinGuard prevents new bad pins from being saved, but a board that already has invalid pins in EEPROM will enter a watchdog reset loop before any serial handler is registered.
The crash loop guard in PowerControlHubApp::setup() detects and breaks this cycle automatically:
ConfigManager::incrementCrashCounter()is called immediately afterConfigManager::load(), before any hardware initialisation. The counter is persisted to EEPROM so a watchdog reset cannot undo it.- If
crashCounter >= CrashCounterThreshold(default 3), the firmware:- Sends
ERR:Crash loop detected — resetting config to defaultson the serial port. - Calls
ConfigManager::resetToDefaults()and saves to EEPROM. - Raises the
DefaultConfigurationFuseBoxwarning. - Continues booting with safe default config.
- Sends
- At the very end of
setup(), afterSystemInitializedis sent,ConfigManager::resetCrashCounter()is called. This only executes if the full initialisation sequence completed without a watchdog reset.
| Boot | crashCounter after increment | Outcome |
|---|---|---|
| 1 | 1 | WDT reset during SPI init — counter never cleared |
| 2 | 2 | WDT reset again — counter never cleared |
| 3 | 3 ≥ threshold | Config reset to defaults, boot completes, counter cleared to 0 |
The threshold is defined in ConfigManager.h:
constexpr uint8_t CrashCounterThreshold = 3;The PinGuard mode is a persistent bitmask stored in SystemHeader::pinGuardFlags and controlled via the F14 serial command. It takes effect immediately at runtime without requiring a reboot and is also applied on every boot before any hardware initialisation.
| Flag | Bit | F14 param |
Effect |
|---|---|---|---|
None |
0x00 |
— | Default strict mode — advisory pins are blocked |
AllowAdvisory |
0x01 |
a=true |
Advisory (strapping/UART) pins are permitted |
Bypass |
0x02 |
b=true |
All PinGuard checks are skipped — validate() always returns Safe |
Note: If both
AllowAdvisoryandBypassare set,Bypasstakes precedence andvalidate()returnsSafeimmediately. Hard-blocked pins (flash-reserved) are never automatically safe —Bypassis an explicit opt-out and should only be used when the board design intentionally uses risky GPIOs.
F14 # read current mode — returns a=0;b=0
F14:a=true # allow advisory pins, bypass off
F14:a=false;b=false # reset to strict mode (same as default)
F14:b=true # bypass all checks
If a user saves invalid pins to EEPROM and the board enters a watchdog reset loop, the crash loop guard (see above) will reset the entire config — including pinGuardFlags — to safe defaults after three consecutive crashes. There is no need to use Bypass mode for recovery; the crash guard handles it automatically.
The user can also send C2 (Config Reset) over serial to reset to defaults at any time.
- Open
PowerControlHub/PinGuard.h. - Add a new
#elif defined(CONFIG_IDF_TARGET_<variant>)block before the existing#elif defined(CONFIG_IDF_TARGET_ESP32)fallback. - Populate
_pinTable[],_pinTableSize,_inputOnlyMin, and_inputOnlyMaxfor the new target. - No changes to
Local.hor any consumer file are needed — the toolchain macro selects the table automatically.
PinGuard.h— implementationConfigManager.h—CrashCounterThreshold,incrementCrashCounter,resetCrashCounterDocs/Commands.md—C4,C6,C8,C18,C32,N3,N4,R11,S4pin-setting commandsDocs/Warnings.md—SpiPinConfigError,DefaultConfigurationFuseBox