作者:yyszone
标签:嵌入式开发 / ESP32 / Android / 逆向协议 / 智能家居


一、前言:当智能家居遇上“安卓杀后台”

在智能家居的日常折腾中,许多人都有过这样的需求:检测家人的手机电量,当电量低于一定阈值时,自动开启桌面插座充电;充到一定时长或电量充足时,自动切断电源,防止电池过充受损。

通常情况下,实现这个需求的路线无非两种:

  1. 手机端装 App 主动上报:利用 Tasker、MacroDroid 甚至 Home Assistant Companion 定期向局域网发 Webhook。
  2. 走云端中转:通过手机厂商的云服务 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 握手包被静默丢弃。
  • 解决办法:在初始化网络后强制执行:
    WiFi.setSleep(false); // 彻底关闭 Wi-Fi 芯片休眠,保持网卡全功率全天候监听
    
    加入后,局域网 Web 请求响应从 20 秒超时变为 10 毫秒秒开。

坑 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% (无需动作)

关键控制特性:

  1. 排队轮询(Queue Worker): 多台设备不在同一时间并发探测,而是由非阻塞状态机逐一发起连接、读取并断开,每台之间间隔 500ms。哪怕某台手机断网关机,也仅会消耗设定的 2 秒超时,绝不卡死系统主线程上的风扇 PWM 控制或插座定时。
  2. 防死锁与防过充: 触发充电后,插座开启时长(默认 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探讨交流。)