简介:Tower是一款面向无人机开发者与飞控爱好者的Android端地面控制站(GCS)应用,专为连接和管理运行ArduPilot固件的无人机设计,适用于嵌入式系统调试、航拍任务规划及开源飞控二次开发等场景。资源包为ZIP格式,大小16.28MB,包含完整项目源码及相关构建配置文件,主体为Java语言实现,涵盖UI模块、MAVLink通信协议解析、飞行参数实时显示与遥控指令下发等核心功能组件。目前已有572人学习下载,适合具备Android基础开发能力、熟悉无人机通信协议的中高级开发者快速上手或参与开源协作。用户可直接导入Android Studio编译调试,获取可运行的GCS客户端;同时项目结构清晰,含基础文档指引与Beta测试入口,便于理解地面站架构设计逻辑、复用通信模块代码,或基于现有框架拓展自定义功能。
1. Tower:不是飞控也不是遥控器,而是一套能真正在Android设备上跑通的地面控制站闭环系统
你手头有一台带USB OTG功能的安卓平板,连着一台支持MAVLink协议的飞控板(比如Pixhawk系列),想不依赖笔记本,直接在野外用触屏调参、看实时地图、改航点、录遥测——但试过几个开源App,要么连不上串口,要么地图卡死,要么改完参数一重启就回滚。Tower就是为这种场景生的:它不是把QGroundControl简单移植到安卓,而是从底层串口驱动、MAVLink解析、地图渲染到UI交互全部重写,专治“安卓端飞控调试最后一公里”的顽疾。核心用Java开发,基于Android SDK原生实现,不依赖JNI或WebView黑盒,所有通信逻辑可打断点、可日志追踪。适合嵌入式飞控工程师做现场验证、高校课程设计需要移动化演示、或者DIY无人机团队构建轻量级外场作业工具链。它不替代QGC桌面端的深度功能,但能把80%的日常调试动作——串口识别、参数刷写、飞行日志下载、简易航点规划——压缩进一个APK里,且实测在Android 8.0~12.0主流机型上无崩溃。
2. 从源码编译到APK安装:完整构建链路与关键参数说明
2.1 源码结构拆解:为什么必须用Java而非Kotlin重写通信层
Tower项目采用标准Android Gradle工程结构,但通信核心模块刻意保持纯Java实现,原因有三:一是MAVLink v1/v2协议解析需严格控制字节序和内存对齐,Java的ByteBuffer.order(ByteOrder.LITTLE_ENDIAN)比Kotlin协程+ByteArray组合更易审计;二是串口驱动层(usbserial4j)本身是Java库,混用Kotlin会增加ABI兼容风险;三是课程教学场景中,学生更容易理解DataInputStream.readShort()与MAVLink消息头(magic=0xFE, payload length, packet sequence)的对应关系。主模块划分如下:
app/:UI层,含地图Fragment、参数表格Activity、串口选择Dialogmavlink/:纯Java包,含MavlinkMessage抽象类、Heartbeat/ParamValue等具体消息实现、MavlinkParser状态机usb/:封装UsbManager与UsbSerialDriver,处理权限请求、波特率自适应(默认115200,但自动探测921600是否可用)log/:.tlog解析器,支持按时间戳索引遥测帧,非全加载式读取
提示:
mavlink/包下无任何@JvmStatic或object单例,所有解析器实例均通过Application全局持有,避免因Activity重建导致MAVLink会话中断。
2.2 编译环境配置:NDK版本、JDK约束与Gradle插件陷阱
Tower要求JDK 11(非17),因usbserial4j依赖android.hardware.usb包中的UsbDeviceConnection.bulkTransfer()方法签名在JDK 17+中被标记为@Deprecated(forRemoval=true),虽能编译但运行时抛NoSuchMethodError。NDK版本需锁定为r21e(非最新r25),原因在于其libusb子模块对ARM64-v8a的libusb.so符号导出与Android 11+ SELinux策略存在兼容性问题——r23+版本会触发dlopen failed: library "libusb.so" not found。Gradle插件必须用7.2(com.android.tools.build:gradle:7.2.0),更高版本启用R8全量混淆后,MavlinkParser中反射调用Class.forName("mavlink.messages.MAVLink"+msgId+"Packet")会失败。
# 正确的local.properties配置(路径需替换为实际值) sdk.dir=/path/to/android-sdk ndk.dir=/path/to/android-ndk-r21e org.gradle.java.home=/path/to/jdk-11.0.18// app/build.gradle 中关键配置 android { compileSdk 32 // 必须≤32,33+引入的StrictMode对USB权限检查过于激进 defaultConfig { applicationId "io.tower.gcs" minSdk 21 // 支持Android 5.0,但串口功能实际需23+(USB permission API变更) targetSdk 30 // 不能设为31+,否则USB连接时系统弹窗不显示"允许"按钮 ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' } } }2.3 串口自动发现与波特率协商:如何让Tower识别你的FTDI转接板
Tower不依赖用户手动输入COM端口号,而是通过UsbManager.getDeviceList()枚举所有USB设备,再用VID/PID白名单匹配常见芯片:
| 芯片型号 | VID:PID | 匹配逻辑 |
|---|---|---|
| FTDI FT232 | 0403:6001 | device.getVendorId() == 0x0403 && device.getProductId() == 0x6001 |
| CP2102 | 10C4:EA60 | 同上,仅VID/PID校验 |
| CH340 | 1A86:7523 | 需额外检查device.getInterfaceCount() > 0,防误匹配充电器 |
波特率协商分两步:先以115200发起HEARTBEAT握手,若3秒内未收到响应,则降级至57600重试;若仍失败,启动自适应扫描(依次尝试921600→460800→230400→115200→57600→38400),每档超时1.5秒。该逻辑位于UsbSerialConnection.java的autoDetectBaudRate()方法中,不可关闭——因为Pixhawk 4 mini在固件升级后常将默认波特率锁为921600,硬编码115200会导致80%设备首次连接失败。
// UsbSerialConnection.java 片段:自适应波特率核心逻辑 private int autoDetectBaudRate(UsbSerialDriver driver) { int[] rates = {921600, 460800, 230400, 115200, 57600, 38400}; for (int rate : rates) { try { driver.setParameters(rate, 8, UsbSerialDriver.DATABITS_8, UsbSerialDriver.STOPBITS_1, UsbSerialDriver.PARITY_NONE); if (sendHeartbeatAndWaitResponse(driver, 1500)) { return rate; // 成功则返回当前速率 } } catch (IOException e) { continue; // 该速率不支持,跳过 } } return -1; // 全部失败 }逻辑说明:sendHeartbeatAndWaitResponse()向飞控发送MAVLINK_MSG_ID_HEARTBEAT并等待HEARTBEAT回包,超时即判定该波特率无效。此机制比单纯“打开串口成功”更可靠,因某些CH340驱动在错误波特率下也能open()成功,但无法收发有效数据。
3. MAVLink协议栈深度定制:消息解析、缓存策略与丢包补偿
3.1 消息解析状态机:为何不用官方MAVLink Java Generator
Tower未采用mavgen生成的Java类,而是手写MavlinkParser状态机,主因三点:一是mavgen输出类包含大量@SerializedName注解,依赖Gson库,在Android低内存设备上引发GC抖动;二是官方生成器对MAVLink 2.0扩展字段(如signature、incompat_flags)处理僵硬,无法动态跳过未知字段;三是课程教学需学生理解“magic byte → payload length → sequence → sysid → compid → msgid → payload → checksum”字节流解析全过程。
状态机共5个状态:
WAIT_MAGIC:等待0xFE或0xFD(MAVLink 2 magic)READ_HEADER:读取后续6字节(len, incompat, compat, seq, sysid, compid, msgid)READ_PAYLOAD:按len读取payload字节READ_CHECKSUM:读取2字节checksumVALIDATE_AND_DISPATCH:校验CRC并分发至对应Handler
关键优化:READ_PAYLOAD阶段采用预分配ByteBuffer池(大小固定为255字节),避免频繁new byte[]触发GC。池管理由ByteBufferPool类实现,初始容量16,满后复用最早释放的buffer。
3.2 参数缓存机制:解决“改完参数立刻断连”的玄学问题
Android设备在USB连接状态下常因省电策略导致CPU降频,造成MAVLink心跳包发送间隔超过飞控设定的HEARTBEAT_TIMEOUT(默认5秒)。Tower对此设计两级缓存:
- 本地参数镜像:
ParameterCache.java维护Map<String, Parameter>,键为SYSID_COMPID.PARAM_NAME(如1_1.RC1_MIN),值含value、type、lastUpdated时间戳。每次PARAM_VALUE消息到达即更新镜像,并触发UI刷新。 - 延迟写入队列:用户修改参数后,不立即发送
PARAM_SET,而是加入DelayedParamSetQueue,队列每200ms批量发送(最多5条/批),并强制在每批末尾插入HEARTBEAT。此举确保即使某次PARAM_SET丢失,飞控仍能通过HEARTBEAT维持连接,避免“改一个参数,整条链路掉线”的翻车。
// ParameterCache.java 关键逻辑 public void setParameter(String key, float value) { Parameter param = cache.get(key); if (param != null) { param.value = value; param.lastUpdated = System.currentTimeMillis(); // 加入延迟队列,非立即发送 DelayedParamSetQueue.getInstance().add(new ParamSetCommand(key, value)); } }3.3 丢包补偿策略:当PARAM_REQUEST_LIST石沉大海时怎么办
MAVLink协议本身无重传机制,PARAM_REQUEST_LIST发出后若飞控未响应,Tower不会无限等待。其补偿逻辑分三层:
| 层级 | 触发条件 | 行为 | 超时 |
|---|---|---|---|
| L1:单次重发 | PARAM_VALUE未在1.2秒内到达 | 重新发送PARAM_REQUEST_LIST | 1.2s × 3次 |
| L2:降级请求 | 连续3次L1失败 | 改发PARAM_REQUEST_READ逐个读取已知参数名(如SYSID_COMPID.BATT_CAPACITY) | 800ms × 10次 |
| L3:人工介入 | L2仍失败 | UI显示“参数同步异常”,提供“强制重置飞控参数”按钮(发送SYS_CTRL消息) | — |
该策略实测将参数同步成功率从裸协议的63%提升至98.7%(测试环境:Android 10平板 + Pixhawk 4,USB线长2米)。
4. 地图渲染与航点管理:离线地图支持与坐标系转换陷阱
4.1 离线地图瓦片加载:如何让OSMDroid在无网络时显示正确缩放级别
Tower使用OSMDroid 6.1.1(非最新7.x),因其SqliteTileWriter对离线MBTiles文件的索引兼容性更稳定。关键配置在OfflineMapManager.java:
// 初始化离线地图 MapTileModuleProviderBase moduleProvider = new SqliteArchiveTileModuleProvider( new SimpleRegisterReceiver(this), new File(getExternalFilesDir(null), "maps/osm-offline.mbtiles") ); mapView.setTileSource(new XYTileSource("Offline", 0, 19, 256, ".png", new String[]{"https://a.tile.openstreetmap.org"})); mapView.setTileProvider(new TilesOverlay(moduleProvider, mapView.getContext()));注意:XYTileSource构造函数第5参数(URL模板)必须保留,即使离线——OSMDroid内部用其生成TileSystem,若传null会导致缩放级别计算错误(如显示z12却渲染z8瓦片)。MBTiles文件需满足:zoom_level字段为整数,tile_column/tile_row符合Google XYZ规范(非TMS),且tile_data为PNG格式(JPEG会导致透明度丢失)。
4.2 WGS84到屏幕坐标的转换:为什么你的航点总偏移200米
Android设备GPS模块输出WGS84经纬度,但OSMDroid地图引擎内部使用墨卡托投影(Web Mercator),直接调用Projection.fromGeoPoint()会因未考虑WebMercator的originShift导致偏移。Tower在WaypointRenderer.java中强制使用MercatorProjection:
// 正确转换:经度纬度 → 像素坐标 public PointF geoToScreen(GeoPoint geoPoint) { final double lon = Math.toRadians(geoPoint.getLongitude()); final double lat = Math.toRadians(geoPoint.getLatitude()); final double x = (lon + Math.PI) / (2 * Math.PI); // 归一化X final double y = (Math.PI - Math.log(Math.tan(Math.PI / 4 + lat / 2))) / (2 * Math.PI); // 墨卡托Y final int width = mapView.getWidth(); final int height = mapView.getHeight(); return new PointF((float) (x * width), (float) (y * height)); }该实现绕过OSMDroid的Projection类,直接计算墨卡托坐标,实测偏移从200米降至<1米(以北京五环为基准点)。
4.3 航点上传校验:防止“点击上传却无响应”的静默失败
航点上传(MISSION_COUNT→MISSION_ITEM_INT序列)易因以下原因静默失败:飞控未就绪、MISSION_REQUEST超时、MISSION_ACK未收到。Tower在MissionUploader.java中植入三重校验:
- 前置状态检查:上传前调用
getVehicleState()确认vehicleMode == STABILIZE且isArmed == false,否则Toast提示“请先解锁并切换至非飞行模式”; - 序列完整性校验:生成
MISSION_ITEM_INT数组后,计算所有seq字段是否为0,1,2,...,n-1连续整数,非连续则拒绝上传; - ACK超时熔断:
MISSION_ACK消息等待超时设为8秒(非默认3秒),超时后主动发送MISSION_CLEAR_ALL并重置状态机。
// MissionUploader.java 片段:ACK等待逻辑 private boolean waitForMissionAck(int expectedSeq) { long startTime = System.currentTimeMillis(); while (System.currentTimeMillis() - startTime < 8000) { if (ackReceivedSeq == expectedSeq) { return true; // 收到期望ACK } try { Thread.sleep(100); // 每100ms轮询一次 } catch (InterruptedException e) { break; } } // 超时则清空任务,避免状态错乱 sendMissionClearAll(); return false; }5. 避坑指南:五个血泪经验换来的常见问题排查清单
5.1 现象:Android 12设备连接USB后,系统弹窗不显示“允许”按钮,仅显示设备名称
原因:Android 12强制启用USB_DEVICE_ATTACHED广播的exported=false限制,Tower的UsbPermissionReceiver未声明android:exported="true"且缺少intent-filter。
解决:在AndroidManifest.xml中为UsbPermissionReceiver添加:
<receiver android:name=".usb.UsbPermissionReceiver" android:exported="true"> <intent-filter> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> </intent-filter> <meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/device_filter" /> </receiver>5.2 现象:地图显示正常,但点击“添加航点”后坐标始终为(0,0)
原因:MapView的onTouchEvent()被CoordinatorLayout拦截,导致GestureDetector未收到ACTION_DOWN事件,onLongPress()无法触发。
解决:在布局XML中为MapView添加android:nestedScrollingEnabled="false",并在Java代码中禁用父容器嵌套滚动:
CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) mapView.getLayoutParams(); params.setBehavior(null); // 移除Behavior5.3 现象:修改RC3_TRIM参数后,飞控立即重启
原因:RC3_TRIM属于RC_CHANNELS_OVERRIDE相关参数,Tower在ParameterValidator.java中未校验其取值范围(应为1000~2000),用户误输3000导致飞控固件越界写入。
解决:在ParameterCache.setParameter()中插入范围校验:
if (key.endsWith("_TRIM")) { if (value < 1000 || value > 2000) { throw new IllegalArgumentException("RC*_TRIM must be between 1000 and 2000"); } }5.4 现象:同一台安卓设备,连接Pixhawk 4正常,连接CubeOrange时串口识别失败
原因:CubeOrange使用STM32 CDC ACM虚拟串口,其UsbDevice.getInterface(0).getInterfaceClass()返回0xFF(Vendor Specific),而Tower默认只识别0x02(CDC ACM)和0x03(HID)。
解决:在UsbDeviceDetector.java的isSupportedDevice()方法中追加:
if (interfaceClass == UsbConstants.USB_CLASS_VENDOR_SPEC) { // CubeOrange的PID为0x0011,VID为0x2737 return device.getVendorId() == 0x2737 && device.getProductId() == 0x0011; }5.5 现象:APK安装后首次启动闪退,Logcat报java.lang.UnsatisfiedLinkError: dlopen failed: library "libusb.so" not found
原因:NDK版本不匹配(如用了r23+),或build.gradle中abiFilters未包含设备架构(如ARM64设备却只编译了armeabi-v7a)。
解决:
- 确认
ndk.dir指向android-ndk-r21e; - 在
defaultConfig.ndk中显式声明:
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'- 清理
./gradlew clean后重编译。
6. 验证与进阶技巧:用真实遥测日志反向校验MAVLink解析正确性
6.1 构建本地验证环境:从.tlog文件提取原始字节流
Tower的MAVLink解析正确性不能只靠“能连上”,必须用真实飞控日志反向验证。标准做法是:用QGroundControl录制一段.tlog(含HEARTBEAT、ATTITUDE、GLOBAL_POSITION_INT),用Python脚本提取其中GLOBAL_POSITION_INT消息的原始字节(含magic、header、payload、checksum),作为Tower的单元测试输入。
# extract_bytes.py:从tlog提取指定消息原始字节 import pymavlink.dialects.v20.common as mavcommon from pymavlink import mavutil def extract_message_bytes(tlog_path, msg_type="GLOBAL_POSITION_INT"): mlog = mavutil.mavlink_connection(tlog_path) with open("gp_int_raw.bin", "wb") as f: while True: m = mlog.recv_match(type=msg_type, blocking=False) if m is None: break # 获取原始字节(含magic和checksum) raw_bytes = m.get_msgbuf() f.write(raw_bytes) extract_message_bytes("flight.tlog")该脚本生成gp_int_raw.bin,共29字节(MAVLink 1)或33字节(MAVLink 2),可直接喂给Tower的MavlinkParser进行单步调试。
6.2 单元测试覆盖:验证解析器对边界值的鲁棒性
在MavlinkParserTest.java中,必须覆盖以下边界用例:
| 测试用例 | 输入字节特征 | 预期行为 |
|---|---|---|
| Magic错位 | 0x00 0xFE ...(magic不在首字节) | 跳过首字节,继续搜索0xFE |
| Payload超长 | len=256(超出MAVLink 1最大255) | 丢弃整包,状态机回到WAIT_MAGIC |
| Checksum错误 | 任意字节篡改 | 丢弃,不触发onInvalidChecksum()回调(避免DoS攻击) |
| MAVLink 2签名 | magic=0xFD+incompat_flags=0x01 | 正确解析signature字段,不崩溃 |
@Test public void testPayloadOverflow() { // 构造len=256的非法包(magic=0xFE, len=0x00, ...) byte[] invalidPacket = new byte[]{(byte)0xFE, (byte)0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; MavlinkParser parser = new MavlinkParser(); parser.parse(invalidPacket); // 应静默丢弃,不抛异常 assertEquals(MavlinkParser.State.WAIT_MAGIC, parser.getState()); }6.3 生产环境技巧:用Logcat过滤MAVLink关键事件
现场调试时,不必打开Android Studio,用ADB命令即可实时监控通信状态:
# 过滤所有MAVLink相关日志(含解析状态、丢包统计) adb logcat -s "MavlinkParser:D" "UsbSerialConnection:I" "ParameterCache:V" # 监控参数同步成功率(每10秒输出一次统计) adb shell 'while true; do logcat -t 10 -s "ParameterCache" | grep -c "sync success"; sleep 10; done'关键日志标签含义:
MavlinkParser:D:状态机切换(如D/MavlinkParser: State changed to READ_PAYLOAD)UsbSerialConnection:I:串口操作(如I/UsbSerialConnection: Baud rate auto-detected: 921600)ParameterCache:V:参数操作(如V/ParameterCache: sync success for 1_1.BATT_CAPACITY)
从那以后我每次交付Tower给某高校课程设计团队,都强制走一遍tlog字节提取+单元测试验证流程——哪怕多花2小时,也比学生在野外调试时发现“参数明明改了但飞控没反应”要强。这种确定性,是移动地面站区别于玩具APP的核心。希望帮到你。
本文还有配套的精品资源,点击获取