/* WiFi / MQTT 链路的 ESP 胶水(A14 + C1)。**只进 IDF 目标**;topic 与规矩在 mlink.h, 配置与「密码永不外泄」在 netcfg.h。 ---- 谁在哪个任务里跑 ------------------------------------------------------ | 事 | 任务 | |---|---| | WiFi / IP 事件(掉线、拿到地址) | 系统事件循环 | | MQTT 事件(连上、收到命令) | esp-mqtt 自己的任务,优先级压到与 `t_link` 相同(§10.3:链路最低) | | **MQTT 客户端的创建 / 销毁、重配置、遥测节拍** | `t_link`(`mlink_esp_poll()`)—— 生命周期只在一个任务里管 | | 回应 / 遥测的发布 | 执行器任务只把一份拷贝塞进 `s_outbox`(不等);`t_link` 取出来 `esp_mqtt_client_enqueue()` | ⚠️ **执行器任务一律不碰 esp-mqtt 的任何 API**,连 `esp_mqtt_client_enqueue()` 也不行: 它要拿客户端的 `api_lock`,而 esp-mqtt 任务在连接 / 写超时 / 分发事件时一直持着这把锁。 0.1.1-c3 之前执行器是「持 `s_mtx` → 等 `api_lock`」,esp-mqtt 任务分发 DISCONNECTED 时是 「持 `api_lock` → `on_mqtt` 里 `ST_EDIT` 等 `s_mtx`」—— 2026-09-28 热点一断就死锁: USB 命令没人执行、WiFi 不重连、**按到一半的键松不开**,只有 t_safety / t_thermal 还活着。 现在的规矩:**持 `s_mtx` 时不调 esp-mqtt;执行器根本不调 esp-mqtt**。 ---- 失联不动作(§10.2 / §10.8) ----------------------------------------- WiFi 断、broker 断,本文件只做一件事:**重连**(WiFi 指数退避,MQTT 由 esp-mqtt 每 10 s 重试)。 没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。 */ #include #include "cmd_queue.h" #include "esp_event.h" #include "esp_log.h" #include "esp_mac.h" #include "esp_netif.h" #include "esp_timer.h" #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/semphr.h" #include "hal_time.h" #include "mlink.h" #include "mlink_esp.h" #include "mqtt_client.h" #include "netcfg.h" #include "nvs.h" #include "nvs_flash.h" #include "phonerail.h" #include "tasks.h" #include "ulink.h" static const char *TAG = "mlink"; #define NVS_NS "phonerail" #define NVS_KEY "net2" #define NVS_VERSION 2 /* netcfg_t 布局一变就加一:旧版本的 blob 直接当没配置 */ /* ≤0.1.8 的配置(只有一组 WiFi)存在 "net" 键里。0.1.9 起: · 读:没有 "net2" 就读 "net" 迁过来(上架的板子升级后不用重新配网); · 写:两个键**都写**,"net" 里放表里第一组 —— OTA 回滚到老固件照样连得上网。 */ #define NVS_KEY_V1 "net" #define NVS_VERSION_V1 1 typedef struct { /* ≤0.1.8 的 netcfg_t,一个字节都不能动 */ char ssid[NETCFG_SSID_MAX + 1]; char pass[NETCFG_PASS_MAX + 1]; char uri[NETCFG_URI_MAX + 1]; char user[NETCFG_USER_MAX + 1]; char mpass[NETCFG_MPASS_MAX + 1]; uint32_t tele_ms; } netcfg_v1_t; typedef struct { uint8_t ver; netcfg_v1_t cfg; } nvs_blob_v1_t; /* evse-ca(home 服务器上那个 mosquitto 的自签 CA,公钥证书,不是机密)。 `main/CMakeLists.txt` 的 EMBED_TXTFILES 编进来,结尾带 0。只在 mqtts:// 时用。 */ extern const char evse_ca_pem_start[] asm("_binary_evse_ca_pem_start"); #define MQTT_KEEPALIVE_S 15 /* 掉线后 ≈1.5 × 15 s broker 发遗嘱;默认 120 s 要等 3 分钟 */ typedef struct { uint8_t ver; netcfg_t cfg; } nvs_blob_t; static SemaphoreHandle_t s_mtx; /* 护 s_st 与 s_mqtt 句柄 */ static net_status_t s_st; static esp_mqtt_client_handle_t s_mqtt; static netcfg_t s_cur; /* 当前生效的配置(只在 t_link 里读写) */ static volatile uint8_t s_idx; /* 正在连 / 已连上的是 s_cur.wifi 第几组 */ static volatile bool s_leaving; /* wifi_apply 主动断开了一条好好的连接:那次断开事件不算失败 */ static volatile bool s_dirty; /* 配置变了,等 t_link 来重配 */ static esp_timer_handle_t s_retry; static uint32_t s_attempt; static mlink_tele_t s_tele; static volatile bool s_tele_inflight; static bool s_up; /* 发件队列(见文件头):执行器 / esp-mqtt 任务往里拷,t_link 取出来交给 esp-mqtt。 深度 4 × 1.5 KB:遥测 1 s 一条、回应偶尔一条,t_link 至少每 100 ms 来取一次; 满了说明 t_link 被 esp-mqtt 堵住了 —— 丢掉这一条,遥测记 skipped,回应靠上位机重传。 */ typedef struct { uint8_t topic; /* mlink_topic_t */ uint8_t qos; uint16_t len; char data[CMD_OUT_MAX]; } out_msg_t; #define OUTBOX_DEPTH 4 static QueueHandle_t s_outbox; static SemaphoreHandle_t s_out_mtx; /* 只护 enqueue() 的暂存缓冲 */ /* ---- 状态:改完立刻推给 netcfg,命令处理器随时读得到一份完整的 ------------ */ /* 可变参数:语句里带逗号(snprintf 的实参)也能整个传进来。 */ #define ST_EDIT(...) \ do { \ xSemaphoreTake(s_mtx, portMAX_DELAY); \ __VA_ARGS__; \ net_status_set(&s_st); \ xSemaphoreGive(s_mtx); \ } while (0) /* ---- NVS ----------------------------------------------------------------- */ static hal_err_t nvs_save(void *ctx, const netcfg_t *cfg) { (void)ctx; /* ⚠️ 在执行器任务里被调(配置命令),flash 写可能要几十毫秒。配置是上架时一次性的事, 这点停顿可以接受;换成异步写就得处理「回了 ok 但没存下」。 */ nvs_handle_t h; if (nvs_open(NVS_NS, NVS_READWRITE, &h) != ESP_OK) { return HAL_ERR_BUS; } static nvs_blob_t b; /* ≈560 B,不放栈上(只有执行器任务进来) */ static nvs_blob_v1_t v1; b.ver = NVS_VERSION; b.cfg = *cfg; memset(&v1, 0, sizeof(v1)); v1.ver = NVS_VERSION_V1; memcpy(v1.cfg.ssid, cfg->wifi[0].ssid, sizeof(v1.cfg.ssid)); memcpy(v1.cfg.pass, cfg->wifi[0].pass, sizeof(v1.cfg.pass)); memcpy(v1.cfg.uri, cfg->uri, sizeof(v1.cfg.uri)); memcpy(v1.cfg.user, cfg->user, sizeof(v1.cfg.user)); memcpy(v1.cfg.mpass, cfg->mpass, sizeof(v1.cfg.mpass)); v1.cfg.tele_ms = cfg->tele_ms; esp_err_t e = nvs_set_blob(h, NVS_KEY, &b, sizeof(b)); if (e == ESP_OK) { e = nvs_set_blob(h, NVS_KEY_V1, &v1, sizeof(v1)); } if (e == ESP_OK) { e = nvs_commit(h); } nvs_close(h); return e == ESP_OK ? HAL_OK : HAL_ERR_BUS; } static bool nvs_load(netcfg_t *out) { nvs_handle_t h; if (nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) { return false; } static nvs_blob_t b; /* ≈560 B,不放栈上(只在启动时进来一次) */ size_t n = sizeof(b); bool ok = nvs_get_blob(h, NVS_KEY, &b, &n) == ESP_OK && n == sizeof(b) && b.ver == NVS_VERSION; if (ok) { *out = b.cfg; } else { static nvs_blob_v1_t v1; n = sizeof(v1); ok = nvs_get_blob(h, NVS_KEY_V1, &v1, &n) == ESP_OK && n == sizeof(v1) && v1.ver == NVS_VERSION_V1; if (ok) { memset(out, 0, sizeof(*out)); memcpy(out->wifi[0].ssid, v1.cfg.ssid, sizeof(v1.cfg.ssid)); memcpy(out->wifi[0].pass, v1.cfg.pass, sizeof(v1.cfg.pass)); memcpy(out->uri, v1.cfg.uri, sizeof(v1.cfg.uri)); memcpy(out->user, v1.cfg.user, sizeof(v1.cfg.user)); memcpy(out->mpass, v1.cfg.mpass, sizeof(v1.cfg.mpass)); out->tele_ms = v1.cfg.tele_ms; ESP_LOGI(TAG, "旧版网络配置(一组 WiFi)已迁移;下次改配置时写成新版"); } } nvs_close(h); return ok; } static void on_cfg_changed(void *ctx) { (void)ctx; s_dirty = true; /* 执行器任务里只置标志;断开重连在 t_link 里做(esp_mqtt_client_stop 会阻塞) */ } /* ---- 发布:任何任务都只进 s_outbox,不等、不碰 esp-mqtt ------------------- */ static bool enqueue(mlink_topic_t t, const char *data, size_t len, int qos) { /* 1.5 KB 不放栈上。执行器和 esp-mqtt 任务(link_error)都会进来 ⇒ s_out_mtx 护着。 这把锁持有期间只有 memcpy 和一次**不等**的入队,不会再去等任何别的东西。 */ static out_msg_t m; if (s_outbox == NULL || len > sizeof(m.data)) { return false; } /* 没连上就不攒(上线后 on_mqtt 会先补一条遥测)。读一个枚举,不拿 s_mtx。 */ if (s_st.mqtt != NET_UP) { return false; } if (xSemaphoreTake(s_out_mtx, pdMS_TO_TICKS(10)) != pdTRUE) { return false; } m.topic = (uint8_t)t; m.qos = (uint8_t)qos; m.len = (uint16_t)len; memcpy(m.data, data, len); const bool ok = xQueueSend(s_outbox, &m, 0) == pdTRUE; xSemaphoreGive(s_out_mtx); return ok; } static void reply_mqtt(void *ctx, const char *line, size_t len) { (void)ctx; (void)enqueue(MLINK_T_RSP, line, len, 1); } static void reply_tele(void *ctx, const char *line, size_t len) { (void)ctx; const bool ok = enqueue(MLINK_T_TELE, line, len, 0); ST_EDIT(if (ok) { s_st.tele_sent++; } else { s_st.tele_skipped++; }); s_tele_inflight = false; } /* t_link 里调:把发件队列交给 esp-mqtt。s_mqtt 只在 t_link 里改,这里读它不用锁; esp_mqtt_client_enqueue 可能等 api_lock 等到 esp-mqtt 的网络超时 —— 堵的是 t_link, 而 t_link 此刻不持任何我们的锁,esp-mqtt 的事件回调照样拿得到 s_mtx。 */ static void outbox_drain(void) { static out_msg_t m; while (s_outbox != NULL && xQueueReceive(s_outbox, &m, 0) == pdTRUE) { if (s_mqtt != NULL) { (void)esp_mqtt_client_enqueue(s_mqtt, mlink_topic((mlink_topic_t)m.topic), m.data, (int)m.len, m.qos, 0, true); } } } static void link_error(const char *payload, const char *err) { char out[96]; const size_t n = ulink_link_error(payload, err, out, sizeof(out)); if (n > 0) { (void)enqueue(MLINK_T_RSP, out, n, 1); } } /* ---- MQTT 事件(esp-mqtt 任务) ------------------------------------------- */ static void on_mqtt(void *arg, esp_event_base_t base, int32_t id, void *data) { (void)arg; (void)base; esp_mqtt_event_handle_t ev = data; switch ((esp_mqtt_event_id_t)id) { case MQTT_EVENT_CONNECTED: { esp_mqtt_client_subscribe_single(ev->client, mlink_topic(MLINK_T_CMD), 1); char buf[96]; const size_t n = mlink_state_online(phonerail_version(), s_st.ip, buf, sizeof(buf)); esp_mqtt_client_enqueue(ev->client, mlink_topic(MLINK_T_STATE), buf, (int)n, 1, 1, true); ST_EDIT(s_st.mqtt = NET_UP); memset(&s_tele, 0, sizeof(s_tele)); /* 一连上先报一条遥测 */ ESP_LOGI(TAG, "MQTT 已连上,订阅 %s", mlink_topic(MLINK_T_CMD)); break; } case MQTT_EVENT_DISCONNECTED: ST_EDIT(if (s_st.mqtt == NET_UP) { s_st.mqtt_drops++; } s_st.mqtt = NET_CONNECTING); break; case MQTT_EVENT_DATA: { const char *cmd_t = mlink_topic(MLINK_T_CMD); if (ev->topic_len != (int)strlen(cmd_t) || memcmp(ev->topic, cmd_t, (size_t)ev->topic_len)) { break; /* 切片的后续片没有 topic —— 首片已经按 too_long 回过了 */ } char line[CMD_LINE_MAX]; const char *err = mlink_inbound(ev->data, ev->data_len, ev->total_data_len, line, sizeof(line)); if (err != NULL) { link_error(NULL, err); } else if (!cmd_queue_submit(CMD_LINK_WIFI, line, reply_mqtt, NULL)) { link_error(line, "queue_full"); } break; } default: break; } } static void mqtt_start(void) { static char client_id[24]; snprintf(client_id, sizeof(client_id), "phonerail-%s", mlink_id()); const esp_mqtt_client_config_t mc = { .broker.address.uri = s_cur.uri, .broker.verification.certificate = (strncmp(s_cur.uri, "mqtts://", 8) == 0) ? evse_ca_pem_start : NULL, .credentials.client_id = client_id, .credentials.username = s_cur.user[0] ? s_cur.user : NULL, .credentials.authentication.password = s_cur.mpass[0] ? s_cur.mpass : NULL, .session.last_will = { .topic = mlink_topic(MLINK_T_STATE), .msg = MLINK_STATE_OFFLINE, .qos = 1, .retain = 1 }, .session.keepalive = MQTT_KEEPALIVE_S, .task.priority = task_desc(TASK_LINK)->prio, .task.stack_size = 6144, }; esp_mqtt_client_handle_t c = esp_mqtt_client_init(&mc); if (c == NULL) { ESP_LOGE(TAG, "MQTT 客户端建不起来"); return; } esp_mqtt_client_register_event(c, ESP_EVENT_ANY_ID, on_mqtt, NULL); ST_EDIT(s_mqtt = c; s_st.mqtt = NET_CONNECTING); esp_mqtt_client_start(c); } static void mqtt_stop(void) { esp_mqtt_client_handle_t c; ST_EDIT(c = s_mqtt; s_mqtt = NULL; s_st.mqtt = NET_OFF); if (c != NULL) { esp_mqtt_client_stop(c); /* 会阻塞到它的任务退出 —— 所以只在 t_link 里调 */ esp_mqtt_client_destroy(c); } } /* ---- WiFi 事件(系统事件循环) ------------------------------------------- */ /* 把 STA 配成表里第 idx 组(idx 必须 < count)。 */ static void wifi_set_sta(uint8_t idx) { const netcfg_wifi_t *w = &s_cur.wifi[idx]; wifi_config_t wc = { 0 }; memcpy(wc.sta.ssid, w->ssid, strlen(w->ssid)); memcpy(wc.sta.password, w->pass, strlen(w->pass)); wc.sta.threshold.authmode = w->pass[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN; esp_wifi_set_config(WIFI_IF_STA, &wc); ST_EDIT(s_st.wifi_idx = idx); } static void retry_cb(void *arg) { (void)arg; const int n = netcfg_wifi_count(&s_cur); if (s_st.wifi != NET_UP && n > 0) { const uint8_t i = s_idx < n ? s_idx : 0; wifi_set_sta(i); esp_wifi_connect(); } } static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data) { (void)arg; if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { const wifi_event_sta_disconnected_t *d = data; const int n = netcfg_wifi_count(&s_cur); const bool was_up = s_st.wifi == NET_UP; ST_EDIT(s_st.wifi_reason = d->reason; if (was_up) { s_st.wifi_drops++; } s_st.wifi = n > 0 ? NET_CONNECTING : NET_OFF; s_st.ip[0] = '\0'; s_st.rssi = 0); if (s_leaving) { s_leaving = false; /* wifi_apply 自己断的,它紧接着就去连了 */ return; } if (n > 0) { /* 连上过又掉的:先重试同一组;没连上的:换下一组。退避按「轮完一整圈」算 —— 几组全连不上也是 1 s、2 s、4 s …… 地拉长,而不是每组各退各的。 */ if (!was_up && n > 1) { s_idx = (uint8_t)((s_idx + 1) % n); } const uint32_t ms = mlink_backoff_ms(s_attempt++ / (uint32_t)n); ESP_LOGW(TAG, "WiFi 断开,原因 %u,%u ms 后连「%s」", (unsigned)d->reason, (unsigned)ms, s_cur.wifi[s_idx < n ? s_idx : 0].ssid); esp_timer_stop(s_retry); esp_timer_start_once(s_retry, (uint64_t)ms * 1000u); } } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { const ip_event_got_ip_t *e = data; s_attempt = 0; ST_EDIT(s_st.wifi = NET_UP; snprintf(s_st.ip, sizeof(s_st.ip), IPSTR, IP2STR(&e->ip_info.ip))); ESP_LOGI(TAG, "WiFi「%s」已连上,IP %s", s_cur.wifi[s_idx].ssid, s_st.ip); } } /* 按 s_cur 重新连 WiFi(t_link 里调)。 */ static void wifi_apply(void) { esp_timer_stop(s_retry); s_leaving = s_st.wifi == NET_UP; esp_wifi_disconnect(); s_attempt = 0; const int n = netcfg_wifi_count(&s_cur); if (n == 0) { s_leaving = false; ST_EDIT(s_st.wifi = NET_OFF); return; } if (s_idx >= n) { s_idx = 0; } wifi_set_sta(s_idx); ST_EDIT(s_st.wifi = NET_CONNECTING); esp_wifi_connect(); ESP_LOGI(TAG, "连 WiFi「%s」(%u/%d)", s_cur.wifi[s_idx].ssid, (unsigned)s_idx + 1, n); /* 只打 SSID,不打口令 */ } /* ---- 对外 ---------------------------------------------------------------- */ hal_err_t mlink_esp_init(void) { s_mtx = xSemaphoreCreateMutex(); s_out_mtx = xSemaphoreCreateMutex(); s_outbox = xQueueCreate(OUTBOX_DEPTH, sizeof(out_msg_t)); esp_err_t e = nvs_flash_init(); if (e == ESP_ERR_NVS_NO_FREE_PAGES || e == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); /* NVS 分区格式不认:清掉重来(里面只有本模块的配置与 WiFi 校准) */ e = nvs_flash_init(); } if (s_mtx == NULL || s_out_mtx == NULL || s_outbox == NULL || e != ESP_OK) { ESP_LOGE(TAG, "NVS 起不来:%s —— WiFi 链路不可用", esp_err_to_name(e)); netcfg_init(NULL, NULL, NULL, NULL); return HAL_ERR_STATE; } netcfg_t loaded; const bool have = nvs_load(&loaded); netcfg_init(have ? &loaded : NULL, nvs_save, on_cfg_changed, NULL); netcfg_get(&s_cur); uint8_t mac[6]; esp_read_mac(mac, ESP_MAC_WIFI_STA); mlink_init(mac); ST_EDIT(snprintf(s_st.id, sizeof(s_st.id), "%s", mlink_id())); const esp_timer_create_args_t ta = { .callback = retry_cb, .name = "wifi_retry" }; if (esp_netif_init() != ESP_OK || esp_event_loop_create_default() != ESP_OK || esp_netif_create_default_wifi_sta() == NULL || esp_timer_create(&ta, &s_retry) != ESP_OK) { ESP_LOGE(TAG, "网络栈起不来 —— WiFi 链路不可用"); return HAL_ERR_STATE; } const wifi_init_config_t wi = WIFI_INIT_CONFIG_DEFAULT(); if (esp_wifi_init(&wi) != ESP_OK) { ESP_LOGE(TAG, "WiFi 驱动起不来 —— WiFi 链路不可用"); return HAL_ERR_STATE; } esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, on_wifi, NULL); esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, on_wifi, NULL); esp_wifi_set_storage(WIFI_STORAGE_RAM); /* 凭据只存我们自己的那份,驱动别再存一份 */ esp_wifi_set_mode(WIFI_MODE_STA); esp_wifi_start(); s_up = true; ESP_LOGI(TAG, "板子 ID %s,topic %s/…", mlink_id(), mlink_topic(MLINK_T_CMD)); if (!have) { ESP_LOGW(TAG, "还没配网:经 USB 发 wifi / mqtt 两条命令(tools/prlink.py)"); } wifi_apply(); return HAL_OK; } void mlink_esp_poll(void) { if (!s_up) { return; } if (s_dirty) { s_dirty = false; /* 加 / 删的不是正连着的那一组、口令也没变:WiFi 不断;broker 也没变:MQTT 也不断。 经 USB 往表里加一组备用热点,不该让板子掉线一次。 */ static netcfg_t next; /* ≈560 B,不放栈上(只有 t_link 进来) */ netcfg_get(&next); const int j = (s_st.wifi == NET_UP) ? netcfg_wifi_find(&next, s_cur.wifi[s_idx].ssid) : -1; const bool wifi_same = j >= 0 && strcmp(next.wifi[j].pass, s_cur.wifi[s_idx].pass) == 0; const bool mqtt_same = strcmp(next.uri, s_cur.uri) == 0 && strcmp(next.user, s_cur.user) == 0 && strcmp(next.mpass, s_cur.mpass) == 0; if (!(wifi_same && mqtt_same)) { mqtt_stop(); } s_cur = next; if (wifi_same) { s_idx = (uint8_t)j; ST_EDIT(s_st.wifi_idx = s_idx); } else { wifi_apply(); } } /* MQTT 的生命周期只在这里管:WiFi 有地址、配了 broker、还没建客户端 ⇒ 建。 之后掉线重连由 esp-mqtt 自己做(network.reconnect_timeout_ms,默认 10 s)。 */ if (s_mqtt == NULL && s_st.wifi == NET_UP && s_cur.uri[0] != '\0') { mqtt_start(); } outbox_drain(); if (s_st.mqtt != NET_UP || !mlink_tele_due(&s_tele, hal_time_now_ms(), s_cur.tele_ms)) { return; } wifi_ap_record_t ap; if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) { ST_EDIT(s_st.rssi = ap.rssi); } if (s_tele_inflight) { ST_EDIT(s_st.tele_skipped++); /* 上一条还在队列里:网络或执行器堵了,不再往里塞 */ return; } s_tele_inflight = true; if (!cmd_queue_submit(CMD_LINK_WIFI, "{\"cmd\":\"status\"}", reply_tele, NULL)) { s_tele_inflight = false; ST_EDIT(s_st.tele_skipped++); } }