- A12 单一命令处理器(cmd*.c):一行 JSON 进出、seq 全局判重(32 条按值)、
限幅 / 空槽一律拒 / 拒绝记事件;命令号与错误码冻结(进 flash)。新增 power 命令,
fan 改为只抬不压的下限(t_thermal_set_floor)。rail_set_mv 注明不拿锁,调用方包 xact。
- A13 USB-Serial-JTAG 分帧(ulink*.c):@C / @R 前缀 + CRC-16,日志行首 @ 垫空格,
任意切块可切;关掉 USB 副控制台(它绕开驱动写 FIFO)。PC 端客户端 tools/prlink.py。
- A14 + C1(netcfg / mlink / cmd_net):topic phonerail/<mac>/{cmd,rsp,tele,state},
mqtts(evse-ca 编进固件)、1 s 遥测、遗嘱离线;wifi / mqtt 配置只收 USB、存 NVS,
net 永不回显密码。
- 协议定稿写进需求文档 §10.8(A12 / A13 / A14 三节);N7 撤销改 D7(机架管理前后端)。
宿主机 23 个 ctest / 400 例全绿;板 B 已烧 0.1.0-a14,经 WiFi → 公网 → 8883 TLS 真连网验过。
固件 0xE2220 / 1 MB 分区只剩 12%,C2 前要重排分区表。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
372 行
14 KiB
C
372 行
14 KiB
C
/* 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()`)—— 生命周期只在一个任务里管 |
|
||
| 回应 / 遥测的发布 | 执行器任务(cmd_queue 的回调)—— 只用 `esp_mqtt_client_enqueue()`,**不阻塞** |
|
||
|
||
`esp_mqtt_client_publish()` 在网络慢的时候会阻塞到超时,放在执行器任务里就是让
|
||
「按键该松了」等 TCP。enqueue 只进发件箱,由 esp-mqtt 自己的任务发。
|
||
|
||
---- 失联不动作(§10.2 / §10.8) -----------------------------------------
|
||
WiFi 断、broker 断,本文件只做一件事:**重连**(WiFi 指数退避,MQTT 由 esp-mqtt 每 10 s 重试)。
|
||
没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。 */
|
||
#include <string.h>
|
||
|
||
#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/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 "net"
|
||
#define NVS_VERSION 1 /* netcfg_t 布局一变就加一:旧版本的 blob 直接当没配置 */
|
||
|
||
/* 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 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;
|
||
|
||
/* ---- 状态:改完立刻推给 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;
|
||
}
|
||
const nvs_blob_t b = { .ver = NVS_VERSION, .cfg = *cfg };
|
||
esp_err_t e = nvs_set_blob(h, NVS_KEY, &b, sizeof(b));
|
||
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;
|
||
}
|
||
nvs_blob_t b;
|
||
size_t n = sizeof(b);
|
||
const bool ok = nvs_get_blob(h, NVS_KEY, &b, &n) == ESP_OK && n == sizeof(b) &&
|
||
b.ver == NVS_VERSION;
|
||
nvs_close(h);
|
||
if (ok) {
|
||
*out = b.cfg;
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
static void on_cfg_changed(void *ctx)
|
||
{
|
||
(void)ctx;
|
||
s_dirty = true; /* 执行器任务里只置标志;断开重连在 t_link 里做(esp_mqtt_client_stop 会阻塞) */
|
||
}
|
||
|
||
/* ---- 发布(执行器任务里调,只进发件箱) ----------------------------------- */
|
||
|
||
static void enqueue(mlink_topic_t t, const char *data, size_t len, int qos, int retain)
|
||
{
|
||
if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(10)) != pdTRUE) {
|
||
return;
|
||
}
|
||
if (s_mqtt != NULL && s_st.mqtt == NET_UP) {
|
||
(void)esp_mqtt_client_enqueue(s_mqtt, mlink_topic(t), data, (int)len, qos, retain, true);
|
||
}
|
||
xSemaphoreGive(s_mtx);
|
||
}
|
||
|
||
static void reply_mqtt(void *ctx, const char *line, size_t len)
|
||
{
|
||
(void)ctx;
|
||
enqueue(MLINK_T_RSP, line, len, 1, 0);
|
||
}
|
||
|
||
static void reply_tele(void *ctx, const char *line, size_t len)
|
||
{
|
||
(void)ctx;
|
||
enqueue(MLINK_T_TELE, line, len, 0, 0);
|
||
ST_EDIT(s_st.tele_sent++);
|
||
s_tele_inflight = false;
|
||
}
|
||
|
||
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) {
|
||
enqueue(MLINK_T_RSP, out, n, 1, 0);
|
||
}
|
||
}
|
||
|
||
/* ---- 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 事件(系统事件循环) ------------------------------------------- */
|
||
|
||
static void retry_cb(void *arg)
|
||
{
|
||
(void)arg;
|
||
if (s_st.wifi != NET_UP && s_cur.ssid[0] != '\0') {
|
||
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;
|
||
ESP_LOGW(TAG, "WiFi 断开,原因 %u,%u ms 后重试", (unsigned)d->reason,
|
||
(unsigned)mlink_backoff_ms(s_attempt));
|
||
ST_EDIT(s_st.wifi_reason = d->reason;if (s_st.wifi == NET_UP) { s_st.wifi_drops++; } s_st.wifi =
|
||
s_cur.ssid[0] ? NET_CONNECTING : NET_OFF;
|
||
s_st.ip[0] = '\0'; s_st.rssi = 0);
|
||
if (s_cur.ssid[0] != '\0') {
|
||
const uint32_t ms = mlink_backoff_ms(s_attempt++);
|
||
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 已连上,IP %s", s_st.ip);
|
||
}
|
||
}
|
||
|
||
/* 按 s_cur 重新连 WiFi(t_link 里调)。 */
|
||
static void wifi_apply(void)
|
||
{
|
||
esp_timer_stop(s_retry);
|
||
esp_wifi_disconnect();
|
||
s_attempt = 0;
|
||
if (s_cur.ssid[0] == '\0') {
|
||
ST_EDIT(s_st.wifi = NET_OFF);
|
||
return;
|
||
}
|
||
wifi_config_t wc = { 0 };
|
||
memcpy(wc.sta.ssid, s_cur.ssid, strlen(s_cur.ssid));
|
||
memcpy(wc.sta.password, s_cur.pass, strlen(s_cur.pass));
|
||
wc.sta.threshold.authmode = s_cur.pass[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
|
||
esp_wifi_set_config(WIFI_IF_STA, &wc);
|
||
ST_EDIT(s_st.wifi = NET_CONNECTING);
|
||
esp_wifi_connect();
|
||
ESP_LOGI(TAG, "连 WiFi「%s」", s_cur.ssid); /* 只打 SSID,不打口令 */
|
||
}
|
||
|
||
/* ---- 对外 ---------------------------------------------------------------- */
|
||
|
||
hal_err_t mlink_esp_init(void)
|
||
{
|
||
s_mtx = xSemaphoreCreateMutex();
|
||
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 || 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;
|
||
mqtt_stop();
|
||
netcfg_get(&s_cur);
|
||
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();
|
||
}
|
||
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++);
|
||
}
|
||
}
|