WLED OTA gate matrix¶
WLED's HTTP /update handler runs four gates before accepting a
firmware POST: three host-recoverable HTTP-401 gates (same-subnet,
settings-PIN, OTA-lock) plus one HTTP-500 gate (firmware
release-name match). Knowing each one is the quickest path through
any "why did the OTA fail?" debugging session. References point at
the upstream WLED source layout (wled00/wled_server.cpp etc.); in
the RaceLink WLED fork the same files live alongside the
racelink_wled usermod.
The operator-facing failure-mode catalog is in
../RaceLink_Host/firmware-updates.md
§"Common OTA failure modes"; the live wire of
the workflow lives in racelink/services/ota_workflow_service.py
and racelink/services/ota_service.py.
The four gates¶
POST /update
│
├─ Gate 1: same-subnet (wled_server.cpp:529, returns 401)
│ if otaSameSubnet=true:
│ reject if !inSameSubnet(client) AND !strlen(settingsPIN)
│ else:
│ reject if !inLocalSubnet(client)
│
├─ Gate 2: settings PIN (wled_server.cpp:535, returns 401)
│ reject if !correctPIN
│
├─ Gate 3: OTA lock (wled_server.cpp:540, returns 401)
│ reject if otaLock
│
└─ Gate 4: release-name (ota_update.cpp:139-143 + wled_metadata.cpp:153-157,
returns 500 with body "Firmware release name
mismatch: current='X', uploaded='Y'.")
reject if uploaded firmware's WLED_RELEASE_NAME doesn't match the
running build's, UNLESS the POST carries `skipValidation=1` in
the multipart body.
Gates 1-3 produce HTTP 401 and can be auto-recovered host-side
via OTAService._wled_attempt_unlock (POST /settings/sec with
OP=<otaPass>). Gate 4 produces HTTP 500 and requires the host
to set skipValidation=1 in the multipart body explicitly —
operator ticks the "Skip firmware-name validation" checkbox in the
OTA dialog. The checkbox is off by default because flashing a
binary with the wrong chip variant can brick a device; the gate
exists for a reason.
The trap that bites a fleet OTA on AP+STA-mode nodes is Gate 1.
inSameSubnet masks the client's IP against the device's
Network.localIP() & subnetMask(). In AP+STA mode Network.localIP()
returns the STA address, not 4.3.2.1, so an AP-side host with
a perfectly valid 4.3.2.x DHCP lease still fails the check.
inLocalSubnet (wled_server.cpp:66-72) is broader — it explicitly
accepts 4.3.2.0/24 when apActive — but it's only consulted when
otaSameSubnet=false.
Build flags that affect the gates¶
The exhaustive list, derived from the upstream WLED source files
wled00/wled.h and wled00/const.h:
| Variable | Default | Build flag | Effect on the gates |
|---|---|---|---|
apPass |
DEFAULT_AP_PASS = "wled1234" |
-D WLED_AP_PASS="..." (requires WLED_AP_SSID too — wled.h:214 has a hard #error if only one is set) |
WiFi association layer; doesn't touch the OTA gate. |
otaPass |
DEFAULT_OTA_PASS = "wledota" (const.h:41) |
-D WLED_OTA_PASS="..." |
Sets the password the host POSTs to /settings/sec to clear otaLock. Defining the macro also flips otaLock=true by default (wled.h:573-577). |
otaLock |
true if WLED_OTA_PASS defined, else false |
implicit | Gate 3. |
otaSameSubnet |
true unconditionally (wled.h:584 — no #ifdef/#else) |
NONE — there is no WLED_OTA_SAME_SUBNET define |
Gate 1. The compiled default is hardcoded; only runtime flips it. |
settingsPIN |
WLED_PIN macro → fallback "" (wled.h:227-229) |
-D WLED_PIN="1234" (exactly 4 chars) |
Bypasses Gate 1 via !strlen(settingsPIN) short-circuit. Sets correctPIN=false at boot, so Gate 2 then blocks until the PIN is entered. |
Important non-obvious points:
WLED_OTA_PASSdoes NOT bypass same-subnet. Gate 3 (OTA lock) and Gate 1 (same-subnet) are independent. The OTA password only clears Gate 3. A tempting reading of WLED's docs makes this ambiguous; the line-pinned source is unambiguous.- There is no build flag for
otaSameSubnet. The only same-subnet bypass at build time isWLED_PIN, and that comes with the Gate 2 PIN-entry tax.
Five firmware-side options to ship same-subnet=false¶
Ranked by friction:
Option 1 — racelink_wled usermod override (recommended)¶
In the usermod's setup():
// usermods/racelink_wled/racelink_wled.cpp
otaSameSubnet = false; // RaceLink: AP+STA means inSameSubnet
// doesn't recognise AP-side clients.
Runs after deserializeConfigFromFS(), so it overrides whatever
cfg.json carries. Side-effect-free. Doesn't touch WLED core. One
line, contained in the usermod's git history.
Option 2 — patch wled.h:584¶
Equivalent to Option 1 but lives in WLED core. Pick this if your fork policy prefers core-default changes over usermod overrides.
Option 3 — WLED_PIN build flag¶
-D WLED_PIN="1234" in platformio_override.ini. Bypasses Gate 1
via the !strlen(settingsPIN) short-circuit, but Gate 2 then
fires until the host enters the PIN. Host implementation: a single
POST to /settings/sec with PIN=1234 form-encoded, then the OTA.
Side effect: the PIN gates ALL settings pages
(wled_server.cpp:759), not just /update, so any browser pulling
up /settings on a device gets prompted.
The PIN entry flow itself is straightforward (single POST handled
at set.cpp:10-13 → checkSettingsPIN at util.cpp:454-460); the
PIN-everywhere side effect is the cost.
Option 4 — bake cfg.json into LittleFS¶
Ship {"ota":{"same-subnet":false}} in the device's filesystem,
either via a wled00/data/cfg.json build-time addition or a
first-boot writer in the racelink_wled usermod. Less robust than
Options 1 / 2 because a /settings/sec save by the operator can
silently flip otaSameSubnet=true again (the form's SU checkbox
is checked-by-default in WLED's UI).
Option 5 — host-side auto-unlock (already implemented)¶
OTAService._wled_attempt_unlock
on a 401 from /update: POSTs /settings/sec with OP=<otaPass>.
Two effects via WLED's settings handler:
OP=<password>→ ifotaLock=trueAND password matches, clearsotaLock(set.cpp:651).SUargument absent →otaSameSubnet = falseunconditionally (set.cpp:669).
The second effect is what fixes Gate 1, and it doesn't depend on
the password matching. For a default-config device (otaLock=false,
no PIN) the auto-unlock POST always succeeds and persistently sets
otaSameSubnet=false in the device's cfg.json. B is therefore
a real fix, not just a runtime mitigation — every device that
401s gets auto-fixed on its first OTA, future OTAs skip the
auto-unlock round-trip.
Recommendation¶
Ship Option 1 (usermod override) in new firmware images so
factory-fresh nodes are OTA-friendly without the auto-unlock
round-trip. Keep Option 5 (host-side auto-unlock) in place as the
safety net — it covers existing devices flashed before Option 1
was in place, plus any device whose cfg.json somehow ended up
with otaSameSubnet=true (factory reset, manual re-enable, etc.).