SDKもBLEのペアリングも不要 — カメラの固定IP、Dio client、そして知らなければひっそりと一日を台無しにするであろういくつかの「落とし穴」だけです。
なぜBluetoothではなくWiFiなのか?
ほとんどの「GoProへの接続」チュートリアルはBluetooth Low Energyから始まります。スキャンし、ペアリングし、GATT characteristicsを交換し、BLEを使用してWiFiをオンにし、最終的にWiFi経由で転送します。これは機能しますが、多くの手順が必要です。そして、BLEは、ユーザーが実際に求めている、カメラの制御と映像の高速転送に対しては、遅くて扱いにくいものです。
そのため、このアプリではBLEを完全にスキップします。ユーザーはスマートフォンの設定からGoProのWiFi access pointに接続し、それ以降のすべてはカメラのOpen GoPro APIに対して固定アドレスで通常のHTTPで行われます。
http://10.5.5.9:8080
カメラの制御、設定、メディアリスト表示、ファイルダウンロードはすべて、そのIPへのHTTP GETリクエストで行われます。統合全体は、dioパッケージを使用した100% Dartであり、ネイティブのKotlin/Swiftは一切使用していません。これが主なポイントですが、最後に説明するように、この設計が脆弱な唯一の箇所でもあります。
フローは次のとおりです。
GoPro WiFi APに接続(スマートフォンの設定)
→ カメラが到達可能か検出(10.5.5.9:8080へのTCP probe)
→ 接続 + モデル識別(GET /gopro/camera/state)
→ keep-alive (30秒) + status poll (5秒) タイマーを開始
→ 制御: shutter / mode / settings
→ メディアリスト表示(GET /gopro/media/list)
→ turbo + progressでダウンロード(dio.download)
1. WiFi-scanパーミッションなしでカメラを検出する
最初の直感としては、WiFi SSIDを列挙してGoProで始まるものを探すことでしょう。しかし、それは避けてください — 位置情報パーミッションとプラットフォーム固有のWiFi-scan 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秒ごとにポーリングし、state changesをstreamでブロードキャストします。接続画面は購読し、socketが開いた瞬間に「connected」に切り替わります。実際のdevice discoveryがないため、サービスは仮想のCameraDevice(固定IPをキーとする)を合成し、カメラ抽象化の残りの部分が違いを知る必要がないようにしています。
2. HTTP client: DioとOpen GoProのendpoints
このclientは薄いDio wrapperです。短いconnect timeout(LAN上にいる場合、遅いなら壊れている)と、control calls用の十分なreceive timeoutに注目してください。
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は非常に統一的で、commandsやsettings changesも含め、すべてがHTTP GETです。このアプリで実際に使用されているendpointsを以下に示します。
| 目的 | メソッド + パス | クエリ |
|---|---|---|
| シャッター開始(録画 / 写真) | GET /gopro/camera/shutter/start | — |
| シャッター停止 | GET /gopro/camera/shutter/stop | — |
| Keep-alive | 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ではきれいに処理できないchaptered.360/GSグループファイルの削除は、古いendpointに頼る唯一の状況です。
403自己修復パターン(ここでの最もきれいなトリック)
カメラの現在のmodeでどのsettingsが有効であるかを事前に知ることはできません。16:9で有効なresolutionが9:16では無効であったり、FPS optionsがresolutionに依存したりします。GoProはこれを困難な方法で伝えてきます。つまり、有効であったであろうoptionsのリストとともにHTTP 403を返し、一時的にビジーな場合はHTTP 500を返します。
そこで、403のbodyをtyped errorにパースし、500の場合はretryします。
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;
}
}
}
カメラが報告するoptionsは、サービス層がSettingRejectedErrorを検出した場合に、自身のcapability listを書き換えるために使用されます。各modelに対し、まず楽観的なhard-codedのcapability tableから始め、runtime中にカメラが私たちを修正することを許容します。これは、GoProのsettings matrix全体を事前にシミュレートしようとするよりも、より実用的で自己修正的です。
3. Keep-alive: 忘れてはならないタイマー
約60秒間の非アクティブ状態の後、GoPro WiFi APはセッションを終了します。操作しなければ、完全に良好な接続でもセッションの途中で静かに切断されてしまいます。したがって、接続時に2つのタイマーを開始します。
- 30秒ごとにKeep-alive → GET /gopro/camera/keep_alive
- 5秒ごとにStatus poll → 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
}
}
Keep-aliveの失敗は、disconnection signalでもあります。ユーザーがWiFiの範囲外に出たことを検知する最も信頼性の高い方法です。
4. カメラ制御: shutter、mode、そしてMAX 2のレンズ切り替え
shutterは簡単な部分です — 開始し、500ms待機し、stateを再pollします。写真撮影も同じshutter/startであり、videoまたはphotoのどちらを得るかは現在のmodeに依存します。
mode switchingは興味深い部分です。GoPro MAX 2における「mode」は、実際には2つのsettingsです。preset group(Video = 1000、Photo = 1001、Timelapse = 1002)とlens(Setting 194: 0 = single-lens/HERO、1 = 360)です。これを正しく実装するには、コードにいくつかの自明ではないルールを組み込む必要がありました。
- mode変更を直列化する。高速なサブmodeのタップがCompleterチェーンを介してキューに入れられ、一貫性のない状態への競合を防ぎます。
- まずshutterを停止する。firmwareは録画中のpreset変更をHTTP 400で拒否するため、切り替える前に積極的にencodingを停止します。
- 順序と遅延が重要。MAX 2では、まずpreset groupを設定し(+400 ms)、次にSetting 194を介してlensを設定します(+1500 ms)。この1.5秒の停止は物理的なlens assemblyの移行によるものです。
- polled stateではなく、intended stateを信頼する。5秒ごとのstatus pollでは移行中に古いsnapshotが渡される可能性があるため、_lastIntendedIs360 / _lastIntendedPresetGroupを追跡し、それらと比較します。
もう一つ、指摘しておくべき非常に巧妙なGoProの奇癖があります。resolution IDはaspect ratioに依存します。4Kは16:9でoption 1、9:16で109、4:3で112です。そのため、aspect ratioを変更した後、capabilitiesを再queryします — そうしないと、同じIDが異なるresolutionを意味することになります。
5. メディアのリスト表示
GET /gopro/media/listは、有名なほど簡潔なキーを持つdirectory-grouped structureを返します: d = directory、fs = files、n = filename、s = size、cre/mod = unix-second timestamps、g = group id、glrv = low-res-proxy size。
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
メディアタイプはfilename prefixとextensionから推測されます: GS = 360 video、GX = HERO video、GT = timelapse、.360 = spherical video、.GPR/.RAW = raw photo。これらのprefixは後で重要になります。なぜなら、.360ファイルは他のすべてとは異なる動作をするためです。
6. ストリーミングダウンロード: turbo、progress、およびcompanion files
実際のbyte転送はdio.download()で直接file pathに行われ、1GBの.360 clipは時間がかかるため、長いreceive timeoutを設定します。
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)),
);
}
その核の周りで、transfer serviceは現実世界の詳細を調整します。
Turbo transfer。ダウンロード前にturbo mode(turbo_transfer?p=1)をオンにします — GoProは5 GHz WiFiに切り替わり、著しく高速な転送が可能になります — そして、エラー時でも常にクリーンアップされるようにfinallyブロックでオフにします。
Progress + speed、スロットリング。すべてのonReceiveProgress callbackで転送速度を計算するのは無駄で不安定なため、500msごとにのみ再計算します。
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));
}
Cancellationは協力的な_isCancelled flagです。cancel時にはpartial fileを削除し、cancelled eventを発行します。
Companion files。これはdocsには載っていない部分です。single-lensの.MP4の場合、.LRV(GoProがfull videoと一緒に記録するlow-res proxy)と.THM thumbnailも取得し、その後、高速なin-app playbackのためにLRVをindexします。ここで2つのnaming gotchasがあります。
- HERO8+ではproxyの名前が変更されます: GX######.MP4 / GH######.MP4がGL######.LRV(2番目の文字がLに変わる)になり、古いカメラ向けには同名fallbackがあります。
- .360ファイルはthumbnailまたはscreennail endpointをまったくサポートしていません。.THM companionにfallbackし、最終手段としてファイル自体をダウンロードします。また、.360はdual-fisheyeであるため、thumbnailをフロントレンズに合わせてcropします — これはcompute()を介したbackground isolateで行われるため、UIが引っかかることはありません。
transfer stateは、fileとtransfer streamsを購読し、完了したtransferを2秒間表示し、エラーを5秒間表面化するRiverpod notifierを通じて公開されます。注目すべき点として、controlとfile-transferは同じカメラに対して2つの独立したDio clientsを使用するため、status pollとkeep-aliveが継続している間にdownloadを実行できます。
7. どのチュートリアルにも載っていない2つの落とし穴
最新のAndroidではCleartext HTTPがデフォルトでブロックされる
http://10.5.5.9:8080はplaintextであり、Android 9+はデフォルトでcleartext trafficをブロックします。これを明示的に許可する必要があります。
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
さらに、base configがcleartextを許可するnetwork_security_config.xml(自身のcloud/backend domainsにはHTTPSを強制することも可能)が必要です。これを怠ると、すべてのGoPro呼び出しが混乱を招くconnection errorで失敗します。
欠けているネットワークバインディング — 設計の脆弱な点
ここからが正直な部分です。最新のAndroidでは、GoPro APに接続しても、GoPro APにインターネットがないため、電話は通常cellularをデフォルトのnetworkとして保持します。これは、10.5.5.9へのHTTP requestがcellular経由でルーティングされて失敗する可能性があることを意味します — カメラに「接続」されているにもかかわらず。
堅牢な解決策はnativeです。transfersの前にConnectivityManager.bindProcessToNetwork(goProNetwork)を呼び出し、後にbindProcessToNetwork(null)を呼び出します。純粋なDartのGoProパスには同等のものがありません — そして、OSが通常固定の10.5.5.9アドレスをAPにルーティングするため、ともかく機能する傾向があります。しかし、これはアーキテクチャの最も脆い部分であり、一部のdevice/Androidバージョンでは最初に壊れる箇所です。これをリリースする場合は、network bindingを追加してください — 100% Dartを維持することでreliabilityが犠牲になる唯一の場所です。
学んだ教訓
- BLEは不要です。ユーザーがAPに接続できれば、Open GoPro HTTP APIは純粋なDartで完全な制御と高速な転送を提供します。
- SSIDスキャンではなくTCP probeで検出してください — 位置情報パーミッションもプラットフォームコードも不要です。
- 30秒ごとにKeep-aliveを行わないとセッションが終了します。Keep-aliveの失敗は切断信号と見なしてください。
- カメラに修正させましょう。403-available_options自己修復ループは、GoProのsettings matrix全体をモデリングするよりも優れています。
- Mode switchingはstate machineです — 直列化し、まず録画を停止し、lens移行の遅延を尊重し、polled stateよりもintended stateを信頼してください。
- Companion filesと.360の癖は注意すべき点です — LRVのファイル名変更、thumbnail endpointの欠如、dual-fisheye croppingなど。
- 2つのインフラの落とし穴が静かにあなたを沈めます。cleartext traffic configと、(特に)requestsがcellular経由で漏れないようにprocessをカメラネットワークにbindingすることです。
その成果:WiFi経由でGoProに接続し、すべての制御を行い、ライブprogress付きでギガバイトの映像をストリーミングするスマートフォン — そのほとんどすべてが、http://10.5.5.9:8080と通信する適切に構造化されたDartコードのみです。

