作者:yyszone
标签:嵌入式开发 / ESP32 / Android / 逆向协议 / 智能家居
一、前言:当智能家居遇上“安卓杀后台”
在智能家居的日常折腾中,许多人都有过这样的需求:检测家人的手机电量,当电量低于一定阈值时,自动开启桌面插座充电;充到一定时长或电量充足时,自动切断电源,防止电池过充受损。
通常情况下,实现这个需求的路线无非两种:
- 手机端装 App 主动上报:利用 Tasker、MacroDroid 甚至 Home Assistant Companion 定期向局域网发 Webhook。
- 走云端中转:通过手机厂商的云服务 API 拉取设备状态。
然而,这两条路都有着致命的痛点:
- 国内安卓激进的“杀后台”机制:哪怕你把电池无限制、自启动全部放开,手机一旦灭屏进入深度休眠(Doze 模式),第三方 App 依然大概率会被无情清理或冻结网络,导致电量无法按时上报。
- 隐私与断网风险:云端中转受制于公网与厂商 API,一旦外网故障,整个联动直接瘫痪。
“能不能反客为主——让局域网里的单片机,主动去问手机‘你还有多少电’?”
经过一番技术摸索,答案浮出水面:利用 Android 系统原生自带的 Wi-Fi ADB(无线调试协议),让一块两美元的 ESP32-C3 裸跑 ADB Client 协议栈,直接向手机发起静默查询。
本文将完整记录如何在资源极度有限的 ESP32-C3(单核 RISC-V)上,从底层协议拼包、RSA 密码学鉴权,到跨越单核看门狗、射频休眠与老旧基带兼容等一系列“血泪踩坑”,最终打造出一个全天候免维护、不依赖任何手机端第三方 App 的智能电量监控与继电器联动系统。
二、原理拆解:ADB 协议在单片机上的极简裸跑
很多人以为,ADB 必须依赖几百兆的 Android SDK、Python 脚本或一台常开的 Linux 软路由。但从网络层剥离来看:ADB 本质上只是一个运行在 TCP 5555 端口上的自定义二进制协议。
只要单片机能发 TCP 报文,就能当 ADB 客户端。
1. 固定 24 字节的数据帧头
ADB 的所有通信报文,头部均由一个严格对齐的 24 字节结构体 构成:
struct adb_message {
uint32_t command; // 命令魔数: CNXN, AUTH, OPEN, OKAY, WRTE, CLSE
uint32_t arg0; // 本地通道ID / 协议版本等
uint32_t arg1; // 远端通道ID / 最大载荷长度等
uint32_t data_length; // 后续载荷的字节长度
uint32_t data_check; // 载荷所有字节相加的 32 位累加校验和
uint32_t magic; // command ^ 0xFFFFFFFF (异或校验)
};
2. 身份认证的“伪装术”
从 Android 4.2.2 起,Google 引入了严格的 RSA 鉴权机制。如果单片机直接连入,手机通常不会理会。
真实的通信时序如下:
ESP32 (Client) 手机后台 (adbd : 5555)
| |
(1) TCP 握手 | ========= 建立 TCP Socket 5555 端口 ============> |
| |
(2) 身份认证 | -------- A_CNXN (发起握手) --------------------> |
| <------- A_AUTH (手机抛出 20 字节随机挑战码) --- |
| |
| [调用 mbedtls 用电脑私钥计算 RSA-SHA1 签名] |
| |
| -------- A_AUTH (回传 256 字节数字签名) --------> |
| <------- A_CNXN (手机比对白名单指纹通过,握手成功) |
| |
(3) 发送指令 | -------- A_OPEN ("shell:dumpsys battery") ------> |
| <------- A_OKAY (打开 Shell 通道成功) ----------- |
| <------- A_WRTE (返回电池文本,如 level: 85) ---- |
| |
(4) 优雅断开 | -------- A_CLSE (关闭通道) ---------------------> |
| ========= 正常关闭 Socket 释放临时堆内存 =======> |
这里的绝妙之处在于“复用电脑的私钥凭据”:
平时我们用电脑调试手机时,勾选过“始终允许此计算机进行 USB 调试”,手机内部的 /data/misc/adb/adb_keys 就会记下这台电脑的 RSA 公钥指纹。
我们只需要从电脑里找到那个没有扩展名的私钥文件(Windows 位于 %USERPROFILE%\.android\adbkey,Mac/Linux 位于 ~/.android/adbkey),将其提取并持久化存入 ESP32 的 Flash 中。
当手机发来 A_AUTH 随机验证码时,ESP32 调用内部的 mbedtls 库,使用这枚私钥对随机码进行 RSA-SHA1 PKCS#1 v1.5 签名回传。手机一验签:“原来是那台受信任的电脑!”,屏幕甚至不需要亮起,便会在后台静默通过鉴权。
3. 读取电量
握手成功后,ESP32 向对端打开虚拟终端并发送:
shell:dumpsys battery
手机回传的输出文本中会包含:
Current Battery Service state:
level: 83
scale: 100
voltage: 4120
temperature: 280
...
ESP32 字符串匹配提取出 83,随后立刻主动发送 A_CLSE 并关闭 TCP 连接,整个交互在毫秒内完成。
三、踩坑实录:单核 RISC-V 上的硬核调试
在理论走通后,实际开发中遭遇了一系列令人发指的“暗坑”。以下是调试过程中最具代表性的几个技术断点:
坑 1:单核 RISC-V 与被饿死的“空闲看门狗”
在将代码移植到 ESP32-C3(单核 160MHz)后,板子频繁出现开机 5 秒后无限死机重启(BootLoop)。
- 排查发现:传统双核 ESP32 可以在 Core 1 跑死循环,系统任务在 Core 0 安然无恙;而 ESP32-C3 是单核架构,当我们在
loop()里疯狂调用webManager.handleClient()且没有任何延时时,CPU 负荷达到 100%,系统最低优先级的IDLE0空闲任务被活活饿死。 - 解决办法:FreeRTOS 的任务看门狗(TWDT)正是通过监控
IDLE0是否被执行来判定系统是否死锁。在loop()循环末尾加上一行delay(5);显式让出 CPU 时间片,5 秒死机重启的玄学问题瞬间根治。
坑 2:小米路由器的“假死”与 WiFi 射频休眠
当设备连入家用小米路由器(192.168.31.x)后,经常出现串口显示获取到 IP,但电脑/手机浏览器访问该 IP 却直接报 ERR_CONNECTION_TIMED_OUT。
- 原因剖析:ESP32 默认开启了
Modem-Sleep(射频休眠节能),依赖 DTIM 周期唤醒接收广播;而部分 Wi-Fi 6 路由器的省电策略较激进,对于单片机发起的微小单播连接包,路由器往往不会及时唤醒网卡,导致 TCP SYN 握手包被静默丢弃。 - 解决办法:在初始化网络后强制执行:
加入后,局域网 Web 请求响应从 20 秒超时变为 10 毫秒秒开。WiFi.setSleep(false); // 彻底关闭 Wi-Fi 芯片休眠,保持网卡全功率全天候监听
坑 3:老款低端机(红米 8A)的“射频水土不服”
在热点配网模式下,小米 13 连接热点秒开,而备用机红米 8A 却始终在“正在获取 IP”和“连接已断开”之间死循环。
- 原因剖析:红米 8A 搭载的高通骁龙 439 基带是 2019 年的老旧射频方案,默认不支持 40MHz 频宽的无密 SoftAP,而 ESP32-C3 默认使用了宽信道(HT40)。
- 解决办法:在底层严格限制配网热点的频宽与信道:
限制后,不管是老旧百元机还是最新旗舰,全部秒连。esp_wifi_set_bandwidth(WIFI_IF_AP, WIFI_BW_HT20); // 强制锁定 20MHz 标准频宽 esp_wifi_set_protocol(WIFI_IF_AP, WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N); WiFi.softAP("ESP32-C3-ADB-Setup", "", 6); // 锁定最干净的第 6 信道
坑 4:内存碎片引发的“网页腰斩”
当网页集成了风扇测速、双路插座、NAS 继电器控制以及 ADB 控制台后,HTML 源码达到了 7KB 左右。由于最初采用常规的 String + String 内存拼接,在 C3 仅剩几十 KB 连续堆内存的情况下,内存分配瞬间失败,导致发给浏览器的 HTML 在表格处直接腰斩,页面只加载到一半卡死。
- 解决办法:彻底抛弃堆内存反复分配拼接,改用预分配缓冲或直接流式输出(
sendContent),配合手机端localStorage本地缓存渲染,不但彻底终结了网页腰斩现象,还实现了打开网页 0 毫秒首屏秒开。
四、系统整合:打造全自动防过充智能充电桩
解决了底层 ADB 通信与稳定性之后,整个系统的自动化控制逻辑变得水到渠成:
[后台排队状态机]
│
├─► 设备 1 (192.168.31.182) ──► ADB 检测: 28% (绑定插座1)
│ │
│ └─► 电量 ≤ 30% 且 插座1当前为关闭状态?
│ │
│ ▼
│ [吸合继电器 1 (GPIO 6)]
│ [启动 40 分钟基于 millis() 的独立硬件倒计时]
│
├─► (间隔 500ms 让出 CPU,保护单核调度)
│
└─► 设备 2 (192.168.31.177) ──► ADB 检测: 82% (无需动作)
关键控制特性:
- 排队轮询(Queue Worker): 多台设备不在同一时间并发探测,而是由非阻塞状态机逐一发起连接、读取并断开,每台之间间隔 500ms。哪怕某台手机断网关机,也仅会消耗设定的 2 秒超时,绝不卡死系统主线程上的风扇 PWM 控制或插座定时。
- 防死锁与防过充: 触发充电后,插座开启时长(默认 40 分钟)直接记录在系统的高精度时钟变量上。即使期间局域网断开、手机被带出门断连,40 分钟倒计时一到,继电器也会准时“咔哒”一声切断供电,彻底杜绝锂电池彻夜过充发热老化。
五、性能与实用性总结
很多读者会关心:ESP32 跑这种复杂加密协议,能扛得住吗?
经过实测,该方案展现出了惊人的能效比:
- CPU 开销:RSA-2048 计算在硬件加速器辅助下仅占 30~50ms,按每小时轮询一次算,CPU 占用率不足 0.001%。
- 网络流量:单次握手+提取电量+断开,双向流量不超过 2 KB。
- 反向保活奇效:Android 11 默认拥有“7 天未连接自动撤销 USB 授权”的安全策略,ESP32 周期性的 ADB 握手打卡,正好反向重置了系统的 7 天计时,使得免密授权永远不过期。
结语
从最初面对 ERR_CONNECTION_TIMED_OUT、看门狗不断复位、未授权红字的迷茫,到最终看着继电器在手机电量掉到 28% 时精准自启并在 40 分钟后优雅关闭,这个项目的落地不仅解决了家庭设备充电的实际痛点,更证明了一件事:
即使是资源极其受限的微控制器,只要能穿透底层协议的迷雾,也完全有能力与现代复杂操作系统直接平起平坐地对话。
(本文代码基于 ESP32-C3 RISC-V 平台,在 Arduino-ESP32 Core 3.x 下编译验证通过。如对具体协议帧组包细节感兴趣,欢迎在评论区或 GitHub esp32_adb_test探讨交流。)
评论区(1 条)
发表评论⏳ 加载编辑器…
站长你好,我是一名SItesLink的维护人员,发现您的站点很有价值,诚邀您加入: https://siteslink.cn
冒昧打扰请见谅