无需 SDK,无需 BLE 配对流程——只需摄像头的固定 IP、一个 Dio 客户端,以及一些你若不了解就会悄然毁掉你一天的陷阱。
为什么选择 WiFi 而不是 Bluetooth?
大多数“连接 GoPro”教程都以 Bluetooth Low Energy 开始:扫描、配对、交换 GATT 特征,然后使用 BLE 开启 WiFi,然后无论如何都通过 WiFi 传输。这虽然可行,但过程繁琐——而且 BLE 对于用户真正想要的功能(即快速控制摄像头并从中提取素材)来说既慢又麻烦。
所以在这个应用中,我们完全跳过了 BLE。用户通过手机设置加入 GoPro 的 WiFi 接入点,从那时起,所有操作都是针对摄像头 Open GoPro API 的纯 HTTP 请求,地址固定为:
http://10.5.5.9:8080
摄像头控制、设置、媒体列表和文件下载都通过对该 IP 地址的 HTTP GET 请求完成。整个集成方案完全基于 Dart 和 dio 包实现——零原生 Kotlin/Swift 代码。这是亮点,但正如我们将在文末看到的,这也是设计脆弱之处。
流程如下:
加入 GoPro WiFi AP(手机设置)
→ 检测摄像头是否可达 (TCP 探测 10.5.5.9:8080)
→ 连接 + 识别型号 (GET /gopro/camera/state)
→ 启动心跳 (30s) + 状态轮询 (5s) 定时器
→ 控制:快门 / 模式 / 设置
→ 列出媒体文件 (GET /gopro/media/list)
→ 使用 turbo + 进度下载 (dio.download)
1. 无需 WiFi 扫描权限即可检测摄像头
第一直觉是枚举 WiFi SSID 并寻找以 GoPro 开头的网络。请不要这样做——那会引入位置权限和平台特定的 WiFi 扫描 API。
GoPro 始终位于相同的 IP 地址,因此检测方法很简单:我能否打开一个指向 10.5.5.9:8080 的 TCP socket?无需 HTTP,无需权限,无需 SSID 解析。
static const String goProIp = '10.5.5.9';
static const int goProPort = 8080;
static const Duration checkInterval = Duration(seconds: 2);
static const Duration connectionTimeout = Duration(seconds: 3);
Future<void> _checkConnection() async {
try {
final socket = await Socket.connect(goProIp, goProPort, timeout: connectionTimeout);
socket.destroy();
if (_currentState != GoProWifiState.connected) {
_currentState = GoProWifiState.connected;
_connectionStateController.add(_currentState);
}
} catch (e) {
if (_currentState != GoProWifiState.disconnected) {
_currentState = GoProWifiState.disconnected;
_connectionStateController.add(_currentState);
}
}
}
我们每 2 秒轮询一次,并通过流广播状态变化。连接屏幕订阅该流,并在 socket 打开的那一刻切换到“已连接”状态。由于没有真正的设备发现机制,服务会合成一个虚拟的 CameraDevice(以固定 IP 为键),这样摄像头的其他抽象层就不必知道其中的区别。
2. HTTP 客户端:Dio 和 Open GoPro 端点
客户端是一个轻量的 Dio 封装。请注意较短的连接超时(你在局域网中——如果连接慢,那就是坏了)以及为控制调用设置的较长的接收超时时间:
static const String baseUrl = 'http://10.5.5.9:8080';
GoProHttpClient() {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
headers: {'Accept': 'application/json'},
));
}
Open GoPro API 令人愉悦地统一:所有操作都是 HTTP GET 请求,即使是命令和设置更改也不例外。以下是此应用实际使用的端点:
| 目的 | 方法 + 路径 | 查询 |
|---|---|---|
| 启动快门(录像 / 拍照) | GET /gopro/camera/shutter/start | — |
| 停止快门 | GET /gopro/camera/shutter/stop | — |
| 心跳 | GET /gopro/camera/keep_alive | — |
| 完整状态(状态 + 设置) | GET /gopro/camera/state | — |
| 设置预设组 | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| 更改设置 | GET /gopro/camera/setting | setting, option |
| 媒体列表 | GET /gopro/media/list | — |
| 下载文件 | GET /videos/DCIM/{dir}/{file} | (bytes) |
| 缩略图 / 屏幕截图 | GET /gopro/media/thumbnail / .../screennail | path |
| 极速传输 | GET /gopro/media/turbo_transfer | p (1/0) |
| 删除 .360 组(旧版) | GET /gp/gpControl/command/storage/delete/group | p |
一个不错的细节是:现代的 /gopro/... 系列和旧版的 /gp/gpControl/... 系列 API 并存。删除新的 API 无法干净处理的 chaptered.360/GS 组文件,是我们求助于旧端点的唯一情况。
403 自愈模式(这里最巧妙的技巧)
你无法提前知道在摄像头的当前模式下哪些设置是有效的——16:9 合法的分辨率在 9:16 可能非法,FPS 选项取决于分辨率,等等。GoPro 通过一种“硬核”方式告诉你这一点:当设置无效时,它会返回 HTTP 403 并附带本应有效的选项列表;而当它暂时忙碌时,则返回 HTTP 500。
因此,我们将 403 响应体解析为类型化错误,并重试 500 错误:
Future<void> setSetting(int settingId, int optionValue) async {
const maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
await _dio.get('/gopro/camera/setting',
queryParameters: {'setting': settingId, 'option': optionValue});
return;
} catch (e) {
if (e is DioException && e.type == DioExceptionType.badResponse) {
final statusCode = e.response?.statusCode;
if (statusCode == 403) {
final body = e.response?.data;
List<int> availableIds = [];
if (body is Map<String, dynamic>) {
final options = body['available_options'];
if (options is List) {
availableIds = options
.whereType<Map<String, dynamic>>()
.map((o) => o['id'] as int? ?? -1)
.where((id) => id >= 0).toList();
}
}
throw SettingRejectedError(
settingId: settingId, rejectedOption: optionValue,
availableOptionIds: availableIds);
}
if (statusCode == 500 && attempt < maxRetries) {
await Future.delayed(Duration(milliseconds: attempt * 500)); // linear backoff
continue;
}
}
rethrow;
}
}
}
如果检测到 SettingRejectedError,服务层会使用摄像头报告的选项来重写其自身的能力列表。对于每个型号,我们都从一个乐观的、硬编码的能力表开始,并在运行时允许摄像头纠正我们。这比试图预先模拟 GoPro 的整个设置矩阵要更实用且具有自纠正能力。
3. 心跳:你绝不能忘记的定时器
大约 60 秒不活动后,GoPro WiFi AP 会结束你的会话。如果你不“戳”它一下,你原本完好的连接就会在会话中悄然中断。因此,我们连接后会启动两个定时器:
- 每 30 秒心跳一次 → GET /gopro/camera/keep_alive
- 每 5 秒轮询一次状态 → GET /gopro/camera/state
void _startKeepAlive() {
_keepAliveTimer?.cancel();
_keepAliveTimer = Timer.periodic(
GoProHttpConstants.keepAliveInterval, // 30s
(_) => _sendKeepAlive(),
);
}
Future<void> _sendKeepAlive() async {
if (!isConnected || _isDisposed) return;
try {
await _httpClient.keepAlive();
} catch (e) {
if (!_isDisposed) _handleDisconnection(); // a failed keep-alive == we're disconnected
}
}
失败的心跳也是我们的断开连接信号——这是检测用户走出 WiFi 范围最可靠的方式。
4. 摄像头控制:快门、模式和 MAX 2 镜头切换的“舞蹈”
快门是最简单的部分——启动,等待 500 毫秒,然后重新轮询状态。拍照也是相同的快门/启动操作;你得到的是视频还是照片,取决于当前模式。
模式切换变得有趣起来,因为 GoPro MAX 2 上的“模式”实际上是两个设置:一个预设组 (Video = 1000, Photo = 1001, Timelapse = 1002) 加上一个镜头 (Setting 194: 0 = single-lens/HERO, 1 = 360)。要正确处理这一点,需要在代码中嵌入一些不那么明显的规则:
- 序列化模式更改。快速的子模式切换通过 Completer 链进行排队,以避免它们竞态进入不一致的状态。
- 首先停止快门。固件会在录制期间以 HTTP 400 拒绝预设更改,因此我们会在切换前主动停止编码。
- 顺序和延迟很重要。在 MAX 2 上:首先设置预设组(+400 毫秒),然后通过设置 194 设置镜头(+1500 毫秒)。那 1.5 秒的暂停是真实存在的——它是物理镜头组件的过渡时间。
- 相信预期状态,而不是轮询状态。我们跟踪 _lastIntendedIs360 / _lastIntendedPresetGroup 并与它们进行比较,因为 5 秒的状态轮询可能会在过渡期间给你一个过时的快照。
还有一个值得指出的 GoPro 的真正隐秘怪癖:分辨率 ID 取决于宽高比。4K 在 16:9 中是选项 1,在 9:16 中是 109,在 4:3 中是 112。因此,在更改宽高比后,我们会重新查询功能——否则相同的 ID 将意味着不同的分辨率。
5. 列出媒体文件
GET /gopro/media/list 返回一个按目录分组的结构,其键值以简洁著称:d = 目录,fs = 文件,n = 文件名,s = 大小,cre/mod = Unix 秒时间戳,g = 组 ID,glrv = 低分辨率代理文件大小。
final media = response['media'] as List<dynamic>?;
for (final dir in media) {
final directory = dirMap['d'] as String? ?? '';
final filesList = dirMap['fs'] as List<dynamic>?;
for (final fileJson in filesList) {
files.add(GoProCameraFile.fromJson(directory, fileMap));
}
}
files.sort((a, b) => b.modifiedAt.compareTo(a.modifiedAt)); // newest first
媒体类型根据文件名前缀 + 扩展名推断:GS = 360 视频,GX = HERO 视频,GT = 延时摄影,.360 = 球形视频,.GPR/.RAW = 原始照片。这些前缀在后续很重要,因为 .360 文件的行为与其他文件不同。
6. 流式下载:极速模式、进度和伴随文件
实际的字节传输通过 dio.download() 直接到文件路径完成,接收超时时间较长,因为一个 1 GB 的 .360 剪辑需要一段时间:
Future<void> downloadMediaToFile(
String directory, String filename, String destPath,
{void Function(int received, int total)? onProgress}) async {
await _dio.download(
'/videos/DCIM/$directory/$filename',
destPath,
onReceiveProgress: onProgress,
options: Options(receiveTimeout: const Duration(minutes: 30)),
);
}
围绕这个核心,传输服务协调实际细节:
极速传输。在下载之前,我们会开启极速模式 (turbo_transfer?p=1)——GoPro 会切换到 5 GHz WiFi 以实现显著更快的传输——并且我们会在 finally 块中关闭它,以便即使在出错时也能始终清理。
进度 + 速度,节流。在每次 onReceiveProgress 回调中计算传输速度是浪费且不稳定的,所以我们每 500 毫秒才重新计算一次:
onProgress: (received, total) {
if (_isCancelled) return;
final now = DateTime.now();
String? speed;
if (now.difference(lastSpeedUpdate).inMilliseconds >= 500) {
final bytesPerSecond = (received - lastBytes) /
(now.difference(lastSpeedUpdate).inMilliseconds / 1000);
speed = _formatSpeed(bytesPerSecond.round());
lastSpeedUpdate = now; lastBytes = received;
}
_transferController.add(GoProFileTransfer(
status: FileTransferStatus.downloading,
progress: total > 0 ? received / total : 0,
speed: speed, currentBytes: received, totalBytes: total));
}
取消是一个协作性的 _isCancelled 标志;取消时,我们会删除部分文件并发出取消事件。
伴随文件。这是你在文档中找不到的部分。对于单镜头 .MP4 文件,我们还会提取 .LRV(GoPro 在录制完整视频时同时录制的低分辨率代理文件)和 .THM 缩略图,然后对 .LRV 进行索引以实现应用内快速播放。这里有两个命名上的陷阱:
- HERO8+ 将代理文件重命名:GX######.MP4 / GH######.MP4 变为 GL######.LRV(第二个字符变为 L),对于旧款相机则采用同名回退方案。
- .360 文件根本不支持缩略图或屏幕截图端点。我们回退到 .THM 伴随文件,万不得已时直接下载文件本身。由于 .360 是双鱼眼文件,我们将其缩略图裁剪为前置镜头——通过 compute() 在后台隔离中执行,这样 UI 就不会卡顿。
传输状态通过一个 Riverpod notifier 暴露,该 notifier 订阅文件和传输流,使已完成的传输可见 2 秒,并在 5 秒内显示错误。值得注意的是:控制和文件传输对同一摄像头使用两个独立的 Dio 客户端,因此下载可以在状态轮询和心跳进行的同时运行。
7. 教程中没有提到的两个陷阱
现代 Android 系统默认阻止明文 HTTP
http://10.5.5.9:8080 是明文的,Android 9+ 默认阻止明文流量。你必须明确允许它:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
此外还需要一个 network_security_config.xml 文件,其基本配置允许明文传输(你仍然可以为自己的云/后端域强制使用 HTTPS)。如果漏掉这一点,每个 GoPro 调用都会因令人困惑的连接错误而失败。
缺失的网络绑定——设计的脆弱点
这是坦诚的部分。在现代 Android 上,当你加入 GoPro AP 时,手机通常会将蜂窝网络作为默认网络,因为 GoPro AP 没有互联网连接。这意味着对 10.5.5.9 的 HTTP 请求可能会通过蜂窝网络路由并失败——即使你已“连接”到摄像头。
健壮的解决方案是原生实现:在传输前调用 ConnectivityManager.bindProcessToNetwork(goProNetwork),并在传输后调用 bindProcessToNetwork(null)。纯 Dart 的 GoPro 路径没有等效方案——不过它通常也能工作,因为操作系统通常会将固定的 10.5.5.9 地址路由到 AP。但这仍是架构中最脆弱的部分,在某些设备/Android 版本上,它会首先出现问题。如果你发布这个应用,请添加网络绑定——这是保持 100% Dart 会让你牺牲可靠性的唯一地方。
经验教训
- 你不需要 BLE。如果用户能够加入 AP,Open GoPro HTTP API 可以让你在纯 Dart 中获得完全控制和快速传输。
- 通过 TCP 探测而不是 SSID 扫描进行检测——无需位置权限,无需平台代码。
- 每 30 秒发送一次心跳,否则会话会断开。将失败的心跳视为你的断开连接信号。
- 让摄像头纠正你。403-available_options 自愈循环优于建模 GoPro 的整个设置矩阵。
- 模式切换是一个状态机——序列化它,先停止录制,遵守镜头过渡延迟,并相信预期状态而非轮询状态。
- 伴随文件和 .360 文件的怪癖是隐藏的难点——LRV 重命名,没有缩略图端点,双鱼眼裁剪。
- 两个基础设施陷阱会悄无声息地让你失败:明文流量配置,以及(尤其是)将进程绑定到摄像头网络以防止请求通过蜂窝网络逃逸。
回报是:一部手机可以通过 WiFi 连接到 GoPro,驱动所有控制,并实时显示进度地传输数千兆字节的素材——而这一切几乎都是结构良好的 Dart 代码与 http://10.5.5.9:8080 进行通信实现的。

