Skip to content

Keep retrying STA after AP fallback, and defer mode switches off the event handler (#78) - #238

Merged
BenSeverson merged 3 commits into
mainfrom
fix/wifi-sta-retry
Jul 29, 2026
Merged

Keep retrying STA after AP fallback, and defer mode switches off the event handler (#78)#238
BenSeverson merged 3 commits into
mainfrom
fix/wifi-sta-retry

Conversation

@BenSeverson

Copy link
Copy Markdown
Owner

Closes #78.

Both halves of the issue were still real — nothing had been fixed since filing, and the second half is worse than described.

The bug

A kiln whose router rebooted — or that powered on before the router finished booting — fell back to its provisioning AP and stayed there until someone physically power-cycled it. No remote access to a device that may be mid-firing.

The fallback path was also fragile in a way the issue didn't capture: start_ap() called esp_wifi_stop() and esp_netif_create_default_wifi_ap() inline on the event-loop task, so a second fallback aborted on the duplicate netif. Three ESP_ERROR_CHECKs in that path would panic the controller on a transient Wi-Fi error.

The fix

Nothing that touches the radio runs in the event handler any more. The handler does bookkeeping and a non-blocking xQueueSend; a new worker task (priority 1, core 0) is the only place the mode changes. It cannot delay the firing or safety tasks and never touches the SSR.

  • wifi_manager_init creates both netifs once, before the radio starts — that is the actual fix for the duplicate-netif abort.
  • enter_ap_fallback is idempotent and sets WIFI_MODE_APSTA when credentials exist, so the STA interface stays available. ESP_ERROR_CHECK → logged error + next-tick retry.
  • leave_ap_fallback returns to plain STA once recovered, but only when no AP client is associated.
  • wifi_manager_is_ap_mode() now means "the AP is the only way in" — false during the brief APSTA overlap, so the status LED and /api/wifi don't lie.

Retry cadence: 30 s doubling to a 5 min ceiling, forever

Bounded so an unreachable router isn't 720 radio events/hour during a firing; persistent because giving up is the bug. The 5 min cap (rather than 15) is deliberate: worst-case remote-monitoring blackout after the router returns is one interval, and a connect attempt is cheap.

Suppression while a client is on the AP — but bounded

A STA connect makes the shared radio scan off-channel, which is exactly "yanking the interface out from under someone mid-provisioning". So retries are suppressed while an AP client is associated — capped at 15 min overdue, otherwise a phone that auto-joined Bisque and got pocketed strands the kiln permanently, trading one failure mode for another.

Suppression deliberately does not advance the policy state, so the overdue attempt fires on the first poll after the client leaves rather than waiting out a fresh backoff. That same property makes "how overdue" a free suppression clock, so bounding it needs no extra field.

Tests

The policy is extracted into wifi_retry_policy.{c,h} — pure, no esp_wifi/esp_event/FreeRTOS — following the safety_helpers.c pattern that exists for exactly this reason. 10 host tests in tests/host/test_wifi_retry_policy.c.

RED verified first, with wifi_retry_step stubbed to return WIFI_RETRY_NOT_DUE (precisely today's never-retry behavior): 9 of 10 failed.

test_retries_sta_after_ap_fallback:FAIL: Expected 2 Was 0
test_retries_are_persistent_over_hours:FAIL: Expected 0 to be greater than 20
test_associated_ap_client_suppresses_retry:FAIL: Expected 1 Was 0
test_suppression_is_bounded:FAIL: Expected 1 Was 0

test_cadence_is_not_a_hammer passed vacuously under that stub — it is an upper bound, deliberately paired with test_retries_are_persistent_over_hours as the lower bound so neither can be satisfied alone.

Independently spot-checked by removing the suppression bound from the merged implementation:

test_wifi_retry_policy.c:151:test_suppression_is_bounded:FAIL: Expected 2 Was 1
Check Result
make firmware 0 — app 0x17b2d0, 63% free
make test-host 0 — 7/7 suites, incl. test_wifi_retry_policy
./scripts/lint.sh 0

Coverage gap, stated plainly

The rest of wifi_manager.c — worker task, mode transitions, netif lifecycle — has no automated coverage; it's firmware-build verification only. APSTA behavior and AP teardown are unverified without hardware. Only the retry policy is tested.

Noticed, deliberately not fixed

  • Provisioning still requires a reboot. POST /api/v1/wifi only writes NVS ("Reboot to connect"), so while in fallback the retry loop keeps trying the old SSID after a user provisions a new one. Harmless — suppression holds retries while they're on the AP — but the obvious follow-up is re-arming the worker with new credentials live.
  • wifi_manager_is_connected() conflates "STA associated" with "AP is up" (the AP sets WIFI_CONNECTED_BIT). status_led.c and main.c depend on that conflation, so it's preserved rather than widening the blast radius; only the clearing of the bit on a dropped STA link was corrected.
  • esp_event_loop_create_default() is still ESP_ERROR_CHECKed and will panic if something else created the loop first. Out of scope.

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91490594c2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/wifi_manager/wifi_manager.c Outdated
Comment on lines +172 to +176
if (err != ESP_OK) {
/* Deliberately not ESP_ERROR_CHECK: aborting the controller over a
Wi-Fi hiccup would take a firing with it. Retry on the next tick. */
ESP_LOGE(TAG, "esp_wifi_set_mode(%s) failed: %s", s_sta_configured ? "APSTA" : "AP", esp_err_to_name(err));
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the fallback pending after a mode-switch failure

When esp_wifi_set_mode(APSTA) returns a non-OK result, the sole WIFI_CMD_ENTER_AP_FALLBACK command has already been dequeued. Because s_ap_active remains false, every subsequent worker tick returns immediately from service_ap_fallback(), and no further event is guaranteed to enqueue another transition. A transient failure therefore leaves the controller without either STA connectivity or its provisioning AP until reboot, despite this branch explicitly intending to retry on the next tick.

Useful? React with 👍 / 👎.

Comment on lines +154 to +157
esp_err_t err = esp_wifi_set_config(WIFI_IF_AP, &ap_config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_wifi_set_config(AP) failed: %s", esp_err_to_name(err));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retry AP configuration before advertising fallback

If esp_wifi_set_config(WIFI_IF_AP, ...) fails, this function only logs the error, after which enter_ap_fallback() sets s_ap_active, publishes 192.168.4.1, and sets WIFI_CONNECTED_BIT. The transition then becomes idempotently suppressed, so the configuration is never retried and callers are told an AP is available even though it may have stale/default settings; in the no-credentials boot path there is no STA connection that can recover access either. Propagate this failure and mark the AP active only after configuration succeeds.

Useful? React with 👍 / 👎.

Comment on lines 127 to +130
s_retry_count = 0;
s_sta_connected = true;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
post_cmd(WIFI_CMD_POLL);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset fallback backoff after STA recovery

When STA recovers while an AP client is still associated, leave_ap_fallback() keeps the AP active, but this handler resets only the fast retry counter and never resets s_retry. If STA drops again during that overlap, the fallback policy uses the timestamp and attempt count from before the successful connection; after a sufficiently long recovery it is already overdue and can reconnect immediately, or bypass AP-client suppression that should have restarted, disrupting the associated provisioning client. Notify the worker to reset the fallback policy whenever a new STA IP is obtained.

Useful? React with 👍 / 👎.

Comment thread components/wifi_manager/wifi_manager.c Outdated
Comment on lines +85 to +87
case WIFI_EVENT_STA_DISCONNECTED:
if (s_retry_count < s_max_retries) {
s_retry_count++;
ESP_LOGI(TAG, "STA retry %d/%d", s_retry_count, s_max_retries);
esp_wifi_connect();
} else {
ESP_LOGW(TAG, "STA connection failed, switching to AP mode");
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
start_ap();
s_sta_connected = false;
if (!s_ap_active) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the AP address when the recovered STA drops again

If an AP client keeps the fallback AP open after STA recovery and the router then drops again, this branch changes s_sta_connected to false but leaves s_ip_str containing the now-invalid STA address written by the prior IP_EVENT_STA_GOT_IP. The controller is reachable only at 192.168.4.1 and reports apMode: true, yet /api/v1/wifi and the Wi-Fi settings card display the stale LAN address. Restore the AP address when a disconnect occurs while s_ap_active is true.

Useful? React with 👍 / 👎.

Comment thread components/wifi_manager/wifi_manager.c Outdated
Comment on lines +98 to +99
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
post_cmd(WIFI_CMD_ENTER_AP_FALLBACK);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Signal readiness only after the fallback AP is active

On normal STA retry exhaustion, WIFI_FAIL_BIT is set before the deferred WIFI_CMD_ENTER_AP_FALLBACK is processed. This wakes wifi_manager_wait_connected() even though its documented condition—STA connected or AP started—has not yet occurred, so app_main can return a timeout, observe wifi_manager_is_ap_mode() as false, and skip the setup-mode boot status while the worker is still waiting to create the AP. Move the wakeup/readiness signal into the worker after the AP transition succeeds.

Useful? React with 👍 / 👎.

BenSeverson and others added 3 commits July 29, 2026 09:22
…event handler (#78)

AP fallback was a one-way door: after five failed STA attempts the controller
brought up its provisioning AP and never tried the configured network again.
A router that reboots — or that is simply slower to boot than the kiln — left
the controller stranded on its own AP with no remote access to a device that
may be mid-firing, recoverable only by a physical power cycle.

Fallback now runs APSTA when STA credentials exist, and a new worker task
retries the configured network on a 30 s → 5 min backoff, forever. Bounded so
the radio is not churning every few seconds during a firing, persistent so
recovery costs at most one backoff interval. Retries are suppressed while a
client is associated with the AP — a STA connect drags the shared radio
off-channel and would yank the provisioning form out from under whoever is
filling it in — and suppression is itself capped at 15 min so a phone that
auto-joined and was pocketed cannot strand the kiln. Once STA reconnects and
the AP is empty, the AP is dropped and we return to the plain STA steady state.

The second half of the issue was start_ap() doing network work inside the
Wi-Fi event handler: it called esp_wifi_stop() and, worse,
esp_netif_create_default_wifi_ap() there, so a second fallback would abort on
the duplicate netif. Both netifs are now created once during init before the
radio starts, the event handler is reduced to bookkeeping plus a queue post,
and every mode switch happens on the worker. The fallback path also drops
ESP_ERROR_CHECK in favour of logged errors and a retry on the next tick —
aborting the controller over a Wi-Fi hiccup would take a firing with it.

The worker is a low-priority task pinned to core 0 that sleeps on its queue;
it cannot delay the firing or safety tasks and never touches the SSR.

The retry policy is extracted into wifi_retry_policy.c as a pure function,
free of esp_wifi/esp_event/FreeRTOS, and covered by host tests — the same
split safety_helpers.c uses, and for the same reason: the rest of
wifi_manager.c is not host-buildable. Verified the tests fail against a policy
that reproduces today's never-retry behaviour before implementing the real one.

Also switches the hardcoded retry limit and AP channel to the APP_WIFI_*
constants they were duplicating, and clears WIFI_CONNECTED_BIT when an
established STA link drops, so the status LED's "disconnected" state works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eadiness

Review follow-ups on #238, all in the paths that only run when something
already went wrong — which is why none of them showed up in normal use.

Splits the transition logic out into wifi_fallback.c: it owns "should the
provisioning AP be up, and is it?", takes every radio call as an injected op,
and is driven by tests/host/test_wifi_fallback.c against a fake radio. The
five fixes:

1. Intent is tracked separately from achieved state. A failed
   esp_wifi_set_mode() used to be terminal — the one queued command had been
   consumed and the service loop began at "is the AP up? no → nothing to do",
   so a transient error left the controller with neither STA nor AP until a
   reboot. ap_wanted now stands until the transition lands.

2. The AP is marked active only after esp_wifi_set_config() succeeds.
   Previously a failed config was logged and the AP advertised anyway, so
   callers were pointed at whatever SSID the interface happened to hold.

3. The backoff policy is re-armed on every tick the STA link is up. Only
   s_retry_count was reset on GOT_IP, so when an AP client held the fallback
   open across a recovery, a second outage fired a reconnect instantly — and
   being that overdue also walks through the bounded client suppression.

4. The reported address is derived from (ap_active && !sta_connected) rather
   than cached, so a LAN address can no longer outlive its link. It used to
   survive a disconnect that happened while the AP was up, leaving
   /api/v1/wifi advertising a dead address for a device only reachable at
   192.168.4.1.

5. Readiness is signalled from the AP-up hook, not from STA retry exhaustion.
   The old WIFI_FAIL_BIT woke wait_connected() before either of its documented
   conditions held, so app_main could see is_ap_mode() as false and skip the
   setup-mode boot status. The bit had no other reader and is gone.
@BenSeverson

Copy link
Copy Markdown
Owner Author

Review follow-ups: all five findings fixed, with host tests

Rebased onto origin/main (2937b8a) — the conflict was with #8844c5d's copy_credential(); resolved by keeping it and using it for the STA config too, so full-width SSIDs/PSKs still aren't truncated. Force-pushed.

The restructure

The four transition-shaped findings all live in code paths that only run when something already failed, which is exactly why they went unnoticed — and none of them were reachable from a host test, because wifi_manager.c is esp_wifi/esp_event all the way down.

So the decisions moved into components/wifi_manager/wifi_fallback.c, same split as wifi_retry_policy.c: it owns "should the provisioning AP be up, and is it?", takes every radio call and notification as an injected op, and pulls in no ESP-IDF header. wifi_manager.c keeps the esp_wifi_* calls and supplies the ops. tests/host/test_wifi_fallback.c drives it against a fake radio that can be told to fail on demand.

This is not a wrapper for its own sake — the failure paths are the whole point, and they are now the easiest thing in the file to test.

Per finding

1 (P1) — fallback dropped forever on a mode-switch failure. Intent is now tracked separately from achieved state. WIFI_CMD_ENTER_AP_FALLBACK only sets ap_wanted; the worker re-runs the whole transition every tick while wanted-but-not-active. The comment that claimed "retry on the next tick" is now true.

2 (P1) — AP advertised even when its config failed. apply_ap_config() returns esp_err_t, and ap_active is set only after both the mode switch and the config land. A failure leaves ap_wanted standing, so finding 1's machinery re-attempts the sequence from the top. The no-credentials boot path also goes through the worker now (init still configures the AP up front so esp_wifi_start() brings up the right SSID, but only the worker marks it active / publishes / signals).

3 (P2) — retry backoff not reset after STA recovery. The policy is re-armed on every worker tick the STA link is up, not just once on an edge — so if an AP client holds the fallback open across a recovery, a second outage starts from the 30 s base backoff instead of firing instantly and walking through the bounded client suppression.

4 (P2) — stale LAN IP reported while only the AP is reachable. Rather than patching the disconnect branch, the cached address is gone: wifi_manager_get_ip() now derives from the same ap_active && !sta_connected condition as wifi_manager_is_ap_mode(), over a s_sta_ip that is only written on GOT_IP. The two can no longer disagree, and a LAN address cannot outlive its link.

5 (P2) — readiness signalled before the AP exists. WIFI_CONNECTED_BIT is set from the AP-up hook, once the transition has actually happened. WIFI_FAIL_BIT had no other reader and is deleted.

RED evidence

Each fix was reverted individually against the committed code and the suite re-run. Failing assertions only (ctest exit code = failure count):

RED 1  (drop ap_wanted, one-shot like the old queued command)        EXIT=3
  test_mode_switch_failure_is_retried_until_it_lands:FAIL: Expected 1 to be greater than or equal to 5
  test_ap_is_not_advertised_until_its_config_lands:FAIL: Expected TRUE Was FALSE
  test_readiness_is_signalled_once_and_only_after_the_ap_is_up:FAIL: Expected 1 Was 0

RED 2  (log the failed AP config and advertise anyway)               EXIT=1
  test_ap_is_not_advertised_until_its_config_lands:FAIL: Expected FALSE Was TRUE

RED 3  (no re-arm of the backoff on recovery)                        EXIT=1
  test_backoff_is_rearmed_while_the_sta_link_is_up:FAIL: Expected 0 Was 2
      ^ WIFI_RETRY_NOT_DUE expected, WIFI_RETRY_ATTEMPT returned — one tick
        after the link dropped, with a client still associated. Exactly the
        "already overdue, blows past suppression" case in the review.

RED 4  (report the cached STA address regardless of the link)        EXIT=2
  test_reported_ip_reverts_to_the_ap_when_the_sta_link_drops:FAIL: Expected '192.168.4.1' Was '10.0.0.7'

RED 5  (signal readiness when the fallback is requested)             EXIT=8
  test_readiness_is_signalled_once_and_only_after_the_ap_is_up:FAIL: Expected 0 Was 1

Verification

command exit
make firmware 0
make test-host (8/8) 0
./scripts/lint.sh 0
clang-format --dry-run --Werror on changed C 0

What I did not verify

  • No hardware. Nothing was flashed. The parts that remain untested are the glue inside wifi_manager.c: that op_ap_up sets the event bit, that the handler writes s_sta_ip on GOT_IP, and the init-time AP path. Reviewed and compiled, not executed.
  • make cppcheck exits 2 locally — entirely from pre-existing findings in files this branch does not touch (main/main.c, firing_engine.c, api_handlers.c, firing_history.c), i.e. my local cppcheck is newer than CI's image. After a cleanup pass there are no cppcheck findings in wifi_manager.c / wifi_fallback.c (the new op callbacks originally tripped constParameterCallback on an unused ctx param, so the ops dropped ctx — there is exactly one radio).
  • make clang-tidy is not usable in my environment (the local esp-clang rejects several GCC flags from compile_commands.json), so CI's static-analysis step is unverified locally.

Behaviour change worth a second opinion

Deleting WIFI_FAIL_BIT means wifi_manager_wait_connected() now blocks its full 30 s if the AP genuinely never comes up, where before it returned early. That is the intended fix for finding 5 — early return was the bug — but it is a real change to boot timing in the "radio is wedged" case.

One thing I left alone: when neither STA nor AP is up, wifi_manager_get_ip() still returns the last STA address rather than 0.0.0.0. Pre-existing, outside these five findings, happy to fix if you want it.

@BenSeverson
BenSeverson merged commit 05a8ecf into main Jul 29, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wi-Fi never retries STA after AP fallback; start_ap() fragile in event-handler context

1 participant