tdisplay_p4: reset and rejoin a wedged ESP-AT C6 at runtime (#685)

On units whose C6 runs LilyGo's factory ESP-AT image, Wi-Fi died after
about an hour: scans came back empty, Connect did nothing, and only a
reboot helped. The AT worker brought the SDIO link up once at boot and
never again, so a C6 that wedged mid-session left every command timing
out for the rest of the session.

Link health: AT commands now report OK / ERROR / TIMEOUT / BUSY. A
timeout or busy reply asks for a plain "AT" ping; a ping that times out,
or three command timeouts in a row with no answer between them, is a
stall. ERROR replies are answers and never count. Connects to dead hosts
(CIPSTART) and missing CIPSEND prompts only ask for a ping. The decision
logic lives in c6_link_health.h with a host test.

Recovery: on a stall the worker takes the link down at once (queued
requests fail fast, links read closed so the TCP/WS companion servers
prune them), then runs sdio_bring_down + the XL9535 C6 reset +
c6at_begin, the same path as boot. Resets back off 30 s, 60 s, 120 s,
240 s, then every 5 min; 10 min of a healthy link resets the ladder.
After a successful reset it rejoins the last network and the kept
listener want-flag re-arms AT+CIPSERVER, so port 5000 comes back without
app-side changes. The Wi-Fi status reads "reconnecting" meanwhile, and
main.cpp no longer treats the reset as "radio off".

Join/disconnect: one latest-wins slot instead of the 6-deep queue, so
the user's Connect can no longer be dropped behind 10 s retry pairs and
a stale CWQAP can no longer undo a fresh join. c6at_join_busy() gates
main.cpp's retry while a join is pending or running.

Bring-up now also sends AT+SLEEP=0 and sets AT+CWRECONNCFG=5,0 when the
AT build supports it (query first; ERROR on old images is ignored).

The esp-hosted build is unchanged: everything is in the c6_at component
or behind !TDP4_C6_HOSTED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaj SchittecatandClaude Opus 5.5 committed 2026-10-18 15:05:54 +02:00
1 parent 2656327d2a
commit ce620f2671
7 files changed
+495 -51

No files matched your search

+6
View File
@@ -52035,6 +52035,12 @@ static void showSubtleNotifyLvgl(const char* text, uint32_t duration_ms) {
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
static const char* wifiStaStatusBrief(int s) {
#if defined(HAS_TDISPLAY_P4) && !TDP4_C6_HOSTED
// Legacy ESP-AT C6: the facade's status() only knows connected/disconnected, so say what the
// AT worker is actually doing (#685: a runtime C6 reset, or a join still running).
if (c6at_is_recovering()) return "reconnecting…";
if (s != WL_CONNECTED && c6at_join_busy()) return "joining…";
#endif
switch (s) {
case WL_CONNECTED: return "connected";
case WL_IDLE_STATUS: return "idle";
+270 -50
View File
@@ -16,6 +16,7 @@
#include "driver/sdmmc_default_configs.h"
#include "sdmmc_cmd.h"
#include "esp_heap_caps.h"
#include "c6_link_health.h"
static const char *TAG = "c6_at";
@@ -99,7 +100,7 @@ static volatile uint32_t s_sntp_at_ms = 0; // esp_timer ms when it was sampled
static bool s_sntp_cfged = false;
// ---- worker request queue -----------------------------------------------------------------------
typedef enum { REQ_SCAN = 1, REQ_JOIN, REQ_DISCONNECT,
typedef enum { REQ_SCAN = 1,
REQ_SOCK_CONNECT, REQ_SOCK_SEND, REQ_SOCK_PULL, REQ_SOCK_CLOSE,
REQ_SERVER_START, REQ_SERVER_STOP } req_type_t;
// Blocking bridge: the caller stack-allocates `status` (REQ_PENDING) and polls it; the worker writes
@@ -109,7 +110,6 @@ typedef enum { REQ_SCAN = 1, REQ_JOIN, REQ_DISCONNECT,
#define REQ_FAIL 2
typedef struct {
req_type_t type;
char ssid[33]; char pass[65]; // JOIN
char host[96]; uint16_t port; bool ssl; // SOCK_CONNECT
int id; // SOCK_*
const uint8_t *data; size_t len; // SOCK_SEND (caller's buffer; caller blocks until done)
@@ -118,6 +118,39 @@ typedef struct {
static QueueHandle_t s_reqq = NULL;
static TaskHandle_t s_worker = NULL;
// ---- association slot (latest wins) ---------------------------------------------------------------
// Join/disconnect do NOT go through the request queue: a 6-deep FIFO filled up when main.cpp's 10 s
// retry fired disconnect+join pairs behind a slow CWJAP, the user's own Connect was then dropped on
// a full queue, and a queued CWQAP that ran right after a successful join disconnected again. One
// slot instead: a join overwrites any pending op, a disconnect clears a pending join. The worker
// takes the slot atomically and marks the join running in the same critical section, so
// c6at_join_busy() never sees a gap between "pending" and "running".
typedef enum { ASSOC_NONE = 0, ASSOC_JOIN, ASSOC_LEAVE } assoc_op_t;
static portMUX_TYPE s_assoc_mux = portMUX_INITIALIZER_UNLOCKED;
static volatile assoc_op_t s_assoc_op = ASSOC_NONE;
static char s_assoc_ssid[33], s_assoc_pass[65];
static volatile bool s_join_running = false;
static volatile bool s_want_assoc = false; // last request was a join (not a disconnect)
static char s_last_ssid[33], s_last_pass[65]; // worker-only: last join taken, for rejoin after a reset
// ---- link health (worker-only; see c6_link_health.h) --------------------------------------------
// A C6 whose ESP-AT stack wedges mid-session (#685: scans empty, Connect dead after about an hour,
// only a reboot helped) used to stay dead: s_up was set once and every command just timed out.
// The worker now counts command timeouts, takes the link down on a stall, resets the C6 through
// the same path the boot uses (with backoff), then rejoins and re-arms the listener.
static c6h_t s_health;
static bool s_health_armed = false; // count outcomes only while the worker runs an up link
static c6h_act_t s_health_act = C6H_ACT_NONE;
static volatile bool s_recovering = false; // link down after a runtime stall, reset pending/running
static inline uint32_t now_ms(void) { return (uint32_t)(esp_timer_get_time() / 1000); }
static void health_note(c6h_res_t r, c6h_kind_t k) {
if (!s_health_armed) return;
c6h_act_t a = c6h_note(&s_health, r, k, now_ms());
if (a > s_health_act) s_health_act = a;
}
// ---- transport ---------------------------------------------------------------------------------
#define C6_SDIO_FN_ENABLE 0x002
@@ -355,44 +388,66 @@ static size_t drain_rx(char *out, size_t sz, uint32_t deadline_ms) {
static void urc_scan(const char *buf, size_t n); // spot +IPD / <id>,CLOSED lines in any response
bool c6at_command(const char *cmd, char *resp, size_t resp_sz, uint32_t timeout_ms) {
if (!s_transport_up || !s_tx) return false;
// One AT exchange. Result is tri-state (+busy) so the link-health counter can tell "the C6
// answered ERROR" (alive) from "nothing came back" (possibly wedged).
static c6h_res_t at_exec(const char *cmd, char *resp, size_t resp_sz, uint32_t timeout_ms,
c6h_kind_t kind) {
if (resp && resp_sz) resp[0] = '\0';
if (!s_transport_up || !s_tx) return C6H_RES_TIMEOUT;
// A stall is already decided: fail the rest of the current request at once instead of
// spending another full timeout per command before the worker gets to the reset.
if (s_health_armed && s_health_act == C6H_ACT_STALL) return C6H_RES_TIMEOUT;
int len = snprintf((char *)s_tx, C6_TX_CAP, "%s\r\n", cmd);
if (len <= 0 || len >= C6_TX_CAP) return false;
if (len <= 0 || len >= C6_TX_CAP) return C6H_RES_ERR;
if (!transport_send_packet(s_tx, (size_t)len, 1000)) {
ESP_LOGW(TAG, "send '%s' failed", cmd);
return false;
health_note(C6H_RES_TIMEOUT, kind); // the C6 is not taking data: same as no answer
return C6H_RES_TIMEOUT;
}
// Collect the reply until a terminating status line appears. Static: only the worker calls this.
static char buf[3584]; size_t n = 0; buf[0] = '\0';
uint32_t deadline = (uint32_t)(esp_timer_get_time() / 1000) + timeout_ms;
bool ok = false, done = false, truncated = false;
while (!done && (uint32_t)(esp_timer_get_time() / 1000) < deadline) {
uint32_t deadline = now_ms() + timeout_ms;
c6h_res_t res = C6H_RES_TIMEOUT;
bool done = false, truncated = false;
while (!done && now_ms() < deadline) {
static char chunk[C6_RECV_BUF];
size_t got = drain_rx(chunk, sizeof(chunk), deadline);
bool chunk_ok = false, chunk_err = false;
bool chunk_ok = false, chunk_err = false, chunk_busy = false;
if (got) {
// A dense CWLAP response can fill `buf` before ESP-AT sends its
// terminal status packet. Always inspect the fresh chunk too, or
// the discarded trailing OK turns a successful scan into TIMEOUT.
chunk_ok = strstr(chunk, "\r\nOK\r\n") || (got >= 4 && !strncmp(chunk, "OK\r\n", 4));
chunk_err = strstr(chunk, "\r\nERROR\r\n") || strstr(chunk, "\r\nFAIL\r\n");
// "busy p..." / "busy s...": ESP-AT rejected the line because it is still stuck on
// something else. No OK/ERROR will follow, so stop waiting for one.
chunk_busy = strstr(chunk, "busy p") || strstr(chunk, "busy s");
size_t cp = (n + got < sizeof(buf) - 1) ? got : (sizeof(buf) - 1 - n);
memcpy(buf + n, chunk, cp); n += cp; buf[n] = '\0';
if (cp < got) truncated = true;
} else {
vTaskDelay(pdMS_TO_TICKS(2)); // SDIO read error: don't spin the core until the deadline
}
if (chunk_ok || strstr(buf, "\r\nOK\r\n") || (n >= 4 && !strncmp(buf, "OK\r\n", 4))) {
ok = true; done = true;
res = C6H_RES_OK; done = true;
} else if (chunk_err || strstr(buf, "\r\nERROR\r\n") || strstr(buf, "\r\nFAIL\r\n")) {
ok = false; done = true;
res = C6H_RES_ERR; done = true;
} else if (chunk_busy) {
res = C6H_RES_BUSY; done = true;
}
}
urc_scan(buf, n); // "+IPD"/"<id>,CLOSED" lines interleave with command output — never miss them
if (resp) { strncpy(resp, buf, resp_sz - 1); resp[resp_sz - 1] = '\0'; }
ESP_LOGI(TAG, "AT '%s' -> %s (%u bytes%s)", cmd, ok ? "OK" : (done ? "ERR" : "TIMEOUT"),
(unsigned)n, truncated ? ", truncated" : "");
return ok;
if (resp && resp_sz) { strncpy(resp, buf, resp_sz - 1); resp[resp_sz - 1] = '\0'; }
static const char *const RES_NAME[] = { "OK", "ERR", "TIMEOUT", "BUSY" };
ESP_LOGI(TAG, "AT '%s' -> %s (%u bytes%s)", cmd, RES_NAME[res], (unsigned)n,
truncated ? ", truncated" : "");
health_note(res, kind);
return res;
}
bool c6at_command(const char *cmd, char *resp, size_t resp_sz, uint32_t timeout_ms) {
return at_exec(cmd, resp, resp_sz, timeout_ms, C6H_KIND_CMD) == C6H_RES_OK;
}
// ---- TEMP diagnostic: scan the C6's mfg_nvs partition for the baked-in GATT service table --------
@@ -518,6 +573,30 @@ bool c6at_begin(void) {
c6at_command("AT+CWMODE=1", NULL, 0, 1000); // station mode
c6at_command("AT+CIPMUX=1", NULL, 0, 1000); // multi-connection (link ids 0..4)
c6at_command("AT+CIPRECVMODE=1", NULL, 0, 1000); // passive rx: data buffers on the C6, host pulls
// Modem sleep off (#685). The C6 runs off the P4's supply, so the saving is small, and a
// sleeping station that misses beacons is the likeliest way for an old AT build to drift off
// its AP. Re-sent after every runtime reset, since c6at_begin is the shared bring-up path.
if (!c6at_command("AT+SLEEP=0", NULL, 0, 1000))
printf("[C6AT] AT+SLEEP=0 not accepted by this AT build (ignored)\n");
{ // Let the C6 rejoin a lost AP by itself (every 5 s, unlimited). Query first: the setting is
// stored in the C6's flash, so only write it when it differs. ERROR on the query = an AT
// build without the command; the P4-side retry in main.cpp still covers reconnects.
char r[96];
if (!c6at_command("AT+CWRECONNCFG?", r, sizeof(r), 1000)) {
printf("[C6AT] AT+CWRECONNCFG unsupported on this AT build (ignored)\n");
} else {
const char *q = strstr(r, "+CWRECONNCFG:");
int iv = -1, cnt = -1;
if (q) sscanf(q + 13, "%d,%d", &iv, &cnt);
if (iv == 5 && cnt == 0) {
printf("[C6AT] C6 auto-reconnect already on (5 s, unlimited)\n");
} else {
bool ok = c6at_command("AT+CWRECONNCFG=5,0", NULL, 0, 1000);
printf("[C6AT] C6 auto-reconnect 5 s, unlimited -> %s (was %d,%d)\n",
ok ? "OK" : "ERROR (ignored)", iv, cnt);
}
}
}
{ // Log the slave firmware version once per boot (cheap, invaluable in bug reports).
// BLE survey result (2026-07-15, factory LilyGo ESP-AT v4.1.0.0-dev): BLE commands PRESENT,
// but the GATT table is the BAKED-IN stock demo (16-bit 0xC30x/0xC40x chars) — ESP-AT can't
@@ -613,7 +692,9 @@ static void worker_sock_connect(c6_req_t *rq) {
char cmd[160];
snprintf(cmd, sizeof(cmd), "AT+CIPSTART=%d,\"%s\",\"%s\",%u",
rq->id, rq->ssl ? "SSL" : "TCP", rq->host, (unsigned)rq->port);
bool ok = c6at_command(cmd, NULL, 0, 15000);
// SOFT: a connect can time out because the far host is dead, not the C6. It only asks for a
// liveness ping and never counts toward the stall streak (tile fetches hit dead hosts).
bool ok = at_exec(cmd, NULL, 0, 15000, C6H_KIND_SOFT) == C6H_RES_OK;
s->open = ok;
if (rq->status) *rq->status = ok ? REQ_OK : REQ_FAIL;
}
@@ -630,7 +711,12 @@ static void worker_sock_send(c6_req_t *rq) {
snprintf(cmd, sizeof(cmd), "AT+CIPSEND=%d,%u", rq->id, (unsigned)chunk);
int clen = snprintf((char *)s_tx, C6_TX_CAP, "%s\r\n", cmd);
if (!transport_send_packet(s_tx, (size_t)clen, 1000)) { ok = false; break; }
if (!collect_until(">", acc, sizeof(acc), 3000)) { ok = false; break; }
if (!collect_until(">", acc, sizeof(acc), 3000)) {
// No prompt: either an ERROR (link closed) or a C6 that stopped answering. Ask for a
// ping so a dead parser is caught even while only sends are flowing.
if (!strstr(acc, "ERROR")) health_note(C6H_RES_TIMEOUT, C6H_KIND_SOFT);
ok = false; break;
}
// Raw payload: exactly `chunk` bytes, as promised by CIPSEND.
memcpy(s_tx, p, chunk);
if (!transport_send_packet(s_tx, chunk, 2000)) { ok = false; break; }
@@ -819,48 +905,168 @@ static void worker_do_join(const char *ssid, const char *pass) {
}
}
// Bring the AT link up, rebuilding the SDIO slot + resetting the C6 every third failed attempt.
// Shared by the boot path (10 attempts) and the runtime recovery path (3 attempts).
static bool link_bringup(int attempts) {
for (int i = 0; i < attempts; i++) {
if (c6at_begin()) return true;
// Reuse a successfully initialized slot while the C6 is merely
// booting, but periodically rebuild a poisoned/half-open SDIO link.
if (i % 3 == 2 && i + 1 < attempts) {
ESP_LOGW(TAG, "worker: rebuilding C6 SDIO link after failed AT probes");
sdio_bring_down();
if (!tdisplay_p4_reset_c6()) ESP_LOGE(TAG, "worker: C6 reset hook failed");
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
return false;
}
// Fail one request without touching the bus (link down).
static void req_fail_fast(const c6_req_t *req) {
if (req->type == REQ_SCAN) { s_scan_n = 0; s_scan_state = 0; }
if (req->type == REQ_SOCK_PULL && req->id >= 0 && req->id < SOCK_MAX)
s_sock[req->id].pull_inflight = false; // else the link could never be kicked again
if (req->status) *req->status = REQ_FAIL;
}
// A stall was decided: take the link down NOW (every caller fails fast from here on), forget all
// state that lived on the C6, and leave the reset to worker_link_recover() once the backoff allows.
static void worker_link_stalled(void) {
const uint32_t now = now_ms();
s_health_armed = false;
s_health_act = C6H_ACT_NONE;
s_up = false;
s_recovering = true;
if (c6h_reset_due(&s_health, now))
printf("[C6AT] link stalled: resetting the C6\n");
else
printf("[C6AT] link stalled: resetting the C6 in %lu s (backoff)\n",
(unsigned long)((s_health.next_reset_ms - now) / 1000));
// Association, IP, listener and SNTP config all lived on the C6 and die with the reset. The
// listener WANT flag (s_srv_want/s_srv_port) is kept: the idle loop re-arms CIPSERVER once the
// link is back, and the router's C6Server stays begun, so nothing app-side has to restart it.
s_connected = false; s_ip4 = 0; s_cur_rssi = 0;
s_srv_up = false;
s_sntp_cfged = false;
if (s_scan_state == -1) { s_scan_n = 0; s_scan_state = 0; } // a scan in flight ends empty
// Links: mark closed so every C6Client reads connected()==false (once its ring drains) and the
// TCP/WS companion servers prune it; the owner's stop() frees the slot (`used` stays theirs, so
// a recycled id can never be closed by a stale holder). Rings are left to their consumers.
for (int id = 0; id < SOCK_MAX; id++) {
s_sock[id].open = false;
s_sock[id].rx_notified = false;
s_sock[id].pull_inflight = false;
}
// Requests already queued were aimed at the dead link: fail them so their callers return.
c6_req_t req;
while (s_reqq && xQueueReceive(s_reqq, &req, 0) == pdTRUE) req_fail_fast(&req);
}
// Reset the C6 through the boot path and, on success, rejoin the last network. Worker-only: the
// worker owns all SDIO access, so nothing else can touch slot 1 while it is torn down.
static void worker_link_recover(void) {
const uint32_t now = now_ms();
const unsigned attempt = (unsigned)s_health.resets + 1;
c6h_on_reset(&s_health, now); // arms the backoff for the NEXT attempt, success or not
printf("[C6AT] reset attempt %u: SDIO down, C6 reset, AT bring-up\n", attempt);
sdio_bring_down();
if (!tdisplay_p4_reset_c6()) ESP_LOGE(TAG, "recover: C6 reset hook failed");
if (!link_bringup(3)) {
sdio_bring_down(); // leave the slot clean for the next attempt
printf("[C6AT] reset attempt %u failed: C6 still silent, next try in %lu s\n", attempt,
(unsigned long)((s_health.next_reset_ms - now_ms()) / 1000));
return; // stay in recovery; callers keep failing fast
}
s_recovering = false;
s_health_armed = true;
s_health_act = C6H_ACT_NONE;
printf("[C6AT] link back up after reset attempt %u\n", attempt);
// Rejoin: only if the last request was a join, the station is still wanted, and nothing
// newer is already waiting in the slot (a Connect the user tapped meanwhile wins).
taskENTER_CRITICAL(&s_assoc_mux);
if (s_want_assoc && s_sta_en && s_last_ssid[0] && s_assoc_op == ASSOC_NONE) {
memcpy(s_assoc_ssid, s_last_ssid, sizeof(s_assoc_ssid));
memcpy(s_assoc_pass, s_last_pass, sizeof(s_assoc_pass));
s_assoc_op = ASSOC_JOIN;
}
taskEXIT_CRITICAL(&s_assoc_mux);
}
// After a timeout the health counter asks for a plain "AT" ping; a stall takes the link down.
static void worker_health_service(void) {
if (!s_health_armed) return;
if (s_health_act == C6H_ACT_PING) {
s_health_act = C6H_ACT_NONE;
(void)at_exec("AT", NULL, 0, 1000, C6H_KIND_PING); // may escalate to STALL
}
if (s_health_act == C6H_ACT_STALL) worker_link_stalled();
}
// Execute the latest join/disconnect from the association slot.
static void worker_assoc_service(void) {
if (s_assoc_op == ASSOC_NONE) return; // racy peek; re-checked under the lock
char ssid[33], pass[65];
assoc_op_t op;
taskENTER_CRITICAL(&s_assoc_mux);
op = s_assoc_op;
if (op == ASSOC_LEAVE && !s_up) {
s_assoc_op = ASSOC_NONE; // link down: the association is gone anyway
op = ASSOC_NONE;
} else if (op == ASSOC_JOIN && !s_up) {
op = ASSOC_NONE; // keep it pending until the link is back
} else {
s_assoc_op = ASSOC_NONE;
if (op == ASSOC_JOIN) {
memcpy(ssid, s_assoc_ssid, sizeof(ssid));
memcpy(pass, s_assoc_pass, sizeof(pass));
s_join_running = true; // same critical section: no busy-gap for main.cpp
}
}
taskEXIT_CRITICAL(&s_assoc_mux);
if (op == ASSOC_JOIN) {
memcpy(s_last_ssid, ssid, sizeof(s_last_ssid));
memcpy(s_last_pass, pass, sizeof(s_last_pass));
worker_do_join(ssid, pass);
if (s_connected) worker_sntp_service(true);
// A (re)association drops any running CIPSERVER on the C6:
// re-arm the want-flag so the idle loop restarts it.
if (s_srv_want) s_srv_up = false;
s_join_running = false;
} else if (op == ASSOC_LEAVE) {
c6at_command("AT+CWQAP", NULL, 0, 3000);
s_connected = false; s_ip4 = 0;
}
}
static void c6WorkerTask(void *arg) {
(void)arg;
c6h_init(&s_health);
// Match LilyGo's factory transport lifecycle: reset immediately before
// configuring SDIO, then let ESP-AT reach its ready state.
if (!tdisplay_p4_reset_c6()) ESP_LOGE(TAG, "worker: C6 reset hook failed");
bool up = false;
for (int i = 0; i < 10 && !up; i++) {
up = c6at_begin();
if (!up) {
// Reuse a successfully initialized slot while the C6 is merely
// booting, but periodically rebuild a poisoned/half-open SDIO link.
if (i == 2 || i == 5 || i == 8) {
ESP_LOGW(TAG, "worker: rebuilding C6 SDIO link after failed AT probes");
sdio_bring_down();
if (!tdisplay_p4_reset_c6()) ESP_LOGE(TAG, "worker: C6 reset hook failed");
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
bool up = link_bringup(10);
if (!up) {
s_dead = true;
ESP_LOGE(TAG, "worker: C6 AT link never came up — Wi-Fi unavailable");
// Keep servicing the queue as no-ops so requesters never wedge.
} else {
s_health_armed = true;
}
uint32_t last_refresh = 0;
for (;;) {
// Runtime recovery: the link stalled earlier; reset once the backoff window allows.
if (s_recovering && c6h_reset_due(&s_health, now_ms())) worker_link_recover();
worker_assoc_service();
worker_health_service();
c6_req_t req;
if (xQueueReceive(s_reqq, &req, pdMS_TO_TICKS(150)) == pdTRUE) {
if (!s_up) { // link dead: fail requests fast
if (req.type == REQ_SCAN) { s_scan_n = 0; s_scan_state = 0; }
if (req.status) *req.status = REQ_FAIL;
req_fail_fast(&req);
continue;
}
switch (req.type) {
case REQ_SCAN: worker_do_scan(); break;
case REQ_JOIN: worker_do_join(req.ssid, req.pass);
if (s_connected) worker_sntp_service(true);
// A (re)association drops any running CIPSERVER on the
// C6 — re-arm the want-flag so the idle loop restarts it.
if (s_srv_want) s_srv_up = false;
break;
case REQ_DISCONNECT: c6at_command("AT+CWQAP", NULL, 0, 3000); s_connected = false; s_ip4 = 0; break;
case REQ_SOCK_CONNECT: worker_sock_connect(&req); break;
case REQ_SOCK_SEND: worker_sock_send(&req); break;
case REQ_SOCK_PULL: worker_sock_pull(req.id); break;
@@ -883,7 +1089,7 @@ static void c6WorkerTask(void *arg) {
if (req.status) *req.status = REQ_OK;
break;
}
last_refresh = (uint32_t)(esp_timer_get_time() / 1000);
last_refresh = now_ms();
} else if (s_up) {
// Idle housekeeping:
// 1. service links with data waiting on the C6 (+IPD seen) — pull into their rings.
@@ -951,23 +1157,35 @@ void c6at_req_scan(void) {
if (s_dead) { s_scan_n = 0; s_scan_state = 0; return; }
s_scan_state = -1; // running (set now so a poll right after sees RUNNING)
c6_req_t r = { .type = REQ_SCAN };
if (s_reqq) xQueueSend(s_reqq, &r, 0);
// A dropped request must not leave the UI on "scanning" forever: report an empty result.
if (!s_reqq || xQueueSend(s_reqq, &r, 0) != pdTRUE) { s_scan_n = 0; s_scan_state = 0; }
}
void c6at_req_join(const char *ssid, const char *pass) {
if (s_dead || !ssid) return;
c6_req_t r = { .type = REQ_JOIN };
strncpy(r.ssid, ssid, sizeof(r.ssid) - 1);
if (pass) strncpy(r.pass, pass, sizeof(r.pass) - 1);
if (s_reqq) xQueueSend(s_reqq, &r, 0);
taskENTER_CRITICAL(&s_assoc_mux);
memset(s_assoc_ssid, 0, sizeof(s_assoc_ssid));
memset(s_assoc_pass, 0, sizeof(s_assoc_pass));
strncpy(s_assoc_ssid, ssid, sizeof(s_assoc_ssid) - 1);
if (pass) strncpy(s_assoc_pass, pass, sizeof(s_assoc_pass) - 1);
s_assoc_op = ASSOC_JOIN; // latest wins: replaces any pending join or disconnect
s_want_assoc = true;
taskEXIT_CRITICAL(&s_assoc_mux);
}
void c6at_req_disconnect(void) {
if (s_dead) return;
c6_req_t r = { .type = REQ_DISCONNECT };
if (s_reqq) xQueueSend(s_reqq, &r, 0);
taskENTER_CRITICAL(&s_assoc_mux);
s_assoc_op = ASSOC_LEAVE; // also cancels a join that has not started yet
s_want_assoc = false;
memset(s_assoc_ssid, 0, sizeof(s_assoc_ssid));
memset(s_assoc_pass, 0, sizeof(s_assoc_pass));
taskEXIT_CRITICAL(&s_assoc_mux);
}
bool c6at_join_busy(void) { return s_join_running || s_assoc_op == ASSOC_JOIN; }
bool c6at_is_recovering(void) { return s_recovering; }
bool c6at_connected(void) { return s_connected; }
uint32_t c6at_ip4(void) { return s_ip4; }
int c6at_current_rssi(void) { return s_cur_rssi; }
@@ -1077,7 +1295,9 @@ bool c6at_sock_connected(int id) {
void c6at_sock_close(int id) {
if (id < 0 || id >= SOCK_MAX || !s_sock[id].used) return;
if (s_reqq && !s_dead) {
// Link down (recovering): the C6 was or will be reset, so there is nothing to close on it, and
// waiting for the worker would block the caller (often the main loop) through the reset.
if (s_reqq && !s_dead && s_up) {
c6_req_t r = { .type = REQ_SOCK_CLOSE, .id = id };
volatile int st = REQ_PENDING; r.status = &st;
if (xQueueSend(s_reqq, &r, pdMS_TO_TICKS(250)) == pdTRUE)
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// c6_link_health: pure state machine that decides when the AT-over-SDIO link to the C6 has
// stalled and when the worker may reset it. No ESP-IDF dependencies, so it compiles on the host
// (test/test_c6_link_health.cpp) exactly as it does in c6_at.c.
//
// Inputs are the outcomes of AT commands the worker runs while the link is up:
// OK / ERR the C6 answered with a terminal status line (ERROR is still an answer)
// TIMEOUT no terminal status before the deadline, or the SDIO send itself failed
// BUSY ESP-AT replied "busy p..." (parser alive, but it is stuck on something)
// and the command's kind:
// CMD a normal command: TIMEOUT/BUSY count toward the stall streak
// SOFT a command whose timeout can be the network's fault (AT+CIPSTART to a dead host):
// it never counts toward the streak, it only asks for a liveness ping
// PING the plain "AT" liveness probe the worker sends after a timeout
//
// Decisions:
// - a CMD/SOFT timeout asks for a PING; a PING that times out is an immediate STALL (the AT
// parser itself is gone). A PING that answers changes nothing: the parser can be alive while
// the Wi-Fi stack under it is wedged, so it must not clear the streak.
// - C6H_STALL_STREAK CMD timeouts in a row with no CMD answer in between is a STALL.
// - a STALL takes the link down at once; the reset itself waits for the backoff window
// (30 s, 60 s, 120 s, 240 s, then 5 min) so a dead C6 is not reset in a tight loop.
// - the backoff ladder drops back to its first step once a recovered link has answered
// commands for C6H_STABLE_MS.
#pragma once
#include <stdbool.h>
#include <stdint.h>
#define C6H_STALL_STREAK 3
#define C6H_BACKOFF_MIN_MS 30000u
#define C6H_BACKOFF_MAX_MS 300000u
#define C6H_STABLE_MS 600000u // 10 min of a healthy link resets the backoff ladder
typedef enum { C6H_RES_OK = 0, C6H_RES_ERR, C6H_RES_TIMEOUT, C6H_RES_BUSY } c6h_res_t;
typedef enum { C6H_KIND_CMD = 0, C6H_KIND_SOFT, C6H_KIND_PING } c6h_kind_t;
typedef enum { C6H_ACT_NONE = 0, C6H_ACT_PING, C6H_ACT_STALL } c6h_act_t;
typedef struct {
uint8_t streak; // consecutive CMD timeouts/busy replies
uint8_t resets; // resets since the link was last stable (drives the backoff)
bool had_reset; // at least one runtime reset happened (stability clock is live)
uint32_t last_reset_ms; // when the last reset attempt ran
uint32_t next_reset_ms; // earliest time the next reset may run
} c6h_t;
static inline void c6h_init(c6h_t *h) {
h->streak = 0; h->resets = 0; h->had_reset = false;
h->last_reset_ms = 0; h->next_reset_ms = 0;
}
// Backoff before reset number `n` (0-based count of earlier resets in this unstable run).
static inline uint32_t c6h_backoff_ms(uint8_t n) {
uint32_t ms = C6H_BACKOFF_MIN_MS;
for (uint8_t i = 0; i < n && ms < C6H_BACKOFF_MAX_MS; i++) ms *= 2;
return ms > C6H_BACKOFF_MAX_MS ? C6H_BACKOFF_MAX_MS : ms;
}
// Wrap-safe "now has reached t".
static inline bool c6h_reached(uint32_t now_ms, uint32_t t_ms) {
return (int32_t)(now_ms - t_ms) >= 0;
}
static inline c6h_act_t c6h_note(c6h_t *h, c6h_res_t r, c6h_kind_t k, uint32_t now_ms) {
const bool answered = (r == C6H_RES_OK || r == C6H_RES_ERR);
if (k == C6H_KIND_PING) {
return (r == C6H_RES_TIMEOUT) ? C6H_ACT_STALL : C6H_ACT_NONE;
}
if (answered) {
if (k == C6H_KIND_CMD) h->streak = 0;
if (h->had_reset && h->resets && c6h_reached(now_ms, h->last_reset_ms + C6H_STABLE_MS))
h->resets = 0;
return C6H_ACT_NONE;
}
if (k == C6H_KIND_SOFT) return C6H_ACT_PING;
if (h->streak < 255) h->streak++;
return (h->streak >= C6H_STALL_STREAK) ? C6H_ACT_STALL : C6H_ACT_PING;
}
// May the worker run a reset now? The first reset of an unstable run is immediate.
static inline bool c6h_reset_due(const c6h_t *h, uint32_t now_ms) {
return h->resets == 0 || c6h_reached(now_ms, h->next_reset_ms);
}
// Record a reset attempt (successful or not) and arm the backoff for the next one.
static inline void c6h_on_reset(c6h_t *h, uint32_t now_ms) {
h->next_reset_ms = now_ms + c6h_backoff_ms(h->resets);
if (h->resets < 255) h->resets++;
h->had_reset = true;
h->last_reset_ms = now_ms;
h->streak = 0;
}
@@ -71,6 +71,8 @@ public:
int32_t channel(uint8_t i) { return (int32_t)c6at_scan_channel(i); } // scan-result channel
// ---- accepted no-ops (ESP-AT handles reconnect + power policy on the C6 itself) ----
// c6at_begin() sends AT+SLEEP=0 and AT+CWRECONNCFG after every link bring-up (boot and each
// runtime reset), so main.cpp's setSleep(true) must stay a no-op here or it would undo that.
bool setAutoReconnect(bool en) { (void)en; return true; }
bool setSleep(bool en) { (void)en; return true; }
};
@@ -28,11 +28,19 @@ extern "C" {
void c6at_worker_start(void);
bool c6at_is_up(void); // transport up + ESP-AT answered (set by the worker)
bool c6at_is_dead(void); // worker gave up bringing the link up (AT probe exhausted)
// Runtime recovery (#685): the worker saw the link stall (3 command timeouts in a row, or a dead
// "AT" ping), took it down and is resetting the C6 (with 30 s .. 5 min backoff between resets).
// c6at_is_up() is false meanwhile; the UI shows "reconnecting" and the worker rejoins by itself.
bool c6at_is_recovering(void);
// ---- async requests (enqueue only; the worker executes) -----------------------------------------
void c6at_req_scan(void);
// Join/disconnect share ONE latest-wins slot (not the request queue): a join replaces whatever is
// pending, a disconnect cancels a pending join. A join posted while the link is recovering waits
// for the link to come back.
void c6at_req_join(const char *ssid, const char *pass); // copies the strings
void c6at_req_disconnect(void);
bool c6at_join_busy(void); // a join is pending in the slot or running on the C6
// ---- lock-free status cache (safe from any task) -------------------------------------------------
bool c6at_connected(void);
+10 -1
View File
@@ -517,7 +517,10 @@ extern "C" void app_main(void) {
#if TDP4_C6_HOSTED
const bool c6_up = s_hosted_c6_up;
#else
const bool c6_up = c6at_is_up();
// A runtime C6 reset (#685) still counts as "up" here: the worker is bringing the link
// back and rejoins by itself. Treating it as down would run the radio-off branch below
// (disconnect + WIFI_OFF), which cancels that rejoin and forgets the station was wanted.
const bool c6_up = c6at_is_up() || c6at_is_recovering();
#endif
bool wifi_radio_en = c6_up && wifiConfigWantsWifi();
// The C6 runs its own AT firmware with auto-connect enabled, so after a power
@@ -558,6 +561,12 @@ extern "C" void app_main(void) {
}
if (wifiConfigHasRuntime() && WiFi.status() != WL_CONNECTED) {
uint32_t now = millis();
#if !TDP4_C6_HOSTED
// A join already pending or running on the C6 (or a C6 reset in progress, which rejoins
// on its own) owns the association. Retrying now would only stack a second CWJAP behind
// it, so restart the 10 s window instead.
if (c6at_join_busy() || c6at_is_recovering()) last_wifi_retry_ms = now;
#endif
if ((uint32_t)(now - last_wifi_retry_ms) >= WIFI_RETRY_INTERVAL_MS) {
last_wifi_retry_ms = now;
char ssid[WIFI_CONFIG_SSID_MAX], pwd[WIFI_CONFIG_PWD_MAX];
+108
View File
@@ -0,0 +1,108 @@
// Host test for the T-Display P4 legacy ESP-AT link-health state machine (#685).
// Build + run from the repo root:
// c++ -std=c++17 -Wall -Wextra -I tdisplay_p4/components/c6_at test/test_c6_link_health.cpp \
// -o /tmp/test_c6_link_health && /tmp/test_c6_link_health
#include <assert.h>
#include <stdio.h>
#include "c6_link_health.h"
static const uint32_t S = 1000; // ms per second
int main() {
// ---- backoff ladder: 30 s, 60 s, 120 s, 240 s, then capped at 5 min ----
assert(c6h_backoff_ms(0) == 30 * S);
assert(c6h_backoff_ms(1) == 60 * S);
assert(c6h_backoff_ms(2) == 120 * S);
assert(c6h_backoff_ms(3) == 240 * S);
assert(c6h_backoff_ms(4) == 300 * S);
assert(c6h_backoff_ms(200) == 300 * S);
c6h_t h;
c6h_init(&h);
// ---- ERROR is an answer: never counts toward a stall ----
for (int i = 0; i < 10; i++) assert(c6h_note(&h, C6H_RES_ERR, C6H_KIND_CMD, 0) == C6H_ACT_NONE);
assert(h.streak == 0);
// ---- timeouts: ping after each, stall on the third in a row ----
assert(c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_CMD, 0) == C6H_ACT_PING);
assert(c6h_note(&h, C6H_RES_OK, C6H_KIND_PING, 0) == C6H_ACT_NONE); // parser alive...
assert(h.streak == 1); // ...streak NOT cleared
assert(c6h_note(&h, C6H_RES_BUSY, C6H_KIND_CMD, 0) == C6H_ACT_PING); // busy counts too
assert(c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_CMD, 0) == C6H_ACT_STALL);
// ---- an answer in between resets the streak ----
c6h_init(&h);
c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_CMD, 0);
c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_CMD, 0);
assert(c6h_note(&h, C6H_RES_ERR, C6H_KIND_CMD, 0) == C6H_ACT_NONE);
assert(h.streak == 0);
assert(c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_CMD, 0) == C6H_ACT_PING);
// ---- a ping that times out is an immediate stall; busy/ERR pings are alive ----
c6h_init(&h);
assert(c6h_note(&h, C6H_RES_BUSY, C6H_KIND_PING, 0) == C6H_ACT_NONE);
assert(c6h_note(&h, C6H_RES_ERR, C6H_KIND_PING, 0) == C6H_ACT_NONE);
assert(c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_PING, 0) == C6H_ACT_STALL);
// ---- SOFT timeouts (connect to a dead host) only ask for a ping, never stall ----
c6h_init(&h);
for (int i = 0; i < 10; i++)
assert(c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_SOFT, 0) == C6H_ACT_PING);
assert(h.streak == 0);
// ...and a SOFT answer does not clear a CMD streak (it says nothing about the AT stack's state)
c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_CMD, 0);
c6h_note(&h, C6H_RES_OK, C6H_KIND_SOFT, 0);
assert(h.streak == 1);
// ---- reset scheduling: first reset immediate, then 30/60/120/240/300/300 s apart ----
c6h_init(&h);
uint32_t t = 5000 * S;
assert(c6h_reset_due(&h, t));
c6h_on_reset(&h, t); // reset #1 at t
assert(h.streak == 0);
assert(!c6h_reset_due(&h, t + 29 * S));
assert(c6h_reset_due(&h, t + 30 * S));
const uint32_t gaps[] = {30, 60, 120, 240, 300, 300, 300};
for (unsigned i = 0; i < sizeof(gaps) / sizeof(gaps[0]); i++) {
assert(!c6h_reset_due(&h, t + gaps[i] * S - 1));
t += gaps[i] * S;
assert(c6h_reset_due(&h, t));
c6h_on_reset(&h, t);
}
// ---- stability: 10 min of answers after a reset drops the ladder back to "immediate" ----
c6h_init(&h);
t = 100 * S;
c6h_on_reset(&h, t);
c6h_on_reset(&h, t + 30 * S); // two resets: next gap would be 60 s
t += 30 * S;
c6h_note(&h, C6H_RES_OK, C6H_KIND_CMD, t + 9 * 60 * S);
assert(h.resets == 2); // 9 min: not stable yet
assert(!c6h_reset_due(&h, t + 59 * S));
c6h_note(&h, C6H_RES_OK, C6H_KIND_CMD, t + 10 * 60 * S);
assert(h.resets == 0); // 10 min: ladder reset
assert(c6h_reset_due(&h, t + 10 * 60 * S + 1));
// ---- a re-stall soon after a successful reset still honours the backoff ----
c6h_init(&h);
t = 1000 * S;
c6h_on_reset(&h, t);
c6h_note(&h, C6H_RES_OK, C6H_KIND_CMD, t + 5 * S); // link came back
c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_CMD, t + 6 * S);
c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_CMD, t + 7 * S);
assert(c6h_note(&h, C6H_RES_TIMEOUT, C6H_KIND_CMD, t + 8 * S) == C6H_ACT_STALL);
assert(!c6h_reset_due(&h, t + 8 * S)); // wait for t + 30 s
assert(c6h_reset_due(&h, t + 30 * S));
// ---- millisecond clock wrap (esp_timer ms in a uint32_t wraps after ~49.7 days) ----
c6h_init(&h);
t = 0xFFFFFFFFu - 10 * S;
c6h_on_reset(&h, t);
assert(!c6h_reset_due(&h, t + 29 * S)); // wrapped past zero, still inside the window
assert(c6h_reset_due(&h, t + 30 * S));
printf("c6_link_health: all tests passed\n");
return 0;
}