ESP8266 IoT网关开发记录
最近写完了一个简单的ESP8266项目。包含配网,自动重连与连接状态检测,MQTT连接与数据交互功能。
在此罗列一些基于PlatformIO、使用ESP8266开发一个简易网关遇到的问题和解法,针对一些内容的设计思路,一些常用的设计方法。
在文章中,“例子”都是开发此项目遇到的问题。示例代码部分来源于仓库。
仓库链接:SilverDragonYo/ESP8266-IoT-Gateway: An ESP8266-based IoT gateway
-
事件驱动与轮询驱动
这是在研究配网和LED灯状态显示的时候接触的。我也不能很好地定义,我结合Gemini的说明解释一下吧。
简单理解,轮询驱动就是在主循环中不断地、主动地去询问外部设备或状态;再简单一点,都是主循环。单片机一般都是这类,不论是
while(1)还是loop()。对于事件驱动,就是当外部发生事情时,系统或硬件会自动调用这个函数。你如果开发过微信小程序,或者接触过前端,应该容易理解。例如微信小程序的JS里的
onShow()函数,就是预先注册了,当页面加载好,会执行这个函数。 -
有限状态机实现非阻塞轮询
这是针对轮询驱动的优化。在这个项目的开发中,就非常重要。
因为ESP8266是单核单线程MCU,资源由用户代码和Wi-Fi协议栈公用。意味着用户代码如果长时间阻塞,或者注册一些回调函数,这些函数引用一些指针,就可能出现包括野指针在内的问题导致异常崩溃。
有限状态机,它在维基百科的定义是:有限状态机 - 维基百科,自由的百科全书
但是在这里,不太需要完全理解理论。我们简单理解,就是把一件事情拆分为几个状态,同一时间同一状态只能处理一件事情。例如Wi-Fi连接,进行一次尝试之后就要转入结果等待的状态,而不是重复尝试。
-
排除崩溃故障
-
现象:在执行某些功能的时候单片机重启。
-
原因:一般不是语法错误,所以编译能过。可能是库选择不当,或者代码逻辑问题。
-
解法:如果你也使用VScode的PIO进行开发,可以使用自带的
monitor_filters处理,方法如下:-
打开工程对应的
platformio.ini文件。 -
添加
monitor_filters如下:1
monitor_filters = esp8266_exception_decoder
如果是esp32,就是
esp32_exception_decoder。 -
在上传代码时候,选择
upload and Monitor选项。通过添加这个过滤器,可以实现在出现异常时自动解码。
Arduino IDE也可以用 Exception Decoder,可以参考这篇文章,相比PIO要麻烦一些。
Debugging Arduino Errors with the ESP32 Exception Decoder
如果需要查找对应的过滤器,可以参考下面的文章。
pio device monitor — PlatformIO latest documentation
需要查找异常崩溃序号对应的错误,可以参考下面的文章(虽然可能没什么用)。
-
-
例子:在本次开发中,我遇到了下面的异常:
1
2
3
4
5
6
7
8
9
10
11
12
13
14Exception (4):
epc1=0x4000dd22 epc2=0x00000000 epc3=0x00000000 excvaddr=0x00000000 depc=0x00000000
>>>stack>>>
ctx: cont sp: 3ffffdf0 end: 3fffffd0 offset: 0160
3fffff50: 00000009 00002710 4020a586 4020b860
3fffff60: 00000000 00e7c4a8 3fffdad0 00449a77
3fffff70: 40207c9c 3ffe8fe8 3ffeef44 3ffef02c
3fffff80: 3fffdad0 3ffeee00 3ffeed74 402017a0
3fffff90: 3fffdad0 3ffeee00 3ffeef44 40201108
3fffffa0: 3fffdad0 00000000 3ffeee00 402015aa
3fffffb0: 3fffdad0 00000000 3ffef000 4020a71c
3fffffc0: feefeffe feefeffe 3fffdab0 40100ea5
<<<stack<<<
--------------- CUT HERE FOR EXCEPTION DECODER ---------------解码之后的是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20--------------- CUT HERE FOR EXCEPTION DECODER ---------------
Soft WDT reset
Exception (4): epc1=0x4010032d epc2=0x00000000 epc3=0x00000000 excvaddr=0x00000000 depc=0x00000000
Level1Interrupt: Level-1 interrupt as indicated by set level-1 bits in the INTERRUPT register epc1=0x4010032d in millis at ??:?
>>>stack>>>
ctx: cont
sp: 3ffffe10 end: 3fffffd0 offset: 0160
3fffff70: 40207c9c 3ffe8fe8 3ffeef44 3ffef02c
3fffff80: 3fffdad0 3ffeee00 3ffeed74 402017a0
3fffff90: 3fffdad0 3ffeee00 3ffeef44 40201108
3fffffa0: 3fffdad0 00000000 3ffeee00 402015aa
3fffffb0: 3fffdad0 00000000 3ffef000 4020a71c
3fffffc0: feefeffe feefeffe 3fffdab0 40100ea5
<<<stack<<<
0x40207c9c in HardwareSerial::write(unsigned char const*, unsigned int) at ??:?
0x402017a0 in SoftTimer::expired() at ??:?
0x40201108 in connectToServer() at ??:?
0x402015aa in loop at ??:?
0x4020a71c in loop_wrapper() at core_esp8266_main.cpp:?
0x40100ea5 in cont_wrapper at ??:?就可以判断出是软件看门狗触发了,
调用链是
loop → connectToServer() → SoftTimer::expired() → HardwareSerial::write。我们看一下错误的源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17void connectToServer() {
while (!Client.connected()) {
if(ConnectTimer.expired()) {
Serial.print("Attempting MQTT connection...");
if (Client.connect(UID)) {
Serial.println("connected");
Serial.print("subscribe:");
Serial.println(subTopic);
Client.subscribe(subTopic);
} else {
Serial.print("failed, rc=");
Serial.print(Client.state());
Serial.println(" try again in 5 seconds");
}
}
}
}在这里,一旦没法连上MQTT服务器,就会通过
while (!Client.connected())循环反复重试,从而阻塞主循环,触发看门狗。
-
-
用LED灯显示不同状态
-
灵感:8266开发板上面有个灯,平时也没用。用户在上电之后又不知道单片机在干什么,交互做的不好,干脆让这个灯的不同状态来表示一些东西,物尽其用。
-
简单的思路:
采用轮询驱动思路,使用
switch-case语句,结合非常简单的状态机搭建合理的函数。一个简单的示例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26void ledTask() {
unsigned long now = millis();
switch(currentMode) {
case LED_OFF:
digitalWrite(LED_BUILTIN, HIGH);
break;
case LED_ON:
digitalWrite(LED_BUILTIN, LOW);
break;
case LED_BLINK_SLOW:
if(now - lastTick >= blinkInterval_slow) {
lastTick = now;
digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
}
break;
case LED_BLINK_FAST:
if(now - lastTick >= blinkInterval_fast) {
lastTick = now;
digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
}
break;
}
}将
ledTask()放进主循环里即可。当然还需要一个函数,用以修改状态,以及一个枚举类型,用来存放这些状态。
很容易引发一些思考。例如:为什么要用这样的思路,为什么要用millis计时而不是delay?
因为如果长时间阻塞主循环,可能触发看门狗重启,导致异常。delay是直接阻塞主循环得到的延时。
-
-
Wi-Fi状态机
-
用途:
写一个Wi-Fi任务状态机,让8266能够按照指定流程完成比较复杂的wifi任务。
不过值得注意的是,8266只支持2.4G的Wi-Fi。
-
流程设计:
采用轮询驱动思路,同样依赖
switch-case语句。整体流程就是:启动→尝试连接→失败→配置→重连→成功→定时检测连接状态。
-
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65void wifiTask() {
switch (currentState) {
case WIFI_CONNECT:
WiFi.mode(WIFI_STA);
setLedMode(LED_BLINK_SLOW);
wifiConnect();
currentState = WIFI_CONNECTING;
break;
case WIFI_CONNECTING:
if(WiFi.status() == WL_CONNECTED) {
currentState = WIFI_CONNECTED;
} else if (RetryTimer.expired() && !wifiConfigMode) {
currentState = WIFI_CONFIG;
}
break;
case WIFI_CONFIG:
setLedMode(LED_BLINK_FAST);
if(!wifiConfigMode) enterConfigMode();
currentState = DNS_SETTING;
DNSSetupTimer.reset();
break;
case DNS_SETTING:
if(DNSSetupTimer.expired()) {
if(!dnsActive && WiFi.softAPIP() != IPAddress(0,0,0,0)) {
dnsStart();
currentState = WIFI_CONFIG_SAVING;
}
}
break;
case WIFI_CONFIG_SAVING:
if(!wifiConfigMode) {
Serial.println("get wifi config");
currentState = WIFI_CONFIG_SAVED;
ConfigSaveTimer.reset();
}
break;
case WIFI_CONFIG_SAVED:
if(ConfigSaveTimer.expired()) {
Serial.println("restart connect");
server.stop();
dnsEnd();
currentState = WIFI_CONNECT;
}
break;
case WIFI_CONNECTED:
setLedMode(LED_ON);
Serial.println("WiFi connected");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
currentState = WIFI_MONITOR;
break;
case WIFI_MONITOR:
if(MonitorTimer.expired() && WiFi.status() != WL_CONNECTED) {
currentState = WIFI_CONNECT;
}
break;
}
} -
简单解析:
- 注意合理拆分状态,一个状态只完成一件事情。
- 切记及时切换状态,避免重入。
- AP模式和STA模式:简单理解,AP模式就是单片机自身作为热点,供外部设备连接;STA模式就是单片机作为终端设备,连接网络。
- 避免使用delay延时,长时间阻塞主循环。如果要用到较多定时器,可以写一个对象。
-
-
AP配网
顾名思义,就是在AP模式下实现配网。不要问AP模式是什么。
配网,就是给物联网设备配置网络参数,让它能联网。如果你用过校园网,或者需要联网的智能设备,在配置模式里扫码连Wi-Fi或者需要先输入学号和密码,也是类似的操作。
在这个项目里面,就是在8266上电后,自动联网失败时,手机连接8266的热点,进入配网页面,配置正确的SSID和密码,重连就可连上配置好的Wi-Fi。
如果没有配网功能,SSID和密码都写死,那换个热点,单片机就上不了网了。
-
选用库:
ESP8266WebServer,DNSServer -
推荐一个好用的仓库,包含很多常用库:esp8266/Arduino: ESP8266 core for Arduino
-
示例代码;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39void serverSetup() {
server.on("/", HTTP_GET, []() {
server.send_P(200, "text/html", index_html);
});
// 处理安卓、iOS、Windows系统的网络连接测试请求
server.on("/generate_204", HTTP_GET, []() {
server.send_P(200, "text/html", index_html);
});
server.on("/library/test/success.html", HTTP_GET, []() {
server.send_P(200, "text/html", index_html);
});
server.on("/connecttest.txt", HTTP_GET, []() {
server.send_P(200, "text/html", index_html);
});
server.on("/wifi", HTTP_POST, []() {
char ssid[21];
char password[21];
if(server.hasArg("ssid")) {
server.arg("ssid").toCharArray(ssid, sizeof(ssid));
};
if(server.hasArg("password")) {
server.arg("password").toCharArray(password, sizeof(password));
}
server.send_P(200, "text/html", success_html);
storageWiFiConfig(ssid, password);
wifiConfigMode = false;
});
server.onNotFound([]() {
server.send_P(404, "text/plain", "not found");
});
server.begin();
} -
DNS库使用示例:
1
2
3
4
5
6
7
8
9
10
11
12
13void dnsStart() {
dnsServer.start(DNS_PORT, "*", IPAddress(192, 168, 4, 1));
Serial.println("dns started");
dnsActive = true;
}
void dnsEnd() {
if(dnsActive) {
dnsServer.stop();
Serial.println("dns ended");
dnsActive = false;
}
} -
部分解析:
-
简单思路:DNS将所有域名解析到目标IP,然后返回非期望的状态码,让设备判断需要登陆/配置,并跳转配网页面。
-
关于
server.on()的参数:1
void on(const String &uri, HTTPMethod method, THandlerFunction fn, THandlerFunction ufn);
-
关于
/generate_204:HTTP generate_204 检测网络连接是否正常 | Yann’s Blog -
DNS_PORT:一般为53。
-
项目使用的均为同步库,针对
server,需要在主循环里放一句server.handleClient();,针对DNSServer,需要放dnsServer.processNextRequest();。
-
-
-
写一个简单的网页
-
用途:在配网时,用户需要进入一个简单的页面,输入并提交对应的Wi-Fi SSID以及密码。
-
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25const char index_html[] PROGMEM = R"keqing(
<head>
<meta charset="UTF-8">
<title>设备配网页</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html {font-family: Arial; display: inline-block; text-align: center;}
h2 {font-weight: bolder; margin-top: 15px;}
input {margin-bottom: 5px; height: 35px; border-radius: 8px;}
button {margin-top: 5px; border-radius: 8px; width: 100px; height: 35px;}
body {margin:x auto;}
</style>
</head>
<body>
<h2>ElectroDragon</h2>
<h4>输入WiFi名称和密码, 然后提交</h4>
<h5 style="color: red;">请输入不超过 20 位的数字或英文字母</h5>
<form action="wifi" method="POST">
<input name="ssid" placeholder="请输入WiFi SSID"><br>
<input name="password" placeholder="请输入WiFi密码"><br>
<button type="submit">提交</button>
</form>
</body>
)keqing"; -
部分解释:
-
<meta charset="UTF-8">:meta是网页的元数据标签,charset="UTF-8"表示指定UTF-8编码,与IDE一致,可以防止编码错误,导致在用户设备上显示乱码。 -
语句
const char index_html[] PROGMEM = R"keqing()keqing":关键字 解释 const常量 char字符类型。字符串的基础单位。 index_html[]字符数组 PROGMEMProgram Memory,程序存储器 R"keqing()keqing"原始字符串字面量 (Raw String Literal),解决字符串需要转义的问题。 R开始,括号内的字符会被原样保留。可以写作R"()",也可以加上喜欢的标签(例如keqing),防止字符串内出现括号导致解析错误。 -
form与input:form表单可以通过type为submit的按钮提交,以键值对保存参数,键为input中的name。 -
关于样式定义和其他内容,请自行查找。
-
-
-
THE END
没了。
