/** * @file ArduinoGeneric.ino * @brief 最小可用的「接入 esp32hub 平台」示例固件。 * * 这块板子不需要是实验板,任何 ESP32 / ESP8266 都行: * 改下面三行(WiFi 账号密码)→ 烧进去 → 打开串口看 6 位配对码 → * 在 https://esp32hub.luo-jin-ai.com 输入配对码 → 设备出现在「我的设备」里, * 平台会按 caps 自动生成控制面板,不用你写任何网页。 * * 依赖库(Arduino 库管理器里装): * - WebSockets by Markus Sattler (≥2.4.0) * - ArduinoJson by Benoit Blanchon (≥7.0.0) * * 协议全文:https://esp32hub.luo-jin-ai.com/protocol.md */ #include #include #include #include #include #include /* ─────────── 只改这三行 ─────────── */ #define WIFI_SSID "OPPO" #define WIFI_PASS "12345678" #define DEV_MODEL "我的 ESP32" /* 平台地址:80 端口明文 ws,免证书、免对时;想加密就改 443 + beginSSL */ #define HUB_HOST "esp32hub.luo-jin-ai.com" #define HUB_PORT 80 #define HUB_PATH "/ws/device" #define FW_VER "1.0.0" const int PIN_LED = 2; // 板载 LED;没有就随便接一个 /* ─────────── 设备身份(自动生成并存 NVS)─────────── */ static char gDid[33] = {0}; // 8~32 位 hex static char gKey[65] = {0}; // 32~64 位 hex static char gPair[7] = {0}; // 6 位数字配对码 /* ─────────── 运行期 ─────────── */ WebSocketsClient ws; static bool ready = false; static char boot[33] = {0}, linkTok[33] = {0}; static char permit[33] = {0}; static uint32_t permitAt = 0, tmAt = 0, retryAt = 0; /* 设备状态:真机上就是你的变量 */ static int ledVal = 0; static int mode = 0; static int bootKey = 1; // 1 = 松开 /* 请求头必须放静态区:库只存指针不拷贝,且**结尾不能带 \r\n**(库自己会补) */ static char extraHdr[128]; static void randHex(char* out, size_t n) { static const char* H = "0123456789abcdef"; for (size_t i = 0; i < n; i++) out[i] = H[esp_random() & 0x0F]; out[n] = 0; } /* ── 首次上电生成身份;之后从 NVS 读 ── */ static void identityBegin() { Preferences p; p.begin("lab", false); String did = p.getString("did", ""); String key = p.getString("key", ""); if (did.length() < 8 || key.length() < 32) { /* 用 MAC 后 6 字节做 did,天然不重复,也方便你在路由器里认设备 */ uint64_t mac = ESP.getEfuseMac(); char tmp[17]; snprintf(tmp, sizeof(tmp), "%04x%08x", (uint16_t)(mac >> 32), (uint32_t)mac); did = String(tmp).substring(4); // 取后 12 位 hex char k[65]; randHex(k, 64); key = k; p.putString("did", did); p.putString("key", key); Serial.println("[身份] 首次上电,已生成并写入 NVS"); } strncpy(gDid, did.c_str(), sizeof(gDid) - 1); strncpy(gKey, key.c_str(), sizeof(gKey) - 1); /* 配对码每次上电换一个:15 分钟内有效,用户用它把设备认领到自己名下 */ snprintf(gPair, sizeof(gPair), "%06u", (unsigned)(esp_random() % 1000000)); p.end(); } /* ── 能力清单:平台据此生成面板,改了这里不用改服务器 ── */ static const char* capsJson() { static const char* j = "[{\"id\":\"led\",\"type\":\"pwm\",\"label\":\"LED 亮度\",\"n\":1," "\"cmd\":\"led\",\"range\":[0,255],\"state\":\"led\"}," "{\"id\":\"mode\",\"type\":\"enum\",\"label\":\"工作模式\",\"cmd\":\"mode\",\"param\":\"v\"," "\"options\":[{\"v\":0,\"n\":\"待机\"},{\"v\":1,\"n\":\"呼吸\"},{\"v\":2,\"n\":\"闪灯\"}]," "\"state\":\"mode\"}," "{\"id\":\"bootkey\",\"type\":\"input\",\"label\":\"BOOT 键\",\"n\":1,\"state\":\"keys\"}," "{\"id\":\"sys\",\"type\":\"sensor\",\"label\":\"运行状态\",\"path\":\"sys\"," "\"fields\":[{\"k\":\"up\",\"label\":\"运行\",\"unit\":\"s\",\"digits\":0}," "{\"k\":\"heap\",\"label\":\"内存\",\"unit\":\"KB\",\"digits\":0}]}]"; return j; } static void sendHello() { StaticJsonDocument<768> doc; // ArduinoJson 7 也可直接 JsonDocument doc["t"] = "device_hello"; doc["boot"] = boot; doc["link"] = linkTok; doc["permit"] = permit; doc["permitTtlMs"] = 600; doc["model"] = DEV_MODEL; doc["fw"] = FW_VER; doc["pair"] = gPair; JsonArray cmds = doc["cmds"].to(); cmds.add("led"); cmds.add("ledall"); cmds.add("mode"); cmds.add("stop"); /* caps 直接塞原始 JSON,省一份内存 */ JsonDocument tmp; deserializeJson(tmp, capsJson()); doc["caps"] = tmp.as(); String out; serializeJson(doc, out); ws.sendTXT(out); Serial.printf("[云端] 发 hello(配对码 %s)\n", gPair); } static void sendPermit() { randHex(permit, 32); permitAt = millis(); StaticJsonDocument<160> doc; doc["t"] = "permit"; doc["boot"] = boot; doc["link"] = linkTok; doc["permit"] = permit; doc["permitTtlMs"] = 600; String out; serializeJson(doc, out); ws.sendTXT(out); } /* ── 指令执行:只认自己声明过的动作 ── */ static void applyCommand(JsonObject c, const char* lease, uint32_t seq) { const char* t = c["t"] | ""; bool ok = true; if (!strcmp(t, "led")) { int v = c["v"] | 0; ledVal = constrain(v, 0, 255); analogWrite(PIN_LED, ledVal); } else if (!strcmp(t, "ledall")) { ledVal = constrain((int)(c["v"] | 0), 0, 255); analogWrite(PIN_LED, ledVal); } else if (!strcmp(t, "mode")) { mode = constrain((int)(c["v"] | c["id"] | 0), 0, 2); } else if (!strcmp(t, "stop")) { mode = 0; ledVal = 0; analogWrite(PIN_LED, 0); } else { ok = false; } StaticJsonDocument<192> ack; ack["t"] = "ack"; ack["lease"] = lease ? lease : ""; ack["seq"] = seq; ack["ok"] = ok; ack["message"] = ok ? "已执行" : "未知指令"; String out; serializeJson(ack, out); ws.sendTXT(out); } static void onEvent(WStype_t type, uint8_t* payload, size_t len) { switch (type) { case WStype_CONNECTED: ready = false; randHex(linkTok, 32); // 每次连接都要换新的 link sendHello(); break; case WStype_DISCONNECTED: ready = false; retryAt = millis() + 3000; Serial.println("[云端] 断开,3 秒后重连"); break; case WStype_TEXT: { JsonDocument doc; if (deserializeJson(doc, payload, len)) return; const char* k = doc["t"] | ""; if (!strcmp(k, "ready")) { ready = true; Serial.println("[云端] 已上线"); Serial.printf("[云端] 配对码 %s(15 分钟内有效)\n", gPair); } else if (!strcmp(k, "lease")) { Serial.println("[云端] 有人取得控制权"); } else if (!strcmp(k, "lease_end")) { Serial.println("[云端] 控制权已释放"); } else if (!strcmp(k, "command")) { applyCommand(doc["command"].as(), doc["lease"] | "", (uint32_t)(doc["seq"] | 0)); } break; } default: break; } } void setup() { Serial.begin(115200); delay(200); pinMode(PIN_LED, OUTPUT); pinMode(0, INPUT_PULLUP); // BOOT 键 randHex(boot, 32); // 本次上电的代次 identityBegin(); Serial.println(); Serial.println("============================================="); Serial.printf(" %s 接入 esp32hub\n", DEV_MODEL); Serial.printf(" 设备编号 %s\n", gDid); Serial.println("============================================="); WiFi.mode(WIFI_STA); WiFi.setSleep(false); WiFi.begin(WIFI_SSID, WIFI_PASS); Serial.printf("[WiFi] 连接 %s", WIFI_SSID); for (int i = 0; i < 60 && WiFi.status() != WL_CONNECTED; i++) { delay(500); Serial.print("."); } Serial.println(); if (WiFi.status() != WL_CONNECTED) { Serial.println("[WiFi] 连接失败,重启"); ESP.restart(); } Serial.printf("[WiFi] 已连上,IP %s\n", WiFi.localIP().toString().c_str()); /* 请求头:静态缓冲区 + 结尾不带 \r\n(库内部自己补,多写会毁掉整个握手) */ snprintf(extraHdr, sizeof(extraHdr), "X-Lab-Device: %s\r\nX-Lab-Key: %s", gDid, gKey); ws.setExtraHeaders(extraHdr); ws.begin(HUB_HOST, HUB_PORT, HUB_PATH); ws.onEvent(onEvent); ws.setReconnectInterval(3000); ws.enableHeartbeat(15000, 4000, 2); } void loop() { ws.loop(); /* 许可心跳:不续就没人能发指令(平台 2 秒收不到就判失联) */ if (ready && millis() - permitAt >= 250) sendPermit(); /* 遥测:2 Hz */ if (ready && millis() - tmAt >= 500) { tmAt = millis(); bootKey = digitalRead(0) ? 1 : 0; StaticJsonDocument<256> doc; doc["t"] = "tm"; JsonArray led = doc["led"].to(); led.add(ledVal); doc["mode"] = mode; JsonArray keys = doc["keys"].to(); keys.add(bootKey); JsonObject sys = doc["sys"].to(); sys["up"] = millis() / 1000; sys["heap"] = ESP.getFreeHeap() / 1024; String out; serializeJson(doc, out); ws.sendTXT(out); } /* 三种模式的本地表现(真机上随便改) */ static uint32_t t0 = 0; if (millis() - t0 >= 20) { t0 = millis(); if (mode == 1) { // 呼吸 static int v = 0, step = 4; v += step; if (v <= 0 || v >= 255) step = -step; analogWrite(PIN_LED, max(0, min(255, v))); } else if (mode == 2) { // 闪灯 analogWrite(PIN_LED, (millis() / 250) % 2 ? 255 : 0); } else { // 待机:听网页的 analogWrite(PIN_LED, ledVal); } } }