如何在ESP32 Arduino框架下生成适合和风天气的JWT

观前提示:我只是一个 ESP32 萌新,这些代码是我和 DeepSeek 一起写出来的,所以代码烂是正常的。
另外,我用的是 PlatformIO,所以本教程对于 Arduino IDE 有些方法可能不适用。

注:此章会使用上一章节的完整demo。如果没看,点我跳转,找到下方「完整demo」章节,但最好看完

完整源码在下方「jwt_ed25519.h 代码总览」和「main.cpp 代码总览」章节,建议先看完讲解再复制使用。

导言

何为JWT?

搬运原文
JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。 从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。

JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。

可以看出,JWT 更符合设计 RESTful API 时的「Stateless(无状态)」原则 。

如果客户端把 JWT 作为 Bearer Token 显式放入 Authorization Header,浏览器不会像 Cookie 那样自动附带它,因此可以降低传统 CSRF 风险。不过,这取决于凭据的传输和存储方式,而不是 JWT 格式本身;如果把 JWT 放在 Cookie 中,仍然需要 CSRF 防护。

我在 JWT 优缺点分析这篇文章中有详细介绍到使用 JWT 做身份认证的优势和劣势。

下面是 RFC 7519 对 JWT 做的较为正式的定义。

JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. ——JSON Web Token (JWT)

译文(机翻,仅供参考):

JSON Web Token(JWT)是一种紧凑且URL安全的表示声明的方式,用于在两方之间传输。JWT中的声明以JSON对象形式编码,该对象可作为JSON Web Signature(JWS)结构的有效载荷或JSON Web Encryption(JWE)结构的明文,从而实现声明的数字签名或通过消息认证码(MAC)进行完整性保护,以及加密处理。——JSON Web Token (JWT)

简单来说,JWT 对比传统 API KEY 更加安全。

为什么要使用 JWT 而不直接使用 API KEY?

首先,正如上文所说,JWT更加安全。其次,和风天气在 2024-10-30 发布公告,即将在 2027年2月1日 开始限制使用API KEY(包括基于API KEY的数字签名)的 每日 请求量。这一限制措施可以确保入侵者即使获取到了API KEY也无法在短时间内请求大量数据而对开发者的利益造成损失。

相关文档:对API KEY请求量的限制 (和风天气官方发文)

关于我自己的故事:我折腾了好几天,发现网上的教程要么是针对ESP-IDF框架,要么就是HS256加密(和风天气强制要求使用EdDSA),所以项目暂时停滞,因为不想让单片机“寄生”在我的电脑上,遂撰写此文。

相关文档:身份认证 (和风天气官方文档)

准备工作

  • ESP32开发板一块(本文使用ESP32-WROOM-32D,其他型号理论上兼容)
  • arduinolibs
  • WiFiClientSecure(自带,不用管)
  • 上篇文章的完整 demo

提示:不用去库管理器安装arduinolibs,因为压根没有

正式开始部署

注意:私钥切记不要上传或泄露,它只保存在你的 ESP32 代码中。

在网页部署请参见和风天气官方文档:
生成Ed25519密钥
上传公钥

Linux用户

首先,对于Linux用户,运行以下命令:

1
2
3
4
cd
git clone https://github.com/rweather/arduinolibs.git
cd arduinolibs/libraries/Crypto
cp -r ./*.cpp ./*.h ./utility [你的项目src目录]/lib

对于command not found: git,运行以下命令:

发行版 安装命令
Debian/Ubuntu sudo apt install git
Fedora/RHEL sudo dnf install git
Arch系 sudo pacman -S git

NixOS和FreeBSD的朋友,既然你们都知道怎么用这个系统了,git应该会装吧?

如果不知道自己的发行版是什么,在终端运行 cat /etc/os-release | grep "^ID" 查看自己的发行版。

Windows 用户

使用命令行配置

如果安装了git,请打开开始菜单,找到 Git Bash 并运行
在 Git Bash 里依次执行以下命令:

1
2
3
4
cd
git clone https://github.com/rweather/arduinolibs.git
cd arduinolibs/libraries/Crypto
cp -r ./*.cpp ./*.h ./utility [你的项目src目录]/lib

对的没错,Git Bash支持Linux语法。

如果没安装,请下载安装 Git for Windows:点这里跳转官网
具体教程请移步搜索引擎,本文不再赘述。

不想碰命令行?

  1. 打开你的项目 src 文件夹,新建一个文件夹,重命名为lib;

  2. 用浏览器打开 https://github.com/rweather/arduinolibs

  3. 点击绿色的 Code 按钮 → Download ZIP;

  4. 解压后,进入 arduinolibs-master/libraries/Crypto 文件夹;

  5. 把里面所有 .cpp、.h 文件以及 utility 文件夹,全部复制粘贴到你的项目 src 文件夹中的 lib 文件夹里,即可安装。

如果不知道项目的 src 目录在哪:用 VS Code 打开你的项目,左侧文件列表里的 src 文件夹就是。右键点击它,选择“复制路径”,然后替换命令中的 [你的项目src目录] 即可。(/lib 保留别动)

开干

jwt_ed25519.h 代码解析

既然安装好了,那就开干。不过跟上篇文章不同,这次我们要自己写一个.h头文件放在main.cpp的同级位置。

创建一个jwt_ed25519.h文件,放在 src 目录下,然后在 jwt_ed25519.h 中写下这几行:

1
2
3
4
5
6
#ifndef JWT_ED25519_H
#define JWT_ED25519_H

#include <Arduino.h> // Arduino IDE 不需要这行
#include <mbedtls/base64.h>
#include "lib/Ed25519.h" // C++ 类

#ifndef JWT_ED25519_H#define JWT_ED25519_H:防止重复包含导致编译失败。
mbedtls/base64.h:是一个关于URLbase64编解码的头文件。
lib/Ed25519.h:你看!前面安装的库就有用了,这就是EdDSA的签名要用到的玩意。由于我们安装库的时候放在了lib文件夹内,而我们写的这个头文件在src文件夹里,所以我们在前面要加一个 lib/ ,这样编译器才能正常链接文件。

接着我们写extractEd25519PrivateKey函数,用于从 PEM 提取 Ed25519 私钥:

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
inline bool extractEd25519PrivateKey(const String& pemKey, uint8_t* outPrivateKey) {
String keyData = pemKey;
keyData.replace("-----BEGIN PRIVATE KEY-----", "");
keyData.replace("-----END PRIVATE KEY-----", "");
keyData.replace("\n", "");
keyData.replace("\r", "");
keyData.trim();

if (keyData.length() == 0) {
Serial.println("[ERR] 私钥数据为空");
return false;
}

size_t decodedLen = 0;
mbedtls_base64_decode(NULL, 0, &decodedLen,
(const unsigned char*)keyData.c_str(), keyData.length());

if (decodedLen == 0) {
Serial.println("[ERR] Base64 解码失败");
return false;
}

unsigned char* decoded = (unsigned char*)malloc(decodedLen);
if (!decoded) {
Serial.println("[ERR] 内存分配失败");
return false;
}

int ret = mbedtls_base64_decode(decoded, decodedLen, &decodedLen,
(const unsigned char*)keyData.c_str(), keyData.length());

if (ret != 0) {
Serial.printf("[ERR] Base64 解码错误: %d\n", ret);
free(decoded);
return false;
}

if (decodedLen < 32) {
Serial.printf("[ERR] 解码后数据太短: %d 字节 (需要 >= 32)\n", decodedLen);
free(decoded);
return false;
}

// Ed25519 私钥在 DER 中通常是最后 32 字节
memcpy(outPrivateKey, decoded + decodedLen - 32, 32);
free(decoded);
return true;
}

代码首先对输入的 PEM 字符串做预处理:移除标记行 -----BEGIN PRIVATE KEY----------END PRIVATE KEY-----,以及换行符和回车符,保留纯 Base64 编码部分。

之后用 mbedtls_base64_decode(NULL, 0, ...) 进行第一次解码调用,目的是计算解码后的数据长度。如果长度小于 32 字节,说明数据无效,直接返回失败。

接着分配与解码长度相等的内存缓冲区,执行实际解码。解码后的数据是 ASN.1 DER 格式的私钥结构,Ed25519 私钥位于结构末尾的 32 字节。代码通过 memcpy(outPrivateKey, decoded + decodedLen - 32, 32) 提取最后 32 字节,存入输出参数。

最后释放内存并返回成功。


接着来写生成 Ed25519 JWT函数:

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
inline String generateEd25519JWT(const String& privateKeyPEM,
const String& kid,
const String& sub,
unsigned long iat,
unsigned long exp) {
// 1. 构建 Header
String header = "{";
header += "\"alg\":\"EdDSA\",";
header += "\"kid\":\"" + kid + "\"";
header += "}";

// 2. 构建 Payload
String payload = "{";
payload += "\"iat\":" + String(iat) + ",";
payload += "\"exp\":" + String(exp) + ",";
payload += "\"sub\":\"" + sub + "\"";
payload += "}";

// 3. Base64URL 编码
String headerB64 = base64UrlEncode((const uint8_t*)header.c_str(), header.length());
String payloadB64 = base64UrlEncode((const uint8_t*)payload.c_str(), payload.length());
String signingInput = headerB64 + "." + payloadB64;

// 4. 提取私钥
uint8_t privateKey[32];
if (!extractEd25519PrivateKey(privateKeyPEM, privateKey)) {
Serial.println("[ERR] 私钥提取失败");
return "";
}

// 5. 从私钥派生公钥(使用 C++ 类的静态方法)
uint8_t publicKey[32];
Ed25519::derivePublicKey(publicKey, privateKey);

// 6. 执行 Ed25519 签名(使用 C++ 类的静态方法)
uint8_t signature[64];
Ed25519::sign(signature, privateKey, publicKey,
(const uint8_t*)signingInput.c_str(),
signingInput.length());

// 7. 组合最终 JWT
String signatureB64 = base64UrlEncode(signature, 64);
return signingInput + "." + signatureB64;
}

函数接收 PEM 格式私钥、kid、sub、签发时间和过期时间,返回完整的 JWT 字符串。

执行流程分为六个阶段。第一阶段构建 JWT 头部,包含算法(EdDSA)和密钥 ID(kid),生成 JSON 格式字符串。第二阶段构建载荷,包含签发时间 iat、过期时间 exp 和主题 sub。第三阶段对头部和载荷分别进行 Base64URL 编码,并用点号拼接成待签名数据。

第四阶段调用 extractEd25519PrivateKey 从 PEM 私钥中提取 32 字节原始私钥。第五阶段调用 Ed25519::derivePublicKey 从私钥派生出 32 字节公钥。第六阶段调用 Ed25519::sign 对待签名数据进行签名,生成 64 字节签名。

最后阶段将签名进行 Base64URL 编码,拼接到待签名数据后面,用点号分隔,形成完整的 JWT 字符串并返回。

jwt_ed25519.h 代码总览

最后,写上#endifjwt_ed25519.h就写完了。

代码总览:

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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
#ifndef JWT_ED25519_H
#define JWT_ED25519_H

#include <Arduino.h> // Arduino IDE 不需要这行
#include <mbedtls/base64.h>
#include "lib/Ed25519.h" // C++ 类

// -------- 从 PEM 提取 Ed25519 私钥 --------
inline bool extractEd25519PrivateKey(const String& pemKey, uint8_t* outPrivateKey) {
String keyData = pemKey;
keyData.replace("-----BEGIN PRIVATE KEY-----", "");
keyData.replace("-----END PRIVATE KEY-----", "");
keyData.replace("\n", "");
keyData.replace("\r", "");
keyData.trim();

if (keyData.length() == 0) {
Serial.println("[ERR] 私钥数据为空");
return false;
}

size_t decodedLen = 0;
mbedtls_base64_decode(NULL, 0, &decodedLen,
(const unsigned char*)keyData.c_str(), keyData.length());

if (decodedLen == 0) {
Serial.println("[ERR] Base64 解码失败");
return false;
}

unsigned char* decoded = (unsigned char*)malloc(decodedLen);
if (!decoded) {
Serial.println("[ERR] 内存分配失败");
return false;
}

int ret = mbedtls_base64_decode(decoded, decodedLen, &decodedLen,
(const unsigned char*)keyData.c_str(), keyData.length());

if (ret != 0) {
Serial.printf("[ERR] Base64 解码错误: %d\n", ret);
free(decoded);
return false;
}

if (decodedLen < 32) {
Serial.printf("[ERR] 解码后数据太短: %d 字节 (需要 >= 32)\n", decodedLen);
free(decoded);
return false;
}

// Ed25519 私钥在 DER 中通常是最后 32 字节
memcpy(outPrivateKey, decoded + decodedLen - 32, 32);
free(decoded);
return true;
}

// -------- 生成 Ed25519 JWT --------
inline String generateEd25519JWT(const String& privateKeyPEM,
const String& kid,
const String& sub,
unsigned long iat,
unsigned long exp) {
// 1. 构建 Header
String header = "{";
header += "\"alg\":\"EdDSA\",";
header += "\"kid\":\"" + kid + "\"";
header += "}";

// 2. 构建 Payload
String payload = "{";
payload += "\"iat\":" + String(iat) + ",";
payload += "\"exp\":" + String(exp) + ",";
payload += "\"sub\":\"" + sub + "\"";
payload += "}";

// 3. Base64URL 编码
String headerB64 = base64UrlEncode((const uint8_t*)header.c_str(), header.length());
String payloadB64 = base64UrlEncode((const uint8_t*)payload.c_str(), payload.length());
String signingInput = headerB64 + "." + payloadB64;

// 4. 提取私钥
uint8_t privateKey[32];
if (!extractEd25519PrivateKey(privateKeyPEM, privateKey)) {
Serial.println("[ERR] 私钥提取失败");
return "";
}

// 5. 从私钥派生公钥(使用 C++ 类的静态方法)
uint8_t publicKey[32];
Ed25519::derivePublicKey(publicKey, privateKey); // ← 改成这个

// 6. 执行 Ed25519 签名(使用 C++ 类的静态方法)
uint8_t signature[64];
Ed25519::sign(signature, privateKey, publicKey, // ← 改成这个
(const uint8_t*)signingInput.c_str(),
signingInput.length());

// 7. 组合最终 JWT
String signatureB64 = base64UrlEncode(signature, 64);
return signingInput + "." + signatureB64;
}

#endif // JWT_ED25519_H

main.cpp 代码解析

把上一章的完整demo拷过来,由于篇幅问题,这里只列出重要部分,如果想看完整 JWT 实现,请见下方的 main.cpp 代码总览

首先加入头文件、一些宏定义和一些变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <time.h>
#include "jwt_ed25519.h"

// ========== NTP 配置 ==========
#define NTP_SERVER "ntp.aliyun.com" // 阿里云 NTP 服务器,可以自行更换,比如改成time.windows.com
#define GMT_OFFSET_SEC 28800 // UTC+8 北京时间
#define DAYLIGHT_OFFSET_SEC 0 // 夏令时偏移(0 = 不启用)

// ========== JWT 字符串 ==========
String JWT;

// ========== JWT 配置 ==========
const char* PRIVATE_KEY_PEM = R"(
-----BEGIN PRIVATE KEY-----
your-private-key
-----END PRIVATE KEY-----
)";

const char* KID = "your-kid";
const char* SUB = "your-sub";

其次把

1
const char* GEO_API_URL = "https://your-api-host.com/geo/v2/city/lookup?key=your-api-key&location=";

改为

1
const char* GEO_API_URL = "https://your-api-host.com/geo/v2/city/lookup?location=";

这一步是取消api key的调用方式。

然后添加这几个函数在 getGeoData 前:

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
bool syncTime() {
configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER);
Serial.print("[INFO] 同步 NTP 时间");
int attempts = 0;
while (time(nullptr) < 100000 && attempts < 60) {
delay(500);
Serial.print(".");
attempts++;
}
if (time(nullptr) > 100000) {
Serial.println(" 成功");
return true;
}
Serial.println(" 失败");
return false;
}

unsigned long getCurrentTimestamp() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
// LOG("[ERR] 获取时间失败");
LOG("ERR", "获取时间失败");
return 0;
}
return mktime(&timeinfo);
}

bool getJWT(String* outStr) {
unsigned long now = getCurrentTimestamp();
if (now == 0) {
LOG("ERR" ,"无法获取当前时间");
return false;
}

unsigned long iat = now - 30;
unsigned long exp = now + 86400 - 30;

String jwt = generateEd25519JWT(
String(PRIVATE_KEY_PEM),
String(KID),
String(SUB),
iat,
exp
);

if (jwt.length() == 0) {
LOG("ERR" ,"JWT 生成失败");
return false;
}

if (outStr != nullptr) {
*outStr = jwt;
}
return true;
}

解析:
syncTime() 函数是用来同步 NTP 时间的,用于生成JWT提供准确时间。
getCurrentTimestamp() 函数用来同步本地时间。
重头戏: getJWT() 函数用来正式生成JWT。

然后改getGeoData() 函数:

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
bool getGeoData(const String& location, String* outJson) {
String encodedLocation = urlEncode(location);
String fullUrl = String(GEO_API_URL) + encodedLocation;
if (!getJWT(&JWT)) {
LOG("ERR", "JWT 生成失败");
return false;
}
LOG("INFO", "JWT: %s", JWT.c_str()); // 这里使用的全局变量
Serial.println("[INFO] 请求 URL: " + fullUrl);

HTTPClient http;
http.begin(secureClient, fullUrl);
http.addHeader("Accept-Encoding", "gzip, deflate");
http.addHeader("Authorization", "Bearer " + JWT); // 官方文档有写,原话:“将创建的完整Token作为参数添加到Authorization: Bearer请求标头”
http.setTimeout(10000);

int httpCode = http.GET();
Serial.printf("[INFO] HTTP 状态码: %d\n", httpCode);

if (httpCode != HTTP_CODE_OK) {
Serial.printf("[ERR] HTTP 请求失败: %d\n", httpCode);
http.end();
return false;
}

WiFiClient* stream = http.getStreamPtr();
bool success = decompressGzip(stream, http, outJson);
http.end();

return success;
}

然后改setup()函数:

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
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=========================================");
Serial.println("Geo API 获取 + Gzip 解压 测试");
Serial.println("=========================================\n");

// 1. WiFi
Serial.printf("[INFO] 连接 WiFi: %s\n", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASS);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println("\n[ERR] WiFi 连接失败");
return;
}
Serial.println("\n[INFO] WiFi 连接成功");
Serial.print("[INFO] IP: ");
Serial.println(WiFi.localIP());

// 2. SSL 客户端
secureClient.setInsecure();
secureClient.setTimeout(10000);

// 3. 获取 Geo 数据
String location = "北京";
String rawJson;

if (syncTime()) {
LOG("INFO" ,"时间同步成功");
} else {
LOG("ERR", "时间同步失败");
return;
}

Serial.printf("\n[INFO] 查询位置: %s\n", location.c_str());

if (getGeoData(location, &rawJson)) {
Serial.println("[INFO] Geo 数据获取成功");
Serial.println("\n[DEBUG] 原始 JSON:");
Serial.println(rawJson);
Serial.println("\n[DEBUG] --- 结束 ---\n");

// 4. 解析
String lat, lon, cityName;
if (parseGeoJson(rawJson, &lat, &lon, &cityName)) {
Serial.println("[OK] 解析成功");
}
} else {
Serial.println("[ERR] Geo 数据获取失败");
}

Serial.println("\n[INFO] 测试完成");
}

loop函数不变,保持原样

main.cpp 代码总览

接着,我们把所有代码拼接好,下面是最终代码:

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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
// ========== 日志宏 ==========
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <ArduinoUZlib.h>
#include <UrlEncode.h>
#include <time.h>
#include "jwt_ed25519.h"

// ========== NTP 配置 ==========
#define NTP_SERVER "ntp.aliyun.com" // 阿里云 NTP 服务器,可以自行更换,比如改成time.windows.com
#define GMT_OFFSET_SEC 28800 // UTC+8 北京时间
#define DAYLIGHT_OFFSET_SEC 0 // 夏令时偏移(0 = 不启用)

// ========== JWT 字符串 ==========
String JWT;
// ========== JWT 配置 ==========
const char* PRIVATE_KEY_PEM = R"(
-----BEGIN PRIVATE KEY-----
your-private-key
-----END PRIVATE KEY-----
)";

const char* KID = "your-kid";
const char* SUB = "your-sub";

// ========== 调试开关,不用即关(注释掉就是关) ==========
#define DEBUG

// ========== WiFi 配置 ==========
const char* WIFI_SSID = "TP-LINK_16F3";
const char* WIFI_PASS = "qwer123tyui456";

// ========== API 配置 ==========
const char* GEO_API_URL = "https://nw6vhhehnj.re.qweatherapi.com/geo/v2/city/lookup?&location=";

WiFiClientSecure secureClient;

// ========== 日志宏 ==========
#define LOG(level, fmt, ...) \
do { \
time_t now = time(nullptr); \
if (now < 100000) { \
Serial.printf("[--:--:--] [%s] " fmt "\n", level, ##__VA_ARGS__); \
} else { \
struct tm* tm = localtime(&now); \
char t[20]; \
strftime(t, sizeof(t), "%H:%M:%S", tm); \
Serial.printf("[%s] [%s] " fmt "\n", t, level, ##__VA_ARGS__); \
} \
} while(0)

// ========== Gzip 解压函数 ==========
bool decompressGzip(WiFiClient* stream, HTTPClient& http, String* outJson) {
if (!stream) {
LOG("ERR" ,"stream 指针为空");
return false;
}

size_t totalSize = http.getSize();
if (totalSize <= 0) {
LOG("ERR", "无法获取数据大小");
return false;
}
LOG("INFO", "压缩数据大小: %d 字节", totalSize);

uint8_t* compressedData = (uint8_t*)malloc(totalSize);
if (!compressedData) {
LOG("ERR" ,"内存分配失败");
return false;
}

size_t readSoFar = 0;
unsigned long startTime = millis();
unsigned long timeout = 5000;

while (readSoFar < totalSize && stream->connected()) {
if (millis() - startTime > timeout) {
LOG("ERR" ,"读取超时");
free(compressedData);
return false;
}

if (stream->available()) {
int len = stream->read(compressedData + readSoFar, totalSize - readSoFar);
if (len > 0) {
readSoFar += len;
startTime = millis();
}
}
delay(1);
yield();
}

if (readSoFar != totalSize) {
LOG("WARN" ,"预期 %d,实际 %d", totalSize, readSoFar);
free(compressedData);
return false;
}
LOG("INFO" ,"读取 %d 字节压缩数据", readSoFar);

if (readSoFar < 2 || compressedData[0] != 0x1F || compressedData[1] != 0x8B) {
LOG("ERR" ,"不是 Gzip 格式");
free(compressedData);
return false;
}
LOG("INFO" ,"检测到 Gzip 格式");

LOG("INFO" ,"开始解压...");
uint8_t* outbuf = NULL;
uint32_t outSize = 0;

int result = ArduinoUZlib::decompress(compressedData, readSoFar, outbuf, outSize);

free(compressedData);
compressedData = NULL;

if (result < 0) {
LOG("ERR" ,"解压失败,错误码: %d", result);
return false;
}

outSize = result;
LOG("OK", "解压成功,大小: %d 字节", outSize);

#ifdef DEBUG
Serial.println();
LOG("DEBUG" ,"--- JSON 数据 ---");
Serial.write(outbuf, outSize);
Serial.println();
LOG("DEBUG" ,"--- JSON 结束 ---\n");
#endif

if (outJson != nullptr) {
outJson->clear();
for (uint32_t i = 0; i < outSize; i++) {
*outJson += (char)outbuf[i];
}
}

// 只在 outbuf 非 NULL 时释放
if (outbuf != NULL) {
free(outbuf);
outbuf = NULL;
}

return true;
}

bool syncTime() {
configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER);
Serial.print("[INFO] 同步 NTP 时间");
int attempts = 0;
while (time(nullptr) < 100000 && attempts < 60) {
delay(500);
Serial.print(".");
attempts++;
}
if (time(nullptr) > 100000) {
Serial.println(" 成功");
return true;
}
Serial.println(" 失败");
return false;
}

unsigned long getCurrentTimestamp() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
LOG("ERR", "获取时间失败");
return 0;
}
return mktime(&timeinfo);
}

bool getJWT(String* outStr) {
unsigned long now = getCurrentTimestamp();
if (now == 0) {
LOG("ERR" ,"无法获取当前时间");
return false;
}

unsigned long iat = now - 30;
unsigned long exp = now + 86400 - 30;

String jwt = generateEd25519JWT(
String(PRIVATE_KEY_PEM),
String(KID),
String(SUB),
iat,
exp
);

if (jwt.length() == 0) {
LOG("ERR" ,"JWT 生成失败");
return false;
}

if (outStr != nullptr) {
*outStr = jwt;
}
return true;
}

// ========== 获取 Geo API 数据 ==========
bool getGeoData(const String& location, String* outJson) {
String encodedLocation = urlEncode(location);
String fullUrl = String(GEO_API_URL) + encodedLocation;
if (!getJWT(&JWT)) {
LOG("ERR", "JWT 生成失败");
return false;
}
LOG("INFO", "JWT: %s", JWT.c_str()); // 这里使用的全局变量
Serial.println("[INFO] 请求 URL: " + fullUrl);

HTTPClient http;
http.begin(secureClient, fullUrl);
http.addHeader("Accept-Encoding", "gzip, deflate");
http.addHeader("Authorization", "Bearer " + JWT); // 官方文档有写,原话:“将创建的完整Token作为参数添加到Authorization: Bearer请求标头”
http.setTimeout(10000);

int httpCode = http.GET();
Serial.printf("[INFO] HTTP 状态码: %d\n", httpCode);

if (httpCode != HTTP_CODE_OK) {
Serial.printf("[ERR] HTTP 请求失败: %d\n", httpCode);
http.end();
return false;
}

WiFiClient* stream = http.getStreamPtr();
bool success = decompressGzip(stream, http, outJson);
http.end();

return success;
}

// ========== 解析 Geo JSON,提取经纬度 ==========
bool parseGeoJson(const String& json, String* lat, String* lon, String* cityName) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, json);

if (error) {
Serial.printf("[ERR] JSON 解析失败: %s\n", error.c_str());
return false;
}

if (doc["location"][0].isNull()) {
Serial.println("[ERR] 未找到 location 数据");
return false;
}

if (lat != nullptr) *lat = doc["location"][0]["lat"].as<String>();
if (lon != nullptr) *lon = doc["location"][0]["lon"].as<String>();
if (cityName != nullptr) *cityName = doc["location"][0]["name"].as<String>();

Serial.printf("[INFO] 城市: %s\n", cityName != nullptr ? cityName->c_str() : "未知");
Serial.printf("[INFO] 经纬度: %s, %s\n",
lat != nullptr ? lat->c_str() : "未知",
lon != nullptr ? lon->c_str() : "未知");

return true;
}

// ========== setup ==========
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=========================================");
Serial.println("Geo API 获取 + Gzip 解压 测试");
Serial.println("=========================================\n");

// 1. WiFi
Serial.printf("[INFO] 连接 WiFi: %s\n", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASS);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println("\n[ERR] WiFi 连接失败");
return;
}
Serial.println("\n[INFO] WiFi 连接成功");
Serial.print("[INFO] IP: ");
Serial.println(WiFi.localIP());

// 2. SSL 客户端
secureClient.setInsecure();
secureClient.setTimeout(10000);

// 3. 获取 Geo 数据
String location = "北京";
String rawJson;

// 同步时间
if (syncTime()) {
LOG("INFO" ,"时间同步成功");
} else {
LOG("ERR", "时间同步失败");
return;
}

Serial.printf("\n[INFO] 查询位置: %s\n", location.c_str());

// 这里无需调用getJWT,因为getGeoData里有
if (getGeoData(location, &rawJson)) {
Serial.println("[INFO] Geo 数据获取成功");
Serial.println("\n[DEBUG] 原始 JSON:");
Serial.println(rawJson);
Serial.println("\n[DEBUG] --- 结束 ---\n");

// 4. 解析
String lat, lon, cityName;
if (parseGeoJson(rawJson, &lat, &lon, &cityName)) {
Serial.println("[OK] 解析成功");
}
} else {
Serial.println("[ERR] Geo 数据获取失败");
}

Serial.println("\n[INFO] 测试完成");
}

void loop() {
delay(10000); // 什么也不做
}

效果展示

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
=========================================
Geo API 获取 + Gzip 解压 测试
=========================================

[INFO] 连接 WiFi: TP-LINK_16F3
....
[INFO] WiFi 连接成功
[INFO] IP: 192.168.0.104
[INFO] 同步 NTP 时间.......... 成功
[14:05:10] [INFO] 时间同步成功

[INFO] 查询位置: 北京
[14:05:10] [INFO] JWT: eyJhbGciOiJ(码)
[INFO] 请求 URL: https://(码)/geo/v2/city/lookup?&location=%E5%8C%97%E4%BA%AC
[INFO] HTTP 状态码: 200
[14:05:11] [INFO] 压缩数据大小: 629 字节
[14:05:11] [INFO] 读取 629 字节压缩数据
[14:05:11] [INFO] 检测到 Gzip 格式
[14:05:11] [INFO] 开始解压...
decompressed 2822 bytes
[14:05:11] [OK] 解压成功,大小: 2822 字节

[14:05:11] [DEBUG] --- JSON 数据 ---
{"code":"200","location":[{"name":"北京","id":"101010100","lat":"39.90499","lon":"116.40529","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"10","fxLink":"https://www.qweather.com/weather/beijing-101010100.html"},{"name":"海淀","id":"101010200","lat":"39.95607","lon":"116.31032","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"15","fxLink":"https://www.qweather.com/weather/haidian-101010200.html"},{"name":"朝阳","id":"101010300","lat":"39.92149","lon":"116.48641","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"15","fxLink":"https://www.qweather.com/weather/chaoyang-101010300.html"},{"name":"顺义","id":"101010400","lat":"40.12894","lon":"116.65353","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/shunyi-101010400.html"},{"name":"怀柔","id":"101010500","lat":"40.32427","lon":"116.63712","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/huairou-101010500.html"},{"name":"通州","id":"101010600","lat":"39.90249","lon":"116.65860","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"23","fxLink":"https://www.qweather.com/weather/tongzhou-101010600.html"},{"name":"昌平","id":"101010700","lat":"40.21809","lon":"116.23591","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"23","fxLink":"https://www.qweather.com/weather/changping-101010700.html"},{"name":"延庆","id":"101010800","lat":"40.46532","lon":"115.98501","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/yanqing-101010800.html"},{"name":"丰台","id":"101010900","lat":"39.86364","lon":"116.28696","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"25","fxLink":"https://www.qweather.com/weather/fengtai-101010900.html"},{"name":"石景山","id":"101011000","lat":"39.91460","lon":"116.19544","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"35","fxLink":"https://www.qweather.com/weather/shijingshan-101011000.html"}],"refer":{"sources":["QWeather"],"license":["QWeather Developers License"]}}
[14:05:11] [DEBUG] --- JSON 结束 ---

[INFO] Geo 数据获取成功

[DEBUG] 原始 JSON:
{"code":"200","location":[{"name":"北京","id":"101010100","lat":"39.90499","lon":"116.40529","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"10","fxLink":"https://www.qweather.com/weather/beijing-101010100.html"},{"name":"海淀","id":"101010200","lat":"39.95607","lon":"116.31032","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"15","fxLink":"https://www.qweather.com/weather/haidian-101010200.html"},{"name":"朝阳","id":"101010300","lat":"39.92149","lon":"116.48641","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"15","fxLink":"https://www.qweather.com/weather/chaoyang-101010300.html"},{"name":"顺义","id":"101010400","lat":"40.12894","lon":"116.65353","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/shunyi-101010400.html"},{"name":"怀柔","id":"101010500","lat":"40.32427","lon":"116.63712","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/huairou-101010500.html"},{"name":"通州","id":"101010600","lat":"39.90249","lon":"116.65860","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"23","fxLink":"https://www.qweather.com/weather/tongzhou-101010600.html"},{"name":"昌平","id":"101010700","lat":"40.21809","lon":"116.23591","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"23","fxLink":"https://www.qweather.com/weather/changping-101010700.html"},{"name":"延庆","id":"101010800","lat":"40.46532","lon":"115.98501","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/yanqing-101010800.html"},{"name":"丰台","id":"101010900","lat":"39.86364","lon":"116.28696","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"25","fxLink":"https://www.qweather.com/weather/fengtai-101010900.html"},{"name":"石景山","id":"101011000","lat":"39.91460","lon":"116.19544","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"35","fxLink":"https://www.qweather.com/weather/shijingshan-101011000.html"}],"refer":{"sources":["QWeather"],"license":["QWeather Developers License"]}}

[DEBUG] --- 结束 ---

[INFO] 城市: 北京
[INFO] 经纬度: 39.90499, 116.40529
[OK] 解析成功

[INFO] 测试完成