无需 SDK,无需繁琐的 BLE 配对——只需摄像头的固定 IP、一个 Dio 客户端,以及一些如果你不知道就会悄悄毁掉你一天的问题。
为什么选择 WiFi 而不是蓝牙?
大多数“连接 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(手机设置)
→ 检测摄像头是否可达(对 10.5.5.9:8080 进行 TCP 探测)
→ 连接 + 识别型号(GET /gopro/camera/state)
→ 启动心跳(30秒)+ 状态轮询(5秒)计时器
→ 控制:快门 / 模式 / 设置
→ 列出媒体(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} | (字节) |
| 缩略图 / 屏幕截图 | 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 同时存在。删除 chaptered.360/GS 组文件(新 API 无法干净地处理)是我们诉诸旧版端点的唯一情况。
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 上的一个“模式”实际上是两个设置:一个预设组(视频 = 1000,照片 = 1001,延时 = 1002)加上一个镜头(设置 194:0 = 单镜头/HERO,1 = 360)。要正确实现这一点,代码中需要内嵌几个不明显的规则:
- 序列化模式更改。快速的子模式点击通过一个 Completer 链进行排队,以防止它们相互竞争导致状态不一致。
- 先停止快门。固件在录制时会以 HTTP 400 拒绝预设更改,因此我们在切换前会主动停止编码。
- 顺序和延迟很重要。在 MAX 2 上:首先设置预设组(+400 毫秒),然后通过设置 194 设置镜头(+1500 毫秒)。那 1.5 秒的暂停是真实的——它是物理镜头组件的转换时间。
- 信任预期状态,而非轮询状态。我们跟踪 _lastIntendedIs360 / _lastIntendedPresetGroup 并与它们进行比较,因为 5 秒的状态轮询可能会在过渡过程中为你提供一个过时的快照。
还有一个值得注意的 GoPro 隐蔽怪癖:分辨率 ID 取决于宽高比。在 16:9 中,4K 是选项 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)); // 最新优先
媒体类型是从文件名后缀 + 扩展名推断出来的: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,驱动所有控制,并以实时进度从 GoPro 流式传输千兆字节的素材——而这一切几乎都只是结构良好的 Dart 代码在与 http://10.5.5.9:8080 通信。

