├── assets ├── photo.png ├── ping.png └── status.png ├── code ├── wifi_config.h └── code.ino ├── LICENSE ├── .github └── ISSUE_TEMPLATE │ ├── feature-request.yml │ └── bug-report.yml └── README.md /assets/photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxuuu/sms_forwarding/HEAD/assets/photo.png -------------------------------------------------------------------------------- /assets/ping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxuuu/sms_forwarding/HEAD/assets/ping.png -------------------------------------------------------------------------------- /assets/status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxuuu/sms_forwarding/HEAD/assets/status.png -------------------------------------------------------------------------------- /code/wifi_config.h: -------------------------------------------------------------------------------- 1 | //WIFI - 仍使用宏定义,因为需要先联网才能配置其他参数 2 | #define WIFI_SSID "你家wifi" 3 | #define WIFI_PASS "你家wifi密码" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 chenxuuu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 请求某功能 / Feature request" 2 | description: "为这个项目出一个好点子 / Suggest an idea for this project" 3 | labels: ["待分类"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | 感谢你提供新的想法! / Thanks for taking the time to fill out this Feature request! 9 | - type: textarea 10 | id: problem 11 | attributes: 12 | label: 您的功能请求是否与解决某些问题有关?请描述一下。/ Is your feature request related to a problem? Please describe. 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: description 17 | attributes: 18 | label: 描述您想要的解决方案 / Describe the solution you'd like 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: usage 23 | attributes: 24 | label: 描述您想要的详细使用步骤描述 / Describe the solution you'd like to use in what way 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: other 29 | attributes: 30 | label: 其他备注信息或截图 / Add any other context or screenshots about the feature request here 31 | validations: 32 | required: false 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41E 上报bug / Bug report" 2 | description: "提交bug以让改进软件功能 / Create a report to help us improve" 3 | labels: ["待分类"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | 感谢你上报新的问题! / Thanks for taking the time to fill out this bug report! 9 | - type: textarea 10 | id: bug-description 11 | attributes: 12 | label: 描述一下这个bug / Describe the bug 13 | description: 请使用简介并详细的语句,来描述这个bug。 / A clear and concise description of what the bug is. 14 | placeholder: 我准备……我想要……但是实际上它……了 / I am doing ... What I expect is ... What actually happening is ... 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: reproduction 19 | attributes: 20 | label: 复现步骤 / To Reproduce 21 | description: 按照下面的步骤,可以复现bug / Steps to reproduce the behavior 22 | placeholder: 首先……然后……接着…… / Go to '...', Click on '....', Scroll down to '....' 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: expected 27 | attributes: 28 | label: 预期的行为 / Expected behavior 29 | description: 清楚简洁地描述你希望发生的事情。 / A clear and concise description of what you expected to happen. 30 | placeholder: 它应该…… / It should be... 31 | validations: 32 | required: true 33 | - type: textarea 34 | id: screenshots 35 | attributes: 36 | label: 截图 / Screenshots 37 | description: 如果需要,请放上你的截图。 / If applicable, add screenshots to help explain your problem. 38 | validations: 39 | required: false 40 | - type: textarea 41 | id: logs 42 | attributes: 43 | label: 日志 / Logs 44 | description: 日志贴到这里 / Upload your log files. 45 | description: 请使用```markdown语法进行包裹! 46 | validations: 47 | required: true 48 | 49 | - type: checkboxes 50 | id: checkboxes 51 | attributes: 52 | label: 验证 53 | description: 提交前请确认已经做过以下操作 / Before submitting the issue, please make sure you do the following 54 | options: 55 | - label: 检查该问题是否已被提过 / Check that there isn't already an issue that reports the same bug to avoid creating a duplicate. 56 | required: true 57 | - label: 提供了最小可复现工程或详细的复现步骤,确保开发者可以复现 / The provided reproduction is a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) of the bug. 58 | required: true 59 | - label: 我已经提供了完整的日志 / I have provided complete logs. 60 | required: true 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 低成本短信转发器 2 | 3 | > 当前分支为新方案,老方案请前往[luatos分支](https://github.com/chenxuuu/sms_forwarding/tree/old-luatos)。 4 | 该项目可能不支持电信卡(CDMA),具体请自测。 5 | 本项目仅用于接收短信与进行保号相关功能。多卡控制、通话、主动拨号等功能永远不会支持,请勿提出相关需求。 6 | 7 | [后台页面演示](https://sms.j2.cx/) 8 | 9 | 本项目旨在使用低成本的硬件设备,实现短信的自动转发功能,支持多种推送方式同时启用。 10 | 11 | > 视频教程:[B站视频](https://www.bilibili.com/video/BV1cSmABYEiX) 12 | 13 | 14 | 15 | ## 功能 16 | 17 | - 支持使用通用AT指令与模块进行通信 18 | - 开启后支持通过WEB界面配置短信转发参数、查询当前状态 19 | - **支持多达5个推送通道同时启用**,每个通道可独立配置 20 | - 支持将收到的短信转发到指定的邮箱 21 | - 支持通过WEB界面主动发送短信,以便消耗余额 22 | - 支持通过WEB界面进行Ping测试,以极低的成本消耗余额 23 | - 支持长短信自动合并(30秒超时) 24 | - 支持管理员短信远程发送短信和重启设备 25 | 26 | ## 推送通道支持 27 | 28 | 支持以下7种推送方式,可同时启用多个通道: 29 | 30 | | 推送方式 | 说明 | 需要配置 | 31 | |---------|------|---------| 32 | | **POST JSON** | 通用HTTP POST | URL | 33 | | **Bark** | iOS推送服务 | Bark服务器URL | 34 | | **GET请求** | URL参数方式 | URL | 35 | | **钉钉机器人** | 企业群通知 | Webhook URL,可选Secret加签 | 36 | | **PushPlus** | 微信公众号推送 | Token | 37 | | **Server酱** | 微信推送服务 | SendKey | 38 | | **自定义模板** | 灵活的JSON模板 | URL + 请求体模板 | 39 | | **飞书机器人** | 自定义通知 | Webhook URL | 40 | 41 | ### 推送格式说明 42 | 43 | - **POST JSON**: `{"sender":"发送者号码","message":"短信内容","timestamp":"时间戳"}` 44 | - **Bark**: `{"title":"发送者号码","body":"短信内容"}` 45 | - **GET请求**: `URL?sender=xxx&message=xxx×tamp=xxx`(自动URL编码) 46 | - **钉钉机器人**: 文本消息格式,支持加签验证 47 | - **PushPlus**: 使用Token推送,支持HTML格式 48 | - **Server酱**: 使用SendKey推送,支持Markdown格式 49 | - **自定义模板**: 使用`{sender}`、`{message}`、`{timestamp}`占位符 50 | - **飞书机器人**: 文本消息格式,支持加签验证 51 | 52 | |状态信息|主动ping| 53 | |-|-| 54 | |![](assets/status.png)|![](assets/ping.png)| 55 | 56 | ## 硬件搭配 57 | 58 | - ESP32C3开发板,当前选用[ESP32C3 Super Mini](https://item.taobao.com/item.htm?id=852057780489&skuId=5813710390565),¥9.5包邮 59 | - ML307R-DC开发板,当前选用[小蓝鲸ML307R-DC核心板](https://item.taobao.com/item.htm?id=797466121802&skuId=5722077108045),¥16.3包邮 60 | - [4G FPC天线](https://item.taobao.com/item.htm?id=797466121802&skuId=5722077108045),¥2,与核心板同购 61 | 62 | 当前成本约¥27.8 63 | 64 | ## 硬件连接 65 | 66 | ESP32C3 与 ML307R-DC 通过串口(UART)连接,接线如下: 67 | 68 | ``` 69 | ESP32C3 Super Mini ML307R-DC核心板 70 | ┌───────────────────┐ ┌─────────────────┐ 71 | │ │ │ │ 72 | │ GPIO3 (TX) ─┼───►│ RX │ 73 | │ │ │ EN ─┼─┐ 74 | │ GPIO4 (RX) ◄┼────┤ TX │ │ 75 | │ │ │ │ │ 76 | │ GND ─┼────┤ GND │ │ 77 | │ │ │ │ │ 78 | │ 5V ─┼────┤ VCC (5V) 5V ─┼─┘ 79 | │ │ │ │ 80 | └───────────────────┘ └─────────────────┘ 81 | │ │ 82 | │ SIM卡槽 │ 83 | │ (插入Nano SIM) │ 84 | │ │ 85 | │ 天线接口 │ 86 | │ (连接4G天线) │ 87 | └─────────────────┘ 88 | ``` 89 | 90 | 可通过USB连接ESP32C3进行编程和供电,正常工作时,ESP32C3的虚拟串口数据将直接被转发到ML307R-DC,方便调试。 91 | 92 | ## 软件组成 93 | 94 | - ESP32C3运行自己的`Arduino`固件,负责连接WiFi和接收ML307R-DC发送过来的短信数据,然后转发到指定HTTP接口或邮箱 95 | - ML307R-DC运行默认的AT固件,不用动 96 | 97 | 需要在`Arduino IDE`中单独安装这些库: 98 | 99 | - **ReadyMail** by Mobizt 100 | - **pdulib** by David Henry 101 | 102 | 需要在`Arduino IDE`中安装ESP32开发板支持,参考[官方文档](https://docs.espressif.com/projects/arduino-esp32/en/latest/installing.html),版型选`MakerGO ESP32 C3 SuperMini`。 103 | -------------------------------------------------------------------------------- /code/code.ino: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #define ENABLE_SMTP 9 | #define ENABLE_DEBUG 10 | #include 11 | #include 12 | #include // 用于钉钉签名的HMAC-SHA256 13 | #include // Base64编码 14 | 15 | //wifi信息,需要你打开这个去改 16 | #include "wifi_config.h" 17 | 18 | //串口映射 19 | #define TXD 3 20 | #define RXD 4 21 | 22 | // 推送通道类型 23 | enum PushType { 24 | PUSH_TYPE_NONE = 0, // 未启用 25 | PUSH_TYPE_POST_JSON = 1, // POST JSON格式 {"sender":"xxx","message":"xxx","timestamp":"xxx"} 26 | PUSH_TYPE_BARK = 2, // Bark格式 POST {"title":"xxx","body":"xxx"} 27 | PUSH_TYPE_GET = 3, // GET请求,参数放URL中 28 | PUSH_TYPE_DINGTALK = 4, // 钉钉机器人 29 | PUSH_TYPE_PUSHPLUS = 5, // PushPlus 30 | PUSH_TYPE_SERVERCHAN = 6,// Server酱 31 | PUSH_TYPE_CUSTOM = 7, // 自定义模板 32 | PUSH_TYPE_FEISHU = 8 // 飞书机器人 33 | }; 34 | 35 | // 最大推送通道数 36 | #define MAX_PUSH_CHANNELS 5 37 | 38 | // 推送通道配置(通用设计,支持多种推送方式) 39 | struct PushChannel { 40 | bool enabled; // 是否启用 41 | PushType type; // 推送类型 42 | String name; // 通道名称(用于显示) 43 | String url; // 推送URL(webhook地址) 44 | String key1; // 额外参数1(如:钉钉secret、pushplus token等) 45 | String key2; // 额外参数2(备用) 46 | String customBody; // 自定义请求体模板(使用 {sender} {message} {timestamp} 占位符) 47 | }; 48 | 49 | // 配置参数结构体 50 | struct Config { 51 | String smtpServer; 52 | int smtpPort; 53 | String smtpUser; 54 | String smtpPass; 55 | String smtpSendTo; 56 | String adminPhone; 57 | PushChannel pushChannels[MAX_PUSH_CHANNELS]; // 多推送通道 58 | String webUser; // Web管理账号 59 | String webPass; // Web管理密码 60 | }; 61 | 62 | // 默认Web管理账号密码 63 | #define DEFAULT_WEB_USER "admin" 64 | #define DEFAULT_WEB_PASS "admin123" 65 | 66 | Config config; 67 | Preferences preferences; 68 | WiFiMulti WiFiMulti; 69 | PDU pdu = PDU(4096); 70 | WiFiClientSecure ssl_client; 71 | SMTPClient smtp(ssl_client); 72 | WebServer server(80); 73 | 74 | bool configValid = false; // 配置是否有效 75 | bool timeSynced = false; // NTP时间是否已同步 76 | unsigned long lastPrintTime = 0; // 上次打印IP的时间 77 | 78 | #define SERIAL_BUFFER_SIZE 500 79 | #define MAX_PDU_LENGTH 300 80 | char serialBuf[SERIAL_BUFFER_SIZE]; 81 | int serialBufLen = 0; 82 | 83 | // 长短信合并相关定义 84 | #define MAX_CONCAT_PARTS 10 // 最大支持的长短信分段数 85 | #define CONCAT_TIMEOUT_MS 30000 // 长短信等待超时时间(毫秒) 86 | #define MAX_CONCAT_MESSAGES 5 // 最多同时缓存的长短信组数 87 | 88 | // 长短信分段结构 89 | struct SmsPart { 90 | bool valid; // 该分段是否有效 91 | String text; // 分段内容 92 | }; 93 | 94 | // 长短信缓存结构 95 | struct ConcatSms { 96 | bool inUse; // 是否正在使用 97 | int refNumber; // 参考号 98 | String sender; // 发送者 99 | String timestamp; // 时间戳(使用第一个收到的分段的时间戳) 100 | int totalParts; // 总分段数 101 | int receivedParts; // 已收到的分段数 102 | unsigned long firstPartTime; // 收到第一个分段的时间 103 | SmsPart parts[MAX_CONCAT_PARTS]; // 各分段内容 104 | }; 105 | 106 | ConcatSms concatBuffer[MAX_CONCAT_MESSAGES]; // 长短信缓存 107 | 108 | // 保存配置到NVS 109 | void saveConfig() { 110 | preferences.begin("sms_config", false); 111 | preferences.putString("smtpServer", config.smtpServer); 112 | preferences.putInt("smtpPort", config.smtpPort); 113 | preferences.putString("smtpUser", config.smtpUser); 114 | preferences.putString("smtpPass", config.smtpPass); 115 | preferences.putString("smtpSendTo", config.smtpSendTo); 116 | preferences.putString("adminPhone", config.adminPhone); 117 | preferences.putString("webUser", config.webUser); 118 | preferences.putString("webPass", config.webPass); 119 | 120 | // 保存推送通道配置 121 | for (int i = 0; i < MAX_PUSH_CHANNELS; i++) { 122 | String prefix = "push" + String(i); 123 | preferences.putBool((prefix + "en").c_str(), config.pushChannels[i].enabled); 124 | preferences.putUChar((prefix + "type").c_str(), (uint8_t)config.pushChannels[i].type); 125 | preferences.putString((prefix + "url").c_str(), config.pushChannels[i].url); 126 | preferences.putString((prefix + "name").c_str(), config.pushChannels[i].name); 127 | preferences.putString((prefix + "k1").c_str(), config.pushChannels[i].key1); 128 | preferences.putString((prefix + "k2").c_str(), config.pushChannels[i].key2); 129 | preferences.putString((prefix + "body").c_str(), config.pushChannels[i].customBody); 130 | } 131 | 132 | preferences.end(); 133 | Serial.println("配置已保存"); 134 | } 135 | 136 | // 从NVS加载配置 137 | void loadConfig() { 138 | preferences.begin("sms_config", true); 139 | config.smtpServer = preferences.getString("smtpServer", ""); 140 | config.smtpPort = preferences.getInt("smtpPort", 465); 141 | config.smtpUser = preferences.getString("smtpUser", ""); 142 | config.smtpPass = preferences.getString("smtpPass", ""); 143 | config.smtpSendTo = preferences.getString("smtpSendTo", ""); 144 | config.adminPhone = preferences.getString("adminPhone", ""); 145 | config.webUser = preferences.getString("webUser", DEFAULT_WEB_USER); 146 | config.webPass = preferences.getString("webPass", DEFAULT_WEB_PASS); 147 | 148 | // 加载推送通道配置 149 | for (int i = 0; i < MAX_PUSH_CHANNELS; i++) { 150 | String prefix = "push" + String(i); 151 | config.pushChannels[i].enabled = preferences.getBool((prefix + "en").c_str(), false); 152 | config.pushChannels[i].type = (PushType)preferences.getUChar((prefix + "type").c_str(), PUSH_TYPE_POST_JSON); 153 | config.pushChannels[i].url = preferences.getString((prefix + "url").c_str(), ""); 154 | config.pushChannels[i].name = preferences.getString((prefix + "name").c_str(), "通道" + String(i + 1)); 155 | config.pushChannels[i].key1 = preferences.getString((prefix + "k1").c_str(), ""); 156 | config.pushChannels[i].key2 = preferences.getString((prefix + "k2").c_str(), ""); 157 | config.pushChannels[i].customBody = preferences.getString((prefix + "body").c_str(), ""); 158 | } 159 | 160 | // 兼容旧配置:如果有旧的httpUrl配置,迁移到第一个通道 161 | String oldHttpUrl = preferences.getString("httpUrl", ""); 162 | if (oldHttpUrl.length() > 0 && !config.pushChannels[0].enabled) { 163 | config.pushChannels[0].enabled = true; 164 | config.pushChannels[0].url = oldHttpUrl; 165 | config.pushChannels[0].type = preferences.getUChar("barkMode", 0) != 0 ? PUSH_TYPE_BARK : PUSH_TYPE_POST_JSON; 166 | config.pushChannels[0].name = "迁移通道"; 167 | Serial.println("已迁移旧HTTP配置到推送通道1"); 168 | } 169 | 170 | preferences.end(); 171 | Serial.println("配置已加载"); 172 | } 173 | 174 | // 检查推送通道是否有效配置 175 | bool isPushChannelValid(const PushChannel& ch) { 176 | if (!ch.enabled) return false; 177 | 178 | switch (ch.type) { 179 | case PUSH_TYPE_POST_JSON: 180 | case PUSH_TYPE_BARK: 181 | case PUSH_TYPE_GET: 182 | case PUSH_TYPE_DINGTALK: 183 | case PUSH_TYPE_FEISHU: 184 | case PUSH_TYPE_CUSTOM: 185 | return ch.url.length() > 0; 186 | case PUSH_TYPE_PUSHPLUS: 187 | case PUSH_TYPE_SERVERCHAN: 188 | return ch.key1.length() > 0; // 这两个主要靠key1(token/sendkey) 189 | default: 190 | return false; 191 | } 192 | } 193 | 194 | // 检查配置是否有效(至少配置了邮件或任一推送通道) 195 | bool isConfigValid() { 196 | bool emailValid = config.smtpServer.length() > 0 && 197 | config.smtpUser.length() > 0 && 198 | config.smtpPass.length() > 0 && 199 | config.smtpSendTo.length() > 0; 200 | 201 | bool pushValid = false; 202 | for (int i = 0; i < MAX_PUSH_CHANNELS; i++) { 203 | if (isPushChannelValid(config.pushChannels[i])) { 204 | pushValid = true; 205 | break; 206 | } 207 | } 208 | 209 | return emailValid || pushValid; 210 | } 211 | 212 | // 获取当前设备URL 213 | String getDeviceUrl() { 214 | return "http://" + WiFi.localIP().toString() + "/"; 215 | } 216 | 217 | // HTML配置页面 218 | const char* htmlPage = R"rawliteral( 219 | 220 | 221 | 222 | 223 | 224 | 短信转发配置 225 | 254 | 255 | 256 |
257 |

📱 短信转发器

258 | 262 |
设备IP: %IP%
263 | 264 |
265 |
266 |
🔐 Web管理账号设置
267 |
⚠️ 首次使用请修改默认密码!默认账号: )rawliteral" DEFAULT_WEB_USER ",默认密码: " DEFAULT_WEB_PASS R"rawliteral( 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 |
🔗 HTTP推送通道设置
305 |
可同时启用多个推送通道,每个通道独立配置。支持POST JSON、Bark、GET、钉钉、PushPlus、Server酱等多种方式。
306 | 307 | %PUSH_CHANNELS% 308 |
309 | 310 |
311 |
👤 管理员设置
312 |
313 | 314 | 315 |
316 |
317 | 318 | 319 |
320 |
321 | 384 | 385 | 386 | )rawliteral"; 387 | 388 | // HTML工具箱页面 389 | const char* htmlToolsPage = R"rawliteral( 390 | 391 | 392 | 393 | 394 | 395 | 工具箱 396 | 432 | 433 | 434 |
435 |

📱 短信转发器

436 | 440 |
设备IP: %IP%
441 | 442 |
443 |
444 |
📤 发送短信
445 |
446 | 447 | 448 |
449 |
450 | 451 | 452 |
已输入 0 字符
453 |
454 | 455 |
456 |
457 | 458 |
459 |
📊 模组信息查询
460 |
461 | 462 | 463 |
464 |
465 | 466 | 467 |
468 |
469 | 470 |
471 |
472 |
473 | 474 |
475 |
🌐 网络测试
476 | 477 |
将向 8.8.8.8 进行 ping 操作,一次性消耗极少流量费用
478 |
479 |
480 |
481 | 546 | 547 | 548 | )rawliteral"; 549 | 550 | // 检查HTTP Basic认证 551 | bool checkAuth() { 552 | if (!server.authenticate(config.webUser.c_str(), config.webPass.c_str())) { 553 | server.requestAuthentication(BASIC_AUTH, "SMS Forwarding", "请输入管理员账号密码"); 554 | return false; 555 | } 556 | return true; 557 | } 558 | 559 | // 处理配置页面请求 560 | void handleRoot() { 561 | if (!checkAuth()) return; 562 | 563 | String html = String(htmlPage); 564 | html.replace("%IP%", WiFi.localIP().toString()); 565 | html.replace("%WEB_USER%", config.webUser); 566 | html.replace("%WEB_PASS%", config.webPass); 567 | html.replace("%SMTP_SERVER%", config.smtpServer); 568 | html.replace("%SMTP_PORT%", String(config.smtpPort)); 569 | html.replace("%SMTP_USER%", config.smtpUser); 570 | html.replace("%SMTP_PASS%", config.smtpPass); 571 | html.replace("%SMTP_SEND_TO%", config.smtpSendTo); 572 | html.replace("%ADMIN_PHONE%", config.adminPhone); 573 | 574 | // 生成推送通道HTML 575 | String channelsHtml = ""; 576 | for (int i = 0; i < MAX_PUSH_CHANNELS; i++) { 577 | String idx = String(i); 578 | String enabledClass = config.pushChannels[i].enabled ? " enabled" : ""; 579 | String checked = config.pushChannels[i].enabled ? " checked" : ""; 580 | 581 | channelsHtml += "
"; 582 | channelsHtml += "
"; 583 | channelsHtml += ""; 584 | channelsHtml += ""; 585 | channelsHtml += "
"; 586 | channelsHtml += "
"; 587 | 588 | // 通道名称 589 | channelsHtml += "
"; 590 | channelsHtml += ""; 591 | channelsHtml += ""; 592 | channelsHtml += "
"; 593 | 594 | // 推送类型 595 | channelsHtml += "
"; 596 | channelsHtml += ""; 597 | channelsHtml += ""; 607 | channelsHtml += "
"; 608 | channelsHtml += "
"; 609 | 610 | // URL 611 | channelsHtml += "
"; 612 | channelsHtml += ""; 613 | channelsHtml += ""; 614 | channelsHtml += "
"; 615 | 616 | // 额外参数区域(钉钉/PushPlus/Server酱等需要) 617 | channelsHtml += "
"; 618 | channelsHtml += "
"; 619 | channelsHtml += ""; 620 | channelsHtml += ""; 621 | channelsHtml += "
"; 622 | channelsHtml += "
"; 623 | channelsHtml += ""; 624 | channelsHtml += ""; 625 | channelsHtml += "
"; 626 | channelsHtml += "
"; 627 | 628 | // 自定义模板区域 629 | channelsHtml += "
"; 630 | channelsHtml += "
"; 631 | channelsHtml += ""; 632 | channelsHtml += ""; 633 | channelsHtml += "
"; 634 | channelsHtml += "
"; 635 | 636 | channelsHtml += "
"; 637 | } 638 | html.replace("%PUSH_CHANNELS%", channelsHtml); 639 | 640 | server.send(200, "text/html", html); 641 | } 642 | 643 | // 处理工具箱页面请求 644 | void handleToolsPage() { 645 | if (!checkAuth()) return; 646 | 647 | String html = String(htmlToolsPage); 648 | html.replace("%IP%", WiFi.localIP().toString()); 649 | server.send(200, "text/html", html); 650 | } 651 | 652 | // 发送AT命令并获取响应 653 | String sendATCommand(const char* cmd, unsigned long timeout) { 654 | while (Serial1.available()) Serial1.read(); 655 | Serial1.println(cmd); 656 | 657 | unsigned long start = millis(); 658 | String resp = ""; 659 | while (millis() - start < timeout) { 660 | while (Serial1.available()) { 661 | char c = Serial1.read(); 662 | resp += c; 663 | if (resp.indexOf("OK") >= 0 || resp.indexOf("ERROR") >= 0) { 664 | delay(50); // 等待剩余数据 665 | while (Serial1.available()) resp += (char)Serial1.read(); 666 | return resp; 667 | } 668 | } 669 | } 670 | return resp; 671 | } 672 | 673 | // 处理模组信息查询请求 674 | void handleQuery() { 675 | if (!checkAuth()) return; 676 | 677 | String type = server.arg("type"); 678 | String json = "{"; 679 | bool success = false; 680 | String message = ""; 681 | 682 | if (type == "ati") { 683 | // 固件信息查询 684 | String resp = sendATCommand("ATI", 2000); 685 | Serial.println("ATI响应: " + resp); 686 | 687 | if (resp.indexOf("OK") >= 0) { 688 | success = true; 689 | // 解析ATI响应 690 | String manufacturer = "未知"; 691 | String model = "未知"; 692 | String version = "未知"; 693 | 694 | // 按行解析 695 | int lineStart = 0; 696 | int lineNum = 0; 697 | for (int i = 0; i < resp.length(); i++) { 698 | if (resp.charAt(i) == '\n' || i == resp.length() - 1) { 699 | String line = resp.substring(lineStart, i); 700 | line.trim(); 701 | if (line.length() > 0 && line != "ATI" && line != "OK") { 702 | lineNum++; 703 | if (lineNum == 1) manufacturer = line; 704 | else if (lineNum == 2) model = line; 705 | else if (lineNum == 3) version = line; 706 | } 707 | lineStart = i + 1; 708 | } 709 | } 710 | 711 | message = ""; 712 | message += ""; 713 | message += ""; 714 | message += ""; 715 | message += "
制造商" + manufacturer + "
模组型号" + model + "
固件版本" + version + "
"; 716 | } else { 717 | message = "查询失败"; 718 | } 719 | } 720 | else if (type == "signal") { 721 | // 信号质量查询 722 | String resp = sendATCommand("AT+CESQ", 2000); 723 | Serial.println("CESQ响应: " + resp); 724 | 725 | if (resp.indexOf("+CESQ:") >= 0) { 726 | success = true; 727 | // 解析 +CESQ: ,,,,, 728 | int idx = resp.indexOf("+CESQ:"); 729 | String params = resp.substring(idx + 6); 730 | int endIdx = params.indexOf('\r'); 731 | if (endIdx < 0) endIdx = params.indexOf('\n'); 732 | if (endIdx > 0) params = params.substring(0, endIdx); 733 | params.trim(); 734 | 735 | // 分割参数 736 | String values[6]; 737 | int valIdx = 0; 738 | int startPos = 0; 739 | for (int i = 0; i <= params.length() && valIdx < 6; i++) { 740 | if (i == params.length() || params.charAt(i) == ',') { 741 | values[valIdx] = params.substring(startPos, i); 742 | values[valIdx].trim(); 743 | valIdx++; 744 | startPos = i + 1; 745 | } 746 | } 747 | 748 | // RSRP转换为dBm (0-97映射到-140到-44 dBm, 99表示未知) 749 | int rsrp = values[5].toInt(); 750 | String rsrpStr; 751 | if (rsrp == 99 || rsrp == 255) { 752 | rsrpStr = "未知"; 753 | } else { 754 | int rsrpDbm = -140 + rsrp; 755 | rsrpStr = String(rsrpDbm) + " dBm"; 756 | if (rsrpDbm >= -80) rsrpStr += " (信号极好)"; 757 | else if (rsrpDbm >= -90) rsrpStr += " (信号良好)"; 758 | else if (rsrpDbm >= -100) rsrpStr += " (信号一般)"; 759 | else if (rsrpDbm >= -110) rsrpStr += " (信号较弱)"; 760 | else rsrpStr += " (信号很差)"; 761 | } 762 | 763 | // RSRQ转换 (0-34映射到-19.5到-3 dB) 764 | int rsrq = values[4].toInt(); 765 | String rsrqStr; 766 | if (rsrq == 99 || rsrq == 255) { 767 | rsrqStr = "未知"; 768 | } else { 769 | float rsrqDb = -19.5 + rsrq * 0.5; 770 | rsrqStr = String(rsrqDb, 1) + " dB"; 771 | } 772 | 773 | message = ""; 774 | message += ""; 775 | message += ""; 776 | message += ""; 777 | message += "
信号强度 (RSRP)" + rsrpStr + "
信号质量 (RSRQ)" + rsrqStr + "
原始数据" + params + "
"; 778 | } else { 779 | message = "查询失败"; 780 | } 781 | } 782 | else if (type == "siminfo") { 783 | // SIM卡信息查询 784 | success = true; 785 | message = ""; 786 | 787 | // 查询IMSI 788 | String resp = sendATCommand("AT+CIMI", 2000); 789 | String imsi = "未知"; 790 | if (resp.indexOf("OK") >= 0) { 791 | int start = resp.indexOf('\n'); 792 | if (start >= 0) { 793 | int end = resp.indexOf('\n', start + 1); 794 | if (end < 0) end = resp.indexOf('\r', start + 1); 795 | if (end > start) { 796 | imsi = resp.substring(start + 1, end); 797 | imsi.trim(); 798 | if (imsi == "OK" || imsi.length() < 10) imsi = "未知"; 799 | } 800 | } 801 | } 802 | message += ""; 803 | 804 | // 查询ICCID 805 | resp = sendATCommand("AT+ICCID", 2000); 806 | String iccid = "未知"; 807 | if (resp.indexOf("+ICCID:") >= 0) { 808 | int idx = resp.indexOf("+ICCID:"); 809 | String tmp = resp.substring(idx + 7); 810 | int endIdx = tmp.indexOf('\r'); 811 | if (endIdx < 0) endIdx = tmp.indexOf('\n'); 812 | if (endIdx > 0) iccid = tmp.substring(0, endIdx); 813 | iccid.trim(); 814 | } 815 | message += ""; 816 | 817 | // 查询本机号码 (如果SIM卡支持) 818 | resp = sendATCommand("AT+CNUM", 2000); 819 | String phoneNum = "未存储或不支持"; 820 | if (resp.indexOf("+CNUM:") >= 0) { 821 | int idx = resp.indexOf(",\""); 822 | if (idx >= 0) { 823 | int endIdx = resp.indexOf("\"", idx + 2); 824 | if (endIdx > idx) { 825 | phoneNum = resp.substring(idx + 2, endIdx); 826 | } 827 | } 828 | } 829 | message += ""; 830 | 831 | message += "
IMSI" + imsi + "
ICCID" + iccid + "
本机号码" + phoneNum + "
"; 832 | } 833 | else if (type == "network") { 834 | // 网络状态查询 835 | success = true; 836 | message = ""; 837 | 838 | // 查询网络注册状态 839 | String resp = sendATCommand("AT+CEREG?", 2000); 840 | String regStatus = "未知"; 841 | if (resp.indexOf("+CEREG:") >= 0) { 842 | int idx = resp.indexOf("+CEREG:"); 843 | String tmp = resp.substring(idx + 7); 844 | int commaIdx = tmp.indexOf(','); 845 | if (commaIdx >= 0) { 846 | String stat = tmp.substring(commaIdx + 1, commaIdx + 2); 847 | int s = stat.toInt(); 848 | switch(s) { 849 | case 0: regStatus = "未注册,未搜索"; break; 850 | case 1: regStatus = "已注册,本地网络"; break; 851 | case 2: regStatus = "未注册,正在搜索"; break; 852 | case 3: regStatus = "注册被拒绝"; break; 853 | case 4: regStatus = "未知"; break; 854 | case 5: regStatus = "已注册,漫游"; break; 855 | default: regStatus = "状态码: " + stat; 856 | } 857 | } 858 | } 859 | message += ""; 860 | 861 | // 查询运营商 862 | resp = sendATCommand("AT+COPS?", 2000); 863 | String oper = "未知"; 864 | if (resp.indexOf("+COPS:") >= 0) { 865 | int idx = resp.indexOf(",\""); 866 | if (idx >= 0) { 867 | int endIdx = resp.indexOf("\"", idx + 2); 868 | if (endIdx > idx) { 869 | oper = resp.substring(idx + 2, endIdx); 870 | } 871 | } 872 | } 873 | message += ""; 874 | 875 | // 查询PDP上下文激活状态 876 | resp = sendATCommand("AT+CGACT?", 2000); 877 | String pdpStatus = "未激活"; 878 | if (resp.indexOf("+CGACT: 1,1") >= 0) { 879 | pdpStatus = "已激活"; 880 | } else if (resp.indexOf("+CGACT:") >= 0) { 881 | pdpStatus = "未激活"; 882 | } 883 | message += ""; 884 | 885 | // 查询APN 886 | resp = sendATCommand("AT+CGDCONT?", 2000); 887 | String apn = "未知"; 888 | if (resp.indexOf("+CGDCONT:") >= 0) { 889 | int idx = resp.indexOf(",\""); 890 | if (idx >= 0) { 891 | idx = resp.indexOf(",\"", idx + 2); // 跳过PDP类型 892 | if (idx >= 0) { 893 | int endIdx = resp.indexOf("\"", idx + 2); 894 | if (endIdx > idx) { 895 | apn = resp.substring(idx + 2, endIdx); 896 | if (apn.length() == 0) apn = "(自动)"; 897 | } 898 | } 899 | } 900 | } 901 | message += ""; 902 | 903 | message += "
网络注册" + regStatus + "
运营商" + oper + "
数据连接" + pdpStatus + "
APN" + apn + "
"; 904 | } 905 | else if (type == "wifi") { 906 | // WiFi状态查询 907 | success = true; 908 | message = ""; 909 | 910 | // WiFi连接状态 911 | String wifiStatus = WiFi.isConnected() ? "已连接" : "未连接"; 912 | message += ""; 913 | 914 | // SSID 915 | String ssid = WiFi.SSID(); 916 | if (ssid.length() == 0) ssid = "未知"; 917 | message += ""; 918 | 919 | // 信号强度 RSSI 920 | int rssi = WiFi.RSSI(); 921 | String rssiStr = String(rssi) + " dBm"; 922 | if (rssi >= -50) rssiStr += " (信号极好)"; 923 | else if (rssi >= -60) rssiStr += " (信号很好)"; 924 | else if (rssi >= -70) rssiStr += " (信号良好)"; 925 | else if (rssi >= -80) rssiStr += " (信号一般)"; 926 | else if (rssi >= -90) rssiStr += " (信号较弱)"; 927 | else rssiStr += " (信号很差)"; 928 | message += ""; 929 | 930 | // IP地址 931 | message += ""; 932 | 933 | // 网关 934 | message += ""; 935 | 936 | // 子网掩码 937 | message += ""; 938 | 939 | // DNS 940 | message += ""; 941 | 942 | // MAC地址 943 | message += ""; 944 | 945 | // BSSID (路由器MAC) 946 | message += ""; 947 | 948 | // 信道 949 | message += ""; 950 | 951 | message += "
连接状态" + wifiStatus + "
当前SSID" + ssid + "
信号强度 (RSSI)" + rssiStr + "
IP地址" + WiFi.localIP().toString() + "
网关" + WiFi.gatewayIP().toString() + "
子网掩码" + WiFi.subnetMask().toString() + "
DNS服务器" + WiFi.dnsIP().toString() + "
MAC地址" + WiFi.macAddress() + "
路由器BSSID" + WiFi.BSSIDstr() + "
WiFi信道" + String(WiFi.channel()) + "
"; 952 | } 953 | else { 954 | message = "未知的查询类型"; 955 | } 956 | 957 | json += "\"success\":" + String(success ? "true" : "false") + ","; 958 | json += "\"message\":\"" + message + "\""; 959 | json += "}"; 960 | 961 | server.send(200, "application/json", json); 962 | } 963 | 964 | // 前置声明 965 | void sendEmailNotification(const char* subject, const char* body); 966 | bool sendSMS(const char* phoneNumber, const char* message); 967 | 968 | // 处理发送短信请求 969 | void handleSendSms() { 970 | if (!checkAuth()) return; 971 | 972 | String phone = server.arg("phone"); 973 | String content = server.arg("content"); 974 | 975 | phone.trim(); 976 | content.trim(); 977 | 978 | bool success = false; 979 | String resultMsg = ""; 980 | 981 | if (phone.length() == 0) { 982 | resultMsg = "错误:请输入目标号码"; 983 | } else if (content.length() == 0) { 984 | resultMsg = "错误:请输入短信内容"; 985 | } else { 986 | Serial.println("网页端发送短信请求"); 987 | Serial.println("目标号码: " + phone); 988 | Serial.println("短信内容: " + content); 989 | 990 | success = sendSMS(phone.c_str(), content.c_str()); 991 | resultMsg = success ? "短信发送成功!" : "短信发送失败,请检查模组状态"; 992 | } 993 | 994 | String html = R"rawliteral( 995 | 996 | 997 | 998 | 999 | 1000 | 发送结果 1001 | 1007 | 1008 | 1009 |
1010 |

%ICON% %MSG%

1011 |

3秒后返回发送页面...

1012 |
1013 | 1014 | 1015 | )rawliteral"; 1016 | 1017 | html.replace("%CLASS%", success ? "success" : "error"); 1018 | html.replace("%ICON%", success ? "✅" : "❌"); 1019 | html.replace("%MSG%", resultMsg); 1020 | 1021 | server.send(200, "text/html", html); 1022 | } 1023 | 1024 | // 处理Ping请求 1025 | void handlePing() { 1026 | if (!checkAuth()) return; 1027 | 1028 | Serial.println("网页端发起Ping请求"); 1029 | 1030 | // 清空串口缓冲区 1031 | while (Serial1.available()) Serial1.read(); 1032 | 1033 | // 先激活PDP上下文(数据连接) 1034 | Serial.println("激活数据连接..."); 1035 | String activateResp = sendATCommand("AT+CGACT=1,1", 10000); 1036 | Serial.println("CGACT响应: " + activateResp); 1037 | 1038 | // 检查激活是否成功(OK或已激活的情况) 1039 | bool networkActivated = (activateResp.indexOf("OK") >= 0); 1040 | if (!networkActivated) { 1041 | Serial.println("数据连接激活失败,尝试继续执行..."); 1042 | } 1043 | 1044 | // 清空串口缓冲区 1045 | while (Serial1.available()) Serial1.read(); 1046 | delay(500); // 等待网络稳定 1047 | 1048 | // 发送MPING命令,ping 8.8.8.8,超时30秒,ping 1次 1049 | Serial1.println("AT+MPING=\"8.8.8.8\",30,1"); 1050 | 1051 | // 等待响应 1052 | unsigned long start = millis(); 1053 | String resp = ""; 1054 | bool gotOK = false; 1055 | bool gotError = false; 1056 | bool gotPingResult = false; 1057 | String pingResultMsg = ""; 1058 | 1059 | // 等待最多35秒(30秒超时 + 5秒余量) 1060 | while (millis() - start < 35000) { 1061 | while (Serial1.available()) { 1062 | char c = Serial1.read(); 1063 | resp += c; 1064 | Serial.print(c); // 调试输出 1065 | 1066 | // 检查是否收到OK 1067 | if (resp.indexOf("OK") >= 0 && !gotOK) { 1068 | gotOK = true; 1069 | } 1070 | 1071 | // 检查是否收到ERROR 1072 | if (resp.indexOf("+CME ERROR") >= 0 || resp.indexOf("ERROR") >= 0) { 1073 | gotError = true; 1074 | pingResultMsg = "模组返回错误"; 1075 | break; 1076 | } 1077 | 1078 | // 检查是否收到Ping结果URC 1079 | // 成功格式: +MPING: 1,8.8.8.8,32,xxx,xxx 1080 | // 失败格式: +MPING: 2 或其他 1081 | int mpingIdx = resp.indexOf("+MPING:"); 1082 | if (mpingIdx >= 0) { 1083 | // 找到换行符确定完整的一行 1084 | int lineEnd = resp.indexOf('\n', mpingIdx); 1085 | if (lineEnd >= 0) { 1086 | String mpingLine = resp.substring(mpingIdx, lineEnd); 1087 | mpingLine.trim(); 1088 | Serial.println("收到MPING结果: " + mpingLine); 1089 | 1090 | // 解析结果 1091 | // +MPING: [,,,