1. 项目概述:一个为微控制器打造的荷兰公共交通信息显示器
作为一名长期捣鼓智能硬件和物联网项目的开发者,我经常琢磨着怎么把网络上的实时信息“搬”到身边的物理设备上。最近,我完成了一个挺有意思的小项目:用一块ESP32开发板,配合一个简单的库,制作了一个能实时显示荷兰公共交通信息的桌面小工具。这个项目的核心,就是通过调用荷兰知名的公共交通查询服务“9292”的API,获取公交车、火车、电车的实时出发时间,然后在一块小屏幕上清晰地展示出来。
想象一下,你正在书桌前工作或学习,手边放着一个巴掌大的小设备,上面清晰地滚动显示着离家最近的电车站下一班车还有几分钟到站,或者去往市中心方向的火车是否准点。这比频繁掏出手机打开App要方便和优雅得多。这个项目非常适合那些对物联网开发、API调用和嵌入式显示感兴趣的朋友,无论你是想学习如何让微控制器“上网”获取数据,还是想亲手打造一个个性化的信息终端,都能从中获得乐趣和实用的知识。接下来,我就把这个项目的完整思路、实现细节以及我踩过的坑,毫无保留地分享给你。
2. 核心思路与方案选型解析
2.1 为什么选择“9292”API与ESP32?
在荷兰,查询公共交通信息最权威、数据最全面的服务之一就是“9292”。它整合了全国几乎所有的火车、巴士、电车和地铁的实时时刻表与运行状态。因此,选择它的API作为数据源,能保证信息的准确性和完整性。对于硬件平台,我选择了乐鑫的ESP32。原因有三点:首先,它内置了Wi-Fi和蓝牙模块,联网能力强大且稳定,这是实现实时数据获取的基础;其次,其计算能力和内存(通常有520KB SRAM)足以处理HTTP请求和JSON解析这类任务;最后,ESP32拥有庞大的社区和丰富的库支持,开发调试非常方便。当然,这个项目的代码经过轻微适配,同样可以在更经济但资源稍紧的ESP8266上运行。
整个系统的数据流非常清晰:ESP32通过Wi-Fi连接到家庭路由器,然后向“9292”的API服务器发起一个结构化的HTTP GET请求。服务器验证请求后,会返回一个包含所需公共交通信息的JSON格式数据包。ESP32再解析这个JSON,提取出我们关心的关键字段,例如“下一班车的出发时间”、“目的地”、“站台号”等。最后,这些信息被格式化后发送到连接的显示屏上进行展示。这个流程涉及嵌入式网络编程、API接口调用、数据解析和显示驱动等多个环节,是一个典型的物联网应用。
2.2 硬件与软件生态的考量
在硬件搭配上,除了主控ESP32,另一个关键部件是显示屏。为了兼顾易用性和显示效果,我选择了常见的SSD1306驱动的0.96英寸OLED屏。这种屏幕是I2C接口,只需要连接ESP32的两根GPIO口(SCL和SDA)以及电源和地线即可,接线简单,且库支持完善。它的分辨率是128x64像素,单色显示,功耗极低,非常适合显示几行文本信息。
在软件层面,我选择使用Arduino框架进行开发。虽然ESP32也支持MicroPython或ESP-IDF原生开发,但Arduino生态对于快速原型开发来说无比友好。它有海量的第三方库,对于HTTP请求、JSON解析、OLED显示都有成熟稳定的库可用,能极大降低开发门槛。我编写的这个“9292”库,本质上就是对底层HTTPClient和ArduinoJson等库进行了一层封装,提供了一个更专注于公共交通信息查询的、更简洁的接口。这样,使用者就不需要关心网络连接、请求构建、JSON解析的繁琐细节,只需调用几个简单的函数就能获取到结构化的交通数据。
3. 核心库设计与API调用详解
3.1 封装库的关键结构设计
为了让这个工具好用,我设计了一个简单的C++类来封装所有功能。这个类的核心目标是:隐藏复杂性,提供清晰的接口。类的设计主要围绕以下几个成员变量和成员函数展开:
首先,需要存储一些配置信息,比如Wi-Fi的SSID和密码、API请求的端点(URL)以及目标车站的代码。车站代码是“9292”系统内部用于唯一标识一个车站的字符串,通常可以在其官网或App上查到。其次,需要网络客户端对象(如WiFiClient和HTTPClient)来负责实际的网络通信。最后,还需要一个JSON解析器对象(来自ArduinoJson库)来处理服务器返回的数据。
类的核心公共方法可能包括:
begin(): 初始化库,连接Wi-Fi。setStation(“station_code”): 设置要查询的车站。fetchDepartures(): 执行获取出发时刻表的操作,这是最核心的函数。getNextDeparture(int index): 获取解析后,列表中第index个车次的信息(如时间、目的地、类型)。isConnected(): 返回Wi-Fi连接状态。
在fetchDepartures()函数内部,发生了很多事情。它首先检查网络连接,然后按照“9292”API的文档要求,构造一个完整的HTTP请求URL。这个URL通常包含基础地址、车站代码、查询参数(如返回结果数量、交通类型过滤等)。接着,它使用HTTPClient发起GET请求,并检查服务器返回的状态码(例如200表示成功)。如果成功,就将返回的JSON字符串传递给ArduinoJson库进行解析。
3.2 JSON数据解析与信息提取
“9292”API返回的JSON结构可能比较复杂,嵌套多层。我们的目标是从中提取出最简洁有用的信息。通常,在“departures”或类似的数组字段中,包含了未来一段时间内所有车次的信息。每个车次是一个对象,里面可能有plannedDateTime(计划时间)、actualDateTime(实际时间,用于判断是否延误)、direction(方向/目的地)、product的shortCategoryName(交通工具类型,如“IC”代表城际快车,“BUS”代表公交)等字段。
解析的关键是使用ArduinoJson库正确反序列化这个JSON结构。我们需要预先定义一个足够大的JsonDocument(如StaticJsonDocument<2048>)来容纳数据,然后使用deserializeJson()函数。解析成功后,就可以像访问对象属性一样,用C++代码遍历departures数组,读取每一个车次对象的字段,并将这些信息存储到我们自定义的、更简单的结构体数组中,供后续显示函数使用。这个过程需要仔细对照API文档,确保字段路径正确,否则很容易解析失败得到空数据。
注意:JSON文档的大小预估非常重要。如果预留的
JsonDocument内存太小,会导致解析失败或数据截断。我建议一开始可以设置一个大一点的值(比如3000字节),解析成功后打印出文档的实际大小(measureJson()),然后再调整到一个更精确、节省内存的值。
4. 完整硬件搭建与软件实现流程
4.1 硬件连接与电路准备
让我们从零开始把硬件搭起来。你需要准备以下材料:
- ESP32开发板(如NodeMCU-32S或ESP32 DevKitC)一块。
- 0.96英寸 I2C接口的OLED显示屏(SSD1306驱动)一块。
- 杜邦线(母对母)若干。
- 微型USB数据线一根,用于供电和编程。
接线非常简单,遵循I2C的标准接法:
- 将OLED屏的
VCC引脚连接到ESP32的3.3V引脚。 - 将OLED屏的
GND引脚连接到ESP32的任一GND引脚。 - 将OLED屏的
SCL(时钟线)引脚连接到ESP32的GPIO22(这是ESP32上常用的I2C SCL引脚)。 - 将OLED屏的
SDA(数据线)引脚连接到ESP32的GPIO21(这是ESP32上常用的I2C SDA引脚)。
有些OLED模块可能还带有RESET引脚,如果存在,可以将其连接到ESP32的一个空闲GPIO(如GPIO16),并在代码中初始化时指定;如果不需要,通常也可以悬空。接好线后,用USB线将ESP32连接到电脑,硬件部分就准备好了。
4.2 软件环境配置与库安装
在电脑上,你需要安装Arduino IDE(或更现代的VS Code + PlatformIO插件)。这里以Arduino IDE为例:
- 打开Arduino IDE,进入“文件”->“首选项”,在“附加开发板管理器网址”中添加ESP32的板支持网址:
https://espressif.github.io/arduino-esp32/package_esp32_index.json。 - 打开“工具”->“开发板”->“开发板管理器”,搜索“esp32”,找到并安装“Espressif Systems”提供的ESP32开发板包。
- 安装必要的库。打开“工具”->“管理库”,分别搜索并安装:
WiFi(通常已内置)HTTPClient(通常已内置)ArduinoJson(by Benoit Blanchon, 选择较新版本如6.x)Adafruit SSD1306(用于驱动OLED屏)Adafruit GFX(图形库,SSD1306依赖它)
安装完成后,在“工具”菜单下选择正确的开发板(如“ESP32 Dev Module”),并选择对应的端口。
4.3 核心代码实现与分步解读
接下来是软件部分的核心。我将创建一个新的Arduino项目(.ino文件),并逐步添加代码。首先,必须包含所有必要的头文件:
#include <WiFi.h> #include <HTTPClient.h> #include <ArduinoJson.h> #include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h>然后,定义网络凭证和API相关常量。请务必将下面的ssid、password和stationCode替换成你自己的信息。stationCode需要你去9292官网查询。
// 你的Wi-Fi配置 const char* ssid = "你的Wi-Fi名称"; const char* password = "你的Wi-Fi密码"; // 9292 API 配置 (此为示例,实际端点请参考最新API文档) const char* apiBaseUrl = "https://api.9292.nl/0.1/departures"; const char* stationCode = "YOUR_STATION_CODE"; // 例如 "asd" 代表 Amsterdam Centraal const int maxResults = 5; // 每次查询返回的最大车次数量接着,初始化OLED显示屏对象。定义屏幕尺寸,并指定I2C地址(通常是0x3C)。
#define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 // 如果屏幕有RESET引脚,则指定GPIO号 Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);现在,我们可以编写setup()函数,它只在设备启动时运行一次:
void setup() { Serial.begin(115200); // 初始化串口,用于调试输出 delay(1000); // 1. 初始化显示屏 if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { Serial.println(F("SSD1306分配失败")); for(;;); // 卡死 } display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0,0); display.println("正在启动..."); display.display(); delay(2000); // 2. 连接Wi-Fi display.clearDisplay(); display.setCursor(0,0); display.print("连接Wi-Fi: "); display.println(ssid); display.display(); WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); display.print("."); display.display(); } Serial.println("\nWi-Fi已连接!"); display.clearDisplay(); display.setCursor(0,0); display.println("Wi-Fi OK!"); display.display(); delay(1000); }最核心的部分在loop()函数中,它将周期性地执行数据获取和显示。为了不让服务器压力过大,也为了节省设备电量,我们设置一个查询间隔,比如每30秒更新一次。
void loop() { // 检查Wi-Fi连接 if (WiFi.status() == WL_CONNECTED) { String departureInfo = fetchAndParseDepartures(); displayDepartures(departureInfo); } else { Serial.println("Wi-Fi断开,尝试重连..."); display.clearDisplay(); display.setCursor(0,0); display.println("Wi-Fi断开"); display.display(); WiFi.reconnect(); } delay(30000); // 等待30秒后再次查询 }上面的代码调用了两个关键的自定义函数:fetchAndParseDepartures()和displayDepartures()。fetchAndParseDepartures()函数负责与API交互并解析数据:
String fetchAndParseDepartures() { HTTPClient http; String payload = ""; // 存储服务器返回的原始JSON String result = ""; // 存储我们格式化后的文本信息 // 构建完整的请求URL,包含车站代码和结果数量参数 String url = String(apiBaseUrl) + "?lang=nl-NL&station=" + stationCode + "&maxJourneys=" + String(maxResults); http.begin(url); // 指定请求地址 int httpCode = http.GET(); // 发起GET请求 if (httpCode == HTTP_CODE_OK) { // 如果返回200 OK payload = http.getString(); // 获取响应内容 Serial.println(payload); // 打印到串口监视器,用于调试 // 开始解析JSON DynamicJsonDocument doc(2048); // 根据响应大小调整,此处先给2048字节 DeserializationError error = deserializeJson(doc, payload); if (error) { Serial.print(F("JSON解析失败: ")); Serial.println(error.f_str()); result = "解析错误"; } else { // 提取“departures”数组 JsonArray departures = doc["departures"].as<JsonArray>(); int count = 0; for (JsonObject dep : departures) { if (count >= maxResults) break; String time = dep["plannedDateTime"].as<String>().substring(11, 16); // 提取"HH:MM" String destination = dep["direction"].as<String>(); String transport = dep["product"]["shortCategoryName"].as<String>(); // 格式化一行信息,例如: “14:05 IC Amsterdam Centraal” result += time + " " + transport + " " + destination + "\n"; count++; } if (count == 0) { result = "无车次信息"; } } } else { Serial.printf("HTTP请求失败,错误码: %d\n", httpCode); result = "请求失败"; } http.end(); // 释放资源 return result; }最后,displayDepartures()函数负责将格式化好的文本显示在OLED屏幕上:
void displayDepartures(String info) { display.clearDisplay(); display.setCursor(0, 0); display.println("=== 出发时刻 ==="); // 显示一个标题 display.println(""); // 空一行 display.println(info); // 显示解析后的车次信息 display.display(); }将以上所有代码段组合成一个完整的.ino文件,编译并上传到你的ESP32。打开串口监视器(波特率115200),你应该能看到Wi-Fi连接日志和原始的JSON数据。同时,OLED屏幕上应该会显示出从你设定的车站出发的接下来几班车的时刻、类型和目的地。
5. 深度优化、问题排查与经验分享
5.1 性能优化与稳定性提升
在实际运行中,你可能会发现一些可以改进的地方。首先是网络稳定性。家庭Wi-Fi偶尔会波动,简单的while循环等待连接在setup()中可行,但在loop()中如果断开,程序会卡住。更好的做法是使用非阻塞的重连逻辑,并设置一个超时机制。例如,在loop()开头检查连接状态,如果断开,则记录一个时间戳并尝试重连,同时继续更新屏幕显示“重连中...”,而不是傻等。
其次是内存管理。在fetchAndParseDepartures()函数中,我们使用了DynamicJsonDocument。在长期运行的项目中,频繁创建和销毁可能会产生内存碎片。一个优化方案是将其声明为全局变量,或者使用StaticJsonDocument并精确计算所需大小(使用ArduinoJson辅助工具https://arduinojson.org/v6/assistant/)。此外,字符串拼接(String类)在嵌入式环境中也可能导致内存碎片,对于显示内容固定的部分,可以考虑使用字符数组(char[])和snprintf函数进行格式化。
第三是显示效果的优化。当前的显示比较简陋。我们可以利用Adafruit_GFX库的功能,绘制更丰富的界面,比如用不同大小的字体显示时间和目的地,用图标代替“BUS”、“IC”等文字,或者添加一个实时更新的时钟在角落。还可以实现自动滚屏,如果车次信息超过一屏,就每隔几秒滚动一行。
5.2 常见问题与故障排除实录
在开发和使用过程中,我遇到了不少典型问题,这里整理成一个速查表,希望能帮你快速排雷:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 编译错误:找不到库 | 库未正确安装或包含路径错误。 | 1. 在Arduino IDE的“管理库”中确认已安装ArduinoJson,Adafruit SSD1306等。2. 检查 #include语句拼写是否正确。3. 尝试重启Arduino IDE。 |
| 上传失败 | 开发板型号或端口选择错误;驱动未安装。 | 1. “工具”->“开发板”确认选择正确的ESP32型号。 2. “工具”->“端口”选择正确的COM口(连接ESP32后会出现)。 3. 如果是首次使用,可能需要安装CP210x或CH340等USB转串口芯片驱动。 |
| OLED屏幕不亮或白屏 | 接线错误;I2C地址不对;电源问题。 | 1. 反复检查VCC、GND、SCL、SDA四根线是否接对、接牢。 2. 尝试修改 display.begin()中的I2C地址,常见的是0x3C或0x3D,可用I2C扫描程序确认。3. 确保使用3.3V供电,5V可能会烧毁屏幕。 |
| 串口显示“Wi-Fi连接失败” | Wi-Fi密码错误;信号太弱;路由器设置限制。 | 1. 检查代码中的ssid和password。2. 将设备靠近路由器。 3. 检查路由器是否开启了MAC地址过滤等安全设置,暂时关闭或添加ESP32的MAC地址到白名单。 |
| 串口收到HTTP错误码(如404,403) | API请求URL构造错误;API端点变更;缺少必要参数或API密钥。 | 1. 将代码中构建的完整url打印到串口,复制到电脑浏览器中访问,看是否返回有效JSON。2. 仔细查阅“9292”最新的官方API文档,确认端点地址、参数名和格式。 3. 某些API可能需要密钥(API Key),请检查文档并申请添加。 |
| JSON解析失败 | 返回的数据不是合法JSON;JsonDocument内存分配不足。 | 1. 将payload(原始JSON字符串)完整打印到串口,复制到在线JSON校验工具检查格式。2. 增大 DynamicJsonDocument doc(大小)中的内存大小,直到解析成功,再根据实际需求微调。 |
| 显示内容错乱或重叠 | 屏幕未清屏;文本超出屏幕范围。 | 1. 确保在每次更新显示前都调用了display.clearDisplay()。2. 计算好每行文本的起始位置( setCursor),确保不会超出SCREEN_WIDTH和SCREEN_HEIGHT。 |
| 设备运行一段时间后重启 | 内存泄漏;看门狗定时器超时。 | 1. 检查是否有动态内存分配(如String拼接)在循环中无限增长,优化代码。2. 如果单次 loop()执行时间过长(如网络请求超时),会导致看门狗复位。可以增加delay或在耗时操作中调用yield()/delay(0)。 |
5.3 项目扩展思路与个人心得
这个基础项目有非常多的扩展可能性。你可以增加一个物理按钮,用来切换查询不同的车站(比如家和公司)。你可以添加一个光敏电阻,让屏幕亮度在夜晚自动调暗。更进一步,可以结合家庭自动化平台(如Home Assistant),将公交信息作为一个传感器实体,在电视大屏或手机通知上显示。甚至可以做一个小型“信息亭”,用电子墨水屏(e-ink)显示,功耗极低,只需电池供电。
从我个人的实操经验来看,这类物联网项目成功的关键在于“分而治之”和“迭代测试”。不要试图一次性写完所有代码。先确保Wi-Fi能连上(用串口打印IP地址测试)。再单独测试HTTP请求,把返回的JSON打印出来看看。然后单独测试JSON解析,把解析出的字段打印出来。最后再测试显示部分,用固定的字符串看显示是否正常。每一步都稳了,再把它们组合起来。另外,一定要善用串口打印调试信息,这是嵌入式开发的“眼睛”。最后,给设备一个稳定的5V电源(比如手机充电器)比一直连着电脑USB更可靠,尤其是当你准备把它装进一个外壳长期运行时。这个小项目虽然简单,但它串联了嵌入式开发中最实用的几个技能点,玩透了,你就能打开物联网世界的大门。