前往 小组作业页面。

在折腾了一段时间的 ESP32 开发套件之后,我已经知道我的电路板需要什么了。那就来看看我想要它具备的功能列表:
需求明确后,我去找适用于 ESP-WROOM-32 的 Eagle 库,找到了好几个:
全部评估之后,我决定使用 MacroYau/MacroYau 那一个。

因为我想要最大程度兼容我熟悉的 ESP32 开发套件,我去找了一份 ESP32-WROOM 的引脚定义,并用它来给引脚命名。

下一步是给每个引脚接上网络连线。我希望能按现在介绍的顺序来做,因为那样能省掉重做很多第一次接错的线。教训是引脚的逻辑编号和物理编号并不总是相同;记住这一点,这次我都接对了。

最终结果是这样:

我在 snapeda 找到的继电器和我手上的不是同一型号,但封装是一样的。
我在网上找到了继电器电路,链接在 这里,指向 web.archive;原理图在下面。

拿到原理图后,我在 Eagle 里画了下面这些:

电池电路我参照了 Random Nerd Tutorials 这个页面上的电路。

因为目前我还没有 TP4056 模块,所以我的电路只会有检测部分。

分压电阻是用这个在线计算器算的,见 这里。

为了以后使用,我找到了 TP4056 的库,在 这里,多亏了我的远程导师 Luis Carvão 的提示。
湿度传感器的库在 这里。拿到之后,我只需要把几根网络连线接到 ESP32 的 32 号引脚。

电流传感器我选择使用 ACS712。我在这个 GitHub 上找到了库。画好的原理图如下:

我也在 SnapEDA 拿到了实时时钟库,但暂时不会用,所以我画的完整原理图如下:

盯着原理图看了一段时间后,我发现了几处错误。我忘了把继电器的 LED 电阻接到 VCC,还忘了加复位按钮和编程按钮(把 ESP 切到编程模式需要它们),以及串口模式和扩展用的排针。最后,我决定加一个晶振,让计时更准。
然后我画了 Reset 和 Program 按钮:

这里我补上 VCC 修正了继电器,还在信号和继电器之间加了一个光耦,避免继电器有任何回馈。

这里我加了一个 TX RX 排针、一个让内部 RTC 计时更准的晶振,以及兼容 ESP32DEVKIT 的排针。

那么,但愿如此,这就是我最终的原理图……

现在我要开始给电路板布线了。说实话我有点紧张,因为我从来没做过这么复杂的电路板!
切到 Eagle 的 board 模式时,我看到的画面真是让人振奋!!!

我做的第一件事是把 devkit 的封装移到中心,和 ESP32 放在一起。

手动连了一段时间后,我给自动布线器一个机会。几分钟后我就不得不停下,结果惨不忍睹。我会把 ESP32 往上移再试一次,如果还是不行,我就放弃兼容 ESP32devkit。

于是我放弃了兼容 DevKit,决定这次慢慢来。我备份了自己的工作,在另一个 Eagle 窗口里打开,并删掉了所有我当时没在处理的元件。原理图和电路板变成了这样。我的想法是每完成一步,就把元件从备份里复制回来。所以下面是第一步的样子。

在电路板上可以看到 1 号继电器、2 号电流传感器电路和 3 号板载 LED。

然后我复制了第一个电磁阀的继电器,切到电路板时,Eagle 给我显示了粘贴进来的元件。我觉得这样要容易得多。

到目前为止,电路板是这样的。我还没有布任何电源线,包括 GND、3.3V 和 5V。现在我会试试自动布线器找找思路,也许再手动布线。完整的布线过程在 输入设备那一周里有更详细的描述。

完成电路板布局后,我决定加上 logo。点击 File、Import 和 Bitmap(目前 Eagle 只接受 .bmp)。

选择要导入的颜色。

然后选择单位(我这里用 mm)和尺寸,点击 OK。

因为我想把 logo 铣出来而不是印上去,所以得把它移到顶层(Top)。

带孔和铣削轮廓的最终电路板布局,如 输出设备那一周所述。

这里展示 G-code 刀路的可视化,红色是离要保留的铜箔最近的那一刀。

和往常一样,而且同样在 输入设备那一周有更详细的说明,这里看一眼自动调平(autolevel)软件。


在灯光下检查电路板。
为了确认所有走线都没问题,我把灯放在电路板下面,逐一检查有没有短路或缺失的走线。

正在贴装底面。
我把大元件留到最后放,免得小尺寸 SMD 元件不好焊。

焊接完所有东西后,我把电路板接到串口适配器上。下面是接线图。

我粘贴了 ESP32 用出厂固件时的第一段输出。它启动了!
--- Available filters and text transformations: colorize, debug, default, direct, esp32_exception_decoder, hexlify, log2file, nocontrol, printable, send_on_enter, time
--- More details at http://bit.ly/pio-monitor-filters
--- Miniterm on COM5 115200,8,N,1 ---
--- Quit: Ctrl+C | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H ---
ets Jun 8 2016 00:22:57
rst:0x1 (POWERON_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
configsip: 0, SPIWP:0x00
clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
mode:DIO, clock div:2
load:0x3fff0008,len:8
load:0x3fff0010,len:3480
load:0x40078000,len:7804
ho 0 tail 12 room 4
load:0x40080000,len:252
entry 0x40080034
␛[0;32mI (45) boot: ESP-IDF v2.0-3-gbef9896 2nd stage bootloader␛[0m
␛[0;32mI (45) boot: compile time 05:59:45␛[0m
␛[0;32mI (45) boot: Enabling RNG early entropy source...␛[0m
␛[0;32mI (64) boot: SPI Speed : 40MHz␛[0m
␛[0;32mI (77) boot: SPI Mode : DIO␛[0m
␛[0;32mI (89) boot: SPI Flash Size : 4MB␛[0m
␛[0;32mI (101) boot: Partition Table:␛[0m
␛[0;32mI (113) boot: ## Label Usage Type ST Offset Length␛[0m
␛[0;32mI (136) boot: 0 phy_init RF data 01 01 0000f000 00001000␛[0m
␛[0;32mI (159) boot: 1 otadata OTA data 01 00 00010000 00002000␛[0m
␛[0;32mI (182) boot: 2 nvs WiFi data 01 02 00012000 0000e000␛[0m
␛[0;32mI (205) boot: 3 at_customize unknown 40 00 00020000 000e0000␛[0m
␛[0;32mI (228) boot: 4 ota_0 OTA app 00 10 00100000 00180000␛[0m
␛[0;32mI (252) boot: 5 ota_1 OTA app 00 11 00280000 00180000␛[0m
␛[0;32mI (275) boot: End of partition table␛[0m
␛[0;32mI (288) boot: Disabling RNG early entropy source...␛[0m
␛[0;32mI (305) boot: Loading app partition at offset 00100000␛[0m
␛[0;32mI (1481) boot: segment 0: paddr=0x00100018 vaddr=0x00000000 size=0x0ffe8 ( 65512) ␛[0m
␛[0;32mI (1481) boot: segment 1: paddr=0x00110008 vaddr=0x3f400010 size=0x1c5f0 (116208) map␛[0m
␛[0;32mI (1498) boot: segment 2: paddr=0x0012c600 vaddr=0x3ffb0000 size=0x0215c ( 8540) load␛[0m
␛[0;32mI (1528) boot: segment 3: paddr=0x0012e764 vaddr=0x40080000 size=0x00400 ( 1024) load␛[0m
␛[0;32mI (1551) boot: segment 4: paddr=0x0012eb6c vaddr=0x40080400 size=0x1b028 (110632) load␛[0m
␛[0;32mI (1630) boot: segment 5: paddr=0x00149b9c vaddr=0x400c0000 size=0x00034 ( 52) load␛[0m
␛[0;32mI (1631) boot: segment 6: paddr=0x00149bd8 vaddr=0x00000000 size=0x06430 ( 25648) ␛[0m
␛[0;32mI (1648) boot: segment 7: paddr=0x00150010 vaddr=0x400d0018 size=0x7a56c (501100) map␛[0m
␛[0;32mI (1675) heap_alloc_caps: Initializing. RAM available for dynamic allocation:␛[0m
␛[0;32mI (1697) heap_alloc_caps: At 3FFBA6B8 len 00025948 (150 KiB): DRAM␛[0m
␛[0;32mI (1719) heap_alloc_caps: At 3FFE8000 len 00018000 (96 KiB): D/IRAM␛[0m
␛[0;32mI (1740) heap_alloc_caps: At 4009B428 len 00004BD8 (18 KiB): IRAM␛[0m
␛[0;32mI (1761) cpu_start: Pro cpu up.␛[0m
␛[0;32mI (1773) cpu_start: Single core mode␛[0m
␛[0;32mI (1786) cpu_start: Pro cpu start user code␛[0m
␛[0;32mI (1846) cpu_start: Starting scheduler on PRO CPU.␛[0m
␛[0;32mI (2085) uart: queue free spaces: 10␛[0m
Bin version:0.10.0
I (2087) wifi: wifi firmware version: c604573
I (2087) wifi: config NVS flash: enabled
I (2088) wifi: config nano formating: disabled
I (2096) wifi: Init dynamic tx buffer num: 32
I (2097) wifi: wifi driver task: 3ffc4eac, prio:23, stack:3584
I (2102) wifi: Init static rx buffer num: 10
I (2106) wifi: Init dynamic rx buffer num: 0
I (2110) wifi: Init rx ampdu len mblock:7
I (2114) wifi: Init lldesc rx ampdu entry mblock:4
I (2118) wifi: wifi power manager task: 0x3ffca254 prio: 21 stack: 2560
I (2125) wifi: wifi timer task: 3ffcb2d4, prio:22, stack:3584
␛[0;31mE (2130) phy_init: PHY data partition validated␛[0m
␛[0;32mI (2152) phy: phy_version: 329, Feb 22 2017, 15:58:07, 0, 0␛[0m
I (2152) wifi: mode : softAP (3c:71:bf:10:66:e1)
I (2155) wifi: mode : sta (3c:71:bf:10:66:e0) + softAP (3c:71:bf:10:66:e1)
I (2159) wifi: mode : softAP (3c:71:bf:10:66:e1)
为了烧录固件,我使用 Microsoft VS Code 加 PlatformIO。
开始烧录之前,我得先配置 PlatformIO 环境。这一点我在 这里描述过,在项目开发那一周。
然后我按住 GPIO0 按钮并按下复位,进入固件烧录模式。接着点击烧录固件的图标,它就开始烧录。成功了!
调试电路板。
最后我把所有元件都拆了,直接检查微控制器产生的信号。
调试的结果是:我发现代码、微控制器的焊接以及附近的走线都没有问题。
我发现的是:我需要在信号和每个光耦的阳极(1 号引脚)之间加 220 欧姆的电阻。原来光耦内部有一个 LED,驱动它的电流必须限流。

于是 R=(3.3V-1.2V)/20mA=105ohms,或者

然后我切断了走线,焊上 220 欧姆的电阻,因为我手头只有这个,结果也能用,但正确的值是 120 欧姆,正如在线电阻计算器所示。

继电器的原理图就是这样的:

我的电路板上有两种我可以描述的通信。
手机和 ESP32 之间的网络靠几个库协同工作:
它的文档这样描述:
“它使用 ESP32 内置的 WiFi 实现网络连接(本地和互联网)。有了这个库,你可以启动服务器、客户端,并通过 WiFi 发送/接收 UDP 数据包。该扩展板可以连接开放或加密的网络(WEP、WPA)。IP 地址可以静态分配,也可以通过 DHCP 分配。这个库还可以管理 DNS。”
它的文档这样描述:
“这是一个完全异步的 TCP 库,旨在为 Espressif 的 ESP32 微控制器提供无忧的多连接网络环境。这个库是 ESPAsyncWebServer 的基础”
它的文档这样描述:
“用于 ESP8266 Arduino 的异步 HTTP 和 WebSocket 服务器”
所以基本上,在我的项目里,这些库负责建立与路由器的网络连接,也负责创建一个接入点。
它们还会创建一个网络服务器和一个 DHCP 服务器,这样如果有人通过下面这段代码创建的接入点连接进来,它就会分配一个 IP 地址:
//Soft Wifi Access point setup
WiFi.softAP("softap", "password_here");
IPAddress IP = WiFi.softAPIP();
与路由器的连接用下面这段代码建立:
//start wifi sessions as a client.
//Wifi client setup
const char* ssid = doc["data"]["ssid"];
const char* password = doc["data"]["pass"];
ssid = "fabfarm";
password = "password_here";
WiFi.begin(ssid, password);
警告: 从 JSON 读取的 ssid 和 password 会立刻被接下来两行里的固定值覆盖,所以 JSON 文件里的凭据永远不会被使用。如果你想让 JSON 里的值生效,就删掉那两处固定赋值。
网络服务器用下面这段代码启动:AsyncWebServer server(81);
JSON 是我为 网络界面和微控制器之间的通信选择的格式。
文档对 ArduinoJson.h 的描述是:
ArduinoJson 是一个用于 Arduino 和 IoT(物联网)的 C++ JSON 库
一个很好的文档来源,也是创建解析程序 deserializeJson() 和序列化程序 serializeJson(doc, Serial) 的好助手,就是 ArduinoJson 网站。
为了使用这个库,我先得创建一个 JSON 文件。助手会用这个文件来生成 C++ 代码。
手动创建好 JSON 文件后,就该写 C++ 程序了。助手会生成 C++ 代码,用于序列化,以及把数据解析到文件系统里的 JSON 文件、再从那里解析回来。

在左边的对话框里,我粘贴了 JSON 文件的内容。

网站会自动生成代码。

下面展示助手生成的代码。
const size_t capacity = JSON_ARRAY_SIZE(1) + JSON_ARRAY_SIZE(2) + 2*JSON_ARRAY_SIZE(3) + JSON_OBJECT_SIZE(2) + 6*JSON_OBJECT_SIZE(4) + JSON_OBJECT_SIZE(5) + 3*JSON_OBJECT_SIZE(6) + 540;
DynamicJsonDocument doc(capacity);
const char* json = "{\"data\":{\"currentTime\":\"Wednesday, June 24 2020 23:49:23\",\"temperature\":\"22.30\",\"humidity\":\"74.00\",\"override\":0,\"ssid\":\"rato\",\"pass\":\"imakestuff\"},\"relays\":[{\"name\":\"Fruit Tree\",\"pin\":25,\"isRunning\":1,\"isEnabled\":0,\"times\":[{\"startTime\":\"01:01\",\"duration\":0,\"hour\":1,\"min\":1}]},{\"name\":\"Vegie Garden\",\"pin\":26,\"isRunning\":1,\"isEnabled\":0,\"status\":1,\"times\":[{\"startTime\":\"02:02\",\"duration\":32,\"hour\":2,\"min\":2},{\"startTime\":\"02:02\",\"duration\":32,\"hour\":2,\"min\":2}]},{\"name\":\"Cypress Hill\",\"pin\":33,\"isRunning\":1,\"isEnabled\":0,\"status\":1,\"times\":[{\"startTime\":\"03:03\",\"duration\":60,\"hour\":3,\"min\":3},{\"startTime\":\"03:03\",\"duration\":60,\"hour\":3,\"min\":3},{\"startTime\":\"03:03\",\"duration\":60,\"hour\":3,\"min\":3}]}]}";
deserializeJson(doc, json);
JsonObject data = doc["data"];
const char* data_currentTime = data["currentTime"]; // "Wednesday, June 24 2020 23:49:23"
const char* data_temperature = data["temperature"]; // "22.30"
const char* data_humidity = data["humidity"]; // "74.00"
int data_override = data["override"]; // 0
const char* data_ssid = data["ssid"]; // "rato"
const char* data_pass = data["pass"]; // "imakestuff"
JsonArray relays = doc["relays"];
JsonObject relays_0 = relays[0];
const char* relays_0_name = relays_0["name"]; // "Fruit Tree"
int relays_0_pin = relays_0["pin"]; // 25
int relays_0_isRunning = relays_0["isRunning"]; // 1
int relays_0_isEnabled = relays_0["isEnabled"]; // 0
JsonObject relays_0_times_0 = relays_0["times"][0];
const char* relays_0_times_0_startTime = relays_0_times_0["startTime"]; // "01:01"
int relays_0_times_0_duration = relays_0_times_0["duration"]; // 0
int relays_0_times_0_hour = relays_0_times_0["hour"]; // 1
int relays_0_times_0_min = relays_0_times_0["min"]; // 1
JsonObject relays_1 = relays[1];
const char* relays_1_name = relays_1["name"]; // "Vegie Garden"
int relays_1_pin = relays_1["pin"]; // 26
int relays_1_isRunning = relays_1["isRunning"]; // 1
int relays_1_isEnabled = relays_1["isEnabled"]; // 0
int relays_1_status = relays_1["status"]; // 1
JsonObject relays_1_times_0 = relays_1["times"][0];
const char* relays_1_times_0_startTime = relays_1_times_0["startTime"]; // "02:02"
int relays_1_times_0_duration = relays_1_times_0["duration"]; // 32
int relays_1_times_0_hour = relays_1_times_0["hour"]; // 2
int relays_1_times_0_min = relays_1_times_0["min"]; // 2
JsonObject relays_1_times_1 = relays_1["times"][1];
const char* relays_1_times_1_startTime = relays_1_times_1["startTime"]; // "02:02"
int relays_1_times_1_duration = relays_1_times_1["duration"]; // 32
int relays_1_times_1_hour = relays_1_times_1["hour"]; // 2
int relays_1_times_1_min = relays_1_times_1["min"]; // 2
JsonObject relays_2 = relays[2];
const char* relays_2_name = relays_2["name"]; // "Cypress Hill"
int relays_2_pin = relays_2["pin"]; // 33
int relays_2_isRunning = relays_2["isRunning"]; // 1
int relays_2_isEnabled = relays_2["isEnabled"]; // 0
int relays_2_status = relays_2["status"]; // 1
JsonArray relays_2_times = relays_2["times"];
JsonObject relays_2_times_0 = relays_2_times[0];
const char* relays_2_times_0_startTime = relays_2_times_0["startTime"]; // "03:03"
int relays_2_times_0_duration = relays_2_times_0["duration"]; // 60
int relays_2_times_0_hour = relays_2_times_0["hour"]; // 3
int relays_2_times_0_min = relays_2_times_0["min"]; // 3
JsonObject relays_2_times_1 = relays_2_times[1];
const char* relays_2_times_1_startTime = relays_2_times_1["startTime"]; // "03:03"
int relays_2_times_1_duration = relays_2_times_1["duration"]; // 60
int relays_2_times_1_hour = relays_2_times_1["hour"]; // 3
int relays_2_times_1_min = relays_2_times_1["min"]; // 3
JsonObject relays_2_times_2 = relays_2_times[2];
const char* relays_2_times_2_startTime = relays_2_times_2["startTime"]; // "03:03"
int relays_2_times_2_duration = relays_2_times_2["duration"]; // 60
int relays_2_times_2_hour = relays_2_times_2["hour"]; // 3
int relays_2_times_2_min = relays_2_times_2["min"]; // 3
const size_t capacity = JSON_ARRAY_SIZE(1) + JSON_ARRAY_SIZE(2) + 2*JSON_ARRAY_SIZE(3) + JSON_OBJECT_SIZE(2) + 6*JSON_OBJECT_SIZE(4) + JSON_OBJECT_SIZE(5) + 3*JSON_OBJECT_SIZE(6);
DynamicJsonDocument doc(capacity);
JsonObject data = doc.createNestedObject("data");
data["currentTime"] = "Wednesday, June 24 2020 23:49:23";
data["temperature"] = "22.30";
data["humidity"] = "74.00";
data["override"] = 0;
data["ssid"] = "rato";
data["pass"] = "imakestuff";
JsonArray relays = doc.createNestedArray("relays");
JsonObject relays_0 = relays.createNestedObject();
relays_0["name"] = "Fruit Tree";
relays_0["pin"] = 25;
relays_0["isRunning"] = 1;
relays_0["isEnabled"] = 0;
JsonArray relays_0_times = relays_0.createNestedArray("times");
JsonObject relays_0_times_0 = relays_0_times.createNestedObject();
relays_0_times_0["startTime"] = "01:01";
relays_0_times_0["duration"] = 0;
relays_0_times_0["hour"] = 1;
relays_0_times_0["min"] = 1;
JsonObject relays_1 = relays.createNestedObject();
relays_1["name"] = "Vegie Garden";
relays_1["pin"] = 26;
relays_1["isRunning"] = 1;
relays_1["isEnabled"] = 0;
relays_1["status"] = 1;
JsonArray relays_1_times = relays_1.createNestedArray("times");
JsonObject relays_1_times_0 = relays_1_times.createNestedObject();
relays_1_times_0["startTime"] = "02:02";
relays_1_times_0["duration"] = 32;
relays_1_times_0["hour"] = 2;
relays_1_times_0["min"] = 2;
JsonObject relays_1_times_1 = relays_1_times.createNestedObject();
relays_1_times_1["startTime"] = "02:02";
relays_1_times_1["duration"] = 32;
relays_1_times_1["hour"] = 2;
relays_1_times_1["min"] = 2;
JsonObject relays_2 = relays.createNestedObject();
relays_2["name"] = "Cypress Hill";
relays_2["pin"] = 33;
relays_2["isRunning"] = 1;
relays_2["isEnabled"] = 0;
relays_2["status"] = 1;
JsonArray relays_2_times = relays_2.createNestedArray("times");
JsonObject relays_2_times_0 = relays_2_times.createNestedObject();
relays_2_times_0["startTime"] = "03:03";
relays_2_times_0["duration"] = 60;
relays_2_times_0["hour"] = 3;
relays_2_times_0["min"] = 3;
JsonObject relays_2_times_1 = relays_2_times.createNestedObject();
relays_2_times_1["startTime"] = "03:03";
relays_2_times_1["duration"] = 60;
relays_2_times_1["hour"] = 3;
relays_2_times_1["min"] = 3;
JsonObject relays_2_times_2 = relays_2_times.createNestedObject();
relays_2_times_2["startTime"] = "03:03";
relays_2_times_2["duration"] = 60;
relays_2_times_2["hour"] = 3;
relays_2_times_2["min"] = 3;
serializeJson(doc, Serial);
在我朋友 Jeff Knight 的帮助下(没有他我一个人做不到),生成的代码为我的程序做了简化。然后我写了几个函数,下图是串口监视器打印读取到的 JSON 文件反序列化结果。

我画了这张图,免得忘记它怎么工作:

最后,它是这样工作的: