我做了一个 DJI 第一代 4G 模块模式切换工具

最近,我为 DJI 第一代 4G 蜂窝模块开发了一个 macOS 桌面工具:DJIModeSwitcher。

它可以识别连接到 Mac 的目标模块,并在“原模式”和“USB 上网模式”之间切换。整个过程包括设备检测、AT 接口探测、参数写入、模块重启、USB 重新枚举和最终结果验证。

项目已经开源:

https://github.com/hiwangchuan/DJIModeSwitcher

当前版本为 1.1。

这是一个由社区独立开发的第三方项目,与 DJI、大疆创新、Quectel、运营商或相关硬件厂商不存在授权、隶属或合作关系。

为什么要做这个工具

第一代 DJI 4G 模块本质上包含蜂窝通信和 USB 接口能力,但不同工作模式下,它向主机暴露的 USB 接口组合并不相同。

在合适的 AT 接口上,可以通过以下参数读取当前 USB 网络模式:

AT+QCFG="usbnet"?

切换到 USB 上网模式:

AT+QCFG="usbnet",1

恢复原模式:

AT+QCFG="usbnet",0

写入参数之后,还需要重启模块:

AT+CFUN=1,1

看起来只是几条命令,但真正做成一个普通用户可以操作的桌面应用,问题会复杂很多:

  • 如何准确识别目标 USB 设备?
  • 如何从多个接口中找到真正的 AT 接口?
  • 如何避免把命令发到错误的 Bulk 端点?
  • 模块重启并从 USB 总线上消失时,如何判断这是正常现象还是操作失败?
  • 设备重新出现后,如何确认配置确实生效?
  • 如何保证最终用户不用安装 Homebrew 和 libusb?
  • 如何让应用在 macOS 13 及更高版本上运行?

DJIModeSwitcher 就是围绕这些问题开发的。

当前支持范围

当前版本支持:

  • Apple Silicon Mac
  • macOS 13.0 或更高版本
  • 目标 USB 标识:2CA3:4006
  • DJI 第一代 4G 模块
  • 原模式:usbnet=0
  • USB 上网模式:usbnet=1

暂时不支持 Intel Mac。

这个项目也不会扩展成通用 AT 终端。目前明确不包含:

  • 短信读取
  • SIM 或 eSIM 信息读取
  • APN 管理
  • 流量统计
  • 固件刷写
  • 账号登录
  • 云服务访问
  • 任意 AT 命令执行

限制功能范围是有意为之。对于会直接操作硬件的工具,我更倾向于让它只做少量、明确、可以验证的事情。

它是怎么工作的

一次完整的模式切换大致经过以下步骤:

识别 USB 设备
    ↓
枚举配置、接口和端点
    ↓
寻找成对的 Bulk IN/OUT 端点
    ↓
发送 AT 探测命令
    ↓
确认返回独立的 OK 行
    ↓
读取当前 usbnet 配置
    ↓
写入目标模式
    ↓
请求模块重启
    ↓
关闭旧 USB 句柄
    ↓
等待设备断开并重新出现
    ↓
重新连接设备
    ↓
再次读取配置并验证结果

其中比较关键的一点,是不能简单地认为“第一个 Bulk 接口就是 AT 接口”。

应用会枚举所有可能的接口和 alternate setting,然后向候选端点发送最基本的 AT 命令。只有收到格式正确、独立成行的 OK 响应后,才会接受这个接口。

这样可以尽量避免把后续命令发送到错误的 USB 接口。

为什么不能在重启时立即判定失败

模块收到 AT+CFUN=1,1 后会主动重启。

从主机的角度看,USB 设备可能会突然消失,正在进行的传输也可能返回超时、I/O 错误或者设备断开。如果把这些结果直接当作失败,应用就会在正常重启过程中错误地报告操作失败。

DJIModeSwitcher 会将重启阶段的断开视为预期事件:

  1. 关闭旧设备句柄;
  2. 等待设备从 USB 总线消失;
  3. 等待设备重新枚举;
  4. 重新寻找 AT 接口;
  5. 再次读取 usbnet;
  6. 只有读取结果与目标模式一致时才报告成功。

换句话说,应用不会因为“命令已经发出去”就宣告完成,而是等待模块回来并验证最终状态。

项目架构

项目采用 SwiftPM 管理,主要分为四个部分。

DJIModeCore

使用 C 编写的底层核心,负责:

  • 动态加载 libusb
  • USB 设备枚举
  • 接口和端点探测
  • Bulk 数据传输
  • AT 响应解析
  • 固定命令白名单
  • libusb 错误映射

C 层不会接受任意 AT 命令,只允许项目预先定义的有限命令。

DJIModeKit

Swift 服务层,负责:

  • 使用 actor 串行化设备访问
  • 设备出现和消失监控
  • 模式切换状态机
  • 重启与重新枚举协调
  • 错误转换
  • 诊断日志
  • Mock 设备和演示流程

DJIModeSwitcher

基于 SwiftUI 的 macOS 图形界面,负责:

  • 显示设备连接状态
  • 显示当前模式
  • 发起切换或恢复操作
  • 操作确认
  • 阶段进度显示
  • 错误提示
  • 诊断信息复制
  • 操作期间的退出保护

mode-switcher

用于开发和协议验证的命令行工具,提供:

swift run mode-switcher detect
swift run mode-switcher status
swift run mode-switcher network
swift run mode-switcher restore

如何做到不依赖 Homebrew

这个项目使用 libusb 与模块通信。

开发阶段最简单的方式可能是要求用户先运行:

brew install libusb

但这对最终用户并不友好。用户拿到一个 macOS 应用,不应该还要理解 Homebrew、动态库路径或者命令行环境。

因此,发行版本会将 libusb 直接放入应用包:

DJIModeSwitcher.app/
└── Contents/
    ├── MacOS/
    │   └── DJIModeSwitcher
    └── Frameworks/
        └── libusb-1.0.0.dylib

运行时优先通过相对路径加载:

@executable_path/../Frameworks/libusb-1.0.0.dylib

最终用户不需要安装:

  • Homebrew
  • Xcode
  • Swift
  • Python
  • 单独的 libusb

除了应用内置的 libusb,程序只链接 macOS 自带的系统框架。

固定并验证第三方源码

首次从源码构建时,脚本会下载官方 libusb 1.0.30 源码包。

下载完成后会验证固定的 SHA-256:

fea36f34f9156400209595e300840767ab1a385ede1dc7ee893015aea9c6dbaf

只有校验通过才会继续编译。

这样做不能解决所有供应链安全问题,但至少可以防止下载内容在不知情的情况下发生变化。

libusb 保持其 LGPL-2.1-or-later 许可证。应用包中也会同时包含对应许可证和第三方组件声明。

macOS 13 兼容问题

在打包过程中,我还遇到了一个比较隐蔽的问题。

使用较新的 macOS SDK 编译 libusb 时,配置脚本会检测到 pipe2()。但是这个符号只存在于更新的 macOS 版本中。如果直接使用检测结果,即使动态库声明的部署目标是 macOS 13,运行时仍可能在旧系统上出现符号缺失。

最终的处理方式是:

  • 明确设置 libusb 的最低部署目标为 macOS 13;
  • 禁用配置阶段对 pipe2() 的使用;
  • 强制使用兼容性更好的 pipe() 实现;
  • 检查最终 Mach-O 的最低系统版本;
  • 使用 nm 确认动态库不再引用 _pipe2。

这也是为什么“编译成功”并不等于“真的兼容目标系统”。

ZIP 和 PKG 两种交付方式

项目目前支持生成两种发行格式。

ZIP 便携包:

./script/archive.sh

输出:

dist/DJIModeSwitcher-1.1-arm64.zip

PKG 安装包:

./script/package.sh

输出:

dist/DJIModeSwitcher-1.1-arm64.pkg

PKG 会将完整应用安装到:

/Applications/DJIModeSwitcher.app

PKG 中没有用于联网下载依赖的安装脚本,libusb 已经包含在应用本身。

签名、公证和 Gatekeeper

“依赖已经全部打包”与“其他 Mac 可以无警告安装”是两个不同的问题。

前者解决的是运行环境:

  • 不需要 Homebrew;
  • 不需要外部 libusb;
  • 不需要开发工具;
  • 不需要首次启动下载组件。

后者属于 Apple 的分发信任体系,需要:

  • Developer ID Application 证书;
  • Developer ID Installer 证书;
  • Hardened Runtime;
  • Apple Notary Service 公证;
  • 将公证票据 staple 到应用或 PKG。

如果没有 Developer ID 和公证,应用即使技术上完全可运行,仍可能被 Gatekeeper 拦截。

项目已经准备了签名和公证脚本入口,但正式公开发布仍需要发布者自己的 Apple Developer 证书和账号。

日志与隐私

应用不包含:

  • 用户账号
  • 云端 API
  • 自动更新服务
  • 运行时遥测
  • 远程日志上传

诊断日志只写入本机:

~/Library/Logs/DJIModeSwitcher/

日志采用 2 MB × 5 文件轮转策略。

日志可能包含 USB 标识、接口号、操作阶段和 AT 响应。因此,在公开分享诊断信息之前,仍建议用户先检查内容。

无硬件演示模式

为了让开发和界面测试不完全依赖真实模块,项目提供了 Mock 传输层和演示模式:

DJI_MODE_SWITCHER_DEMO=1 ./script/build_and_run.sh

演示模式可以完整体验:

  • 设备识别
  • 模式读取
  • 操作确认
  • 参数写入
  • 模拟重启
  • USB 重新连接
  • 最终验证

不过,Mock 测试不能代替真实硬件测试。

当前仍需完成的工作

目前项目的主要代码、独立打包和文档已经完成,但正式发布仍有一些外部门槛:

  • 使用更多第一代 DJI 4G 模块和固件版本测试;
  • 在多台 Apple Silicon Mac 上验证;
  • 在 macOS 13 实机上进行最低版本测试;
  • 完成至少 50 次“原模式 → 上网模式 → 原模式”耐久循环;
  • 使用 Developer ID 完成正式签名;
  • 完成 Apple 公证;
  • 在没有 Homebrew 的干净 Mac 上验证首次安装。

对于硬件工具来说,我认为明确写出“还没有验证什么”,和说明“已经实现什么”同样重要。

开源地址

项目源码、构建脚本、架构说明、测试指南和分发文档均已发布到 GitHub:

https://github.com/hiwangchuan/DJIModeSwitcher

项目原创代码和文档采用 Apache License 2.0。

libusb 保持其原有 LGPL-2.1-or-later 许可证。

写在最后

这个项目表面上只是切换一个 usbnet 参数,但真正实现时,涉及 USB 枚举、接口探测、AT 通信、设备重启、状态机、动态库封装、macOS 版本兼容、代码签名和开源许可证等多个问题。

对我来说,最有价值的部分并不是“成功发送了几条 AT 命令”,而是把一次容易出错的硬件操作,整理成一个边界明确、过程可见、结果可验证的桌面工具。

如果你手上有第一代 DJI 4G 模块,并且愿意参与不同固件、Mac 型号或连接方式的测试,欢迎在 GitHub 提交 Issue 或测试结果。