SDKもBLEペアリングの煩わしさもなし — カメラの固定IP、Dioクライアント、そして知らなければひっそりと一日を台無しにするいくつかの落とし穴だけ。
なぜBluetoothではなく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リクエストです。統合全体は、dioパッケージを使用した100% Dartで、ネイティブのKotlin/Swiftは一切使用していません。これが主要な点であり、最後に説明するように、設計が脆弱な唯一の場所でもあります。
フロー:
GoPro WiFi APに接続 (スマートフォンの設定)
→ カメラが到達可能か検出 (10.5.5.9:8080へのTCPプローブ)
→ 接続 + モデル識別 (GET /gopro/camera/state)
→ キープアライブ (30秒) + ステータスポール (5秒) タイマーを開始
→ 制御: シャッター / モード / 設定
→ メディアリスト表示 (GET /gopro/media/list)
→ ターボ + プログレス付きダウンロード (dio.download)
1. WiFiスキャン権限なしでカメラを検出する
最初の直感は、WiFi SSIDを列挙し、GoProで始まるものを探すことでしょう。やめてください。それでは位置情報権限やプラットフォーム固有のWiFiスキャンAPIが必要になります。
GoProは常に同じIPアドレスに存在するため、検出は単純に「10.5.5.9:8080へのTCPソケットを開けるか?」だけです。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秒ごとにポーリングし、ストリームを介して状態変化をブロードキャストします。接続画面は購読し、ソケットが開いた瞬間に「接続済み」に切り替わります。実際のデバイス発見がないため、サービスは仮想のCameraDevice(固定IPをキーとする)を合成し、カメラ抽象化の残りの部分はその違いを知る必要がありません。
2. HTTPクライアント: DioとOpen GoProエンドポイント
クライアントは薄いDioラッパーです。短い接続タイムアウト(LAN上にいる場合、遅いなら壊れています)と、制御呼び出しのための寛大な受信タイムアウトに注意してください。
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では適切に処理できない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)); // リニアバックオフ
continue;
}
}
rethrow;
}
}
}
カメラが報告するオプションは、SettingRejectedErrorを検出した場合に、サービス層が自身の機能リストを書き換えるために使用されます。各モデルについて、まず楽観的なハードコードされた機能テーブルから始め、実行中にカメラが我々を修正することを許可します。これは、GoProの全体の設定マトリックスを事前にシミュレートしようとするよりも、実用的で自己修正的です。
3. キープアライブ: 忘れてはならないタイマー
約60秒間操作がないと、GoPro WiFi APはセッションを終了します。あなたが触らなければ、完璧な接続もセッション中に静かに途絶えます。したがって、接続時に2つのタイマーを開始します。
- 30秒ごとにキープアライブ → GET /gopro/camera/keep_alive
- 5秒ごとにステータスポール → GET /gopro/camera/state
void _startKeepAlive() {
_keepAliveTimer?.cancel();
_keepAliveTimer = Timer.periodic(
GoProHttpConstants.keepAliveInterval, // 30秒
(_) => _sendKeepAlive(),
);
}
Future<void> _sendKeepAlive() async {
if (!isConnected || _isDisposed) return;
try {
await _httpClient.keepAlive();
} catch (e) {
if (!_isDisposed) _handleDisconnection(); // キープアライブの失敗は切断を意味する
}
}
キープアライブの失敗は、切断のシグナルでもあります。これは、ユーザーがWiFiの範囲外に出たことを検知する最も信頼できる方法です。
4. カメラ制御: シャッター、モード、およびMAX 2レンズの切り替え
シャッターは簡単な部分です。開始し、500ミリ秒待機し、状態を再ポーリングします。写真撮影も同じシャッター開始動作です。ビデオになるか写真になるかは現在のモードによって異なります。
モード切り替えが興味深い点です。GoPro MAX 2では「モード」が実際には2つの設定で構成されているからです。プリセットグループ(ビデオ = 1000、写真 = 1001、タイムラプス = 1002)とレンズ(設定194: 0 = シングルレンズ/HERO、1 = 360)です。これを正しく実現するには、コードに組み込まれたいくつかの自明でないルールが必要でした。
- モード変更をシリアル化します。素早いサブモードのタップはCompleterチェーンを介してキューに入れられ、互いに競合して不整合な状態にならないようにします。
- まずシャッターを停止します。ファームウェアは録画中にプリセット変更をHTTP 400で拒否するため、切り替える前に積極的にエンコーディングを停止します。
- 順序と遅延が重要です。MAX 2では、最初にプリセットグループを設定し(+400ミリ秒)、次に設定194経由でレンズを設定します(+1500ミリ秒)。この1.5秒の一時停止は現実のもので、物理的なレンズアセンブリの移行です。
- ポーリングされた状態ではなく、意図した状態を信頼します。5秒間のステータスポールは移行途中の古いスナップショットを渡す可能性があるため、_lastIntendedIs360 / _lastIntendedPresetGroupを追跡し、それらと比較します。
また、言及すべき本当に巧妙な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)); // 最新が最初
メディアタイプはファイル名プレフィックスと拡張子から推測されます: GS = 360 video, GX = HERO video, GT = timelapse, .360 = 全天球ビデオ, .GPR/.RAW = RAW写真。これらのプレフィックスは後で重要になります。なぜなら、.360ファイルは他のすべてとは異なる動作をするからです。
6. ストリーミングダウンロード: ターボ、進捗、および関連ファイル
実際のバイト転送は、dio.download()でファイルパスに直接行われます。1GBの.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をインデックス化します。ここで2つの命名上の落とし穴があります。
- HERO8+はプロキシをリネームします: GX######.MP4 / GH######.MP4がGL######.LRVになり(2番目の文字がLに変わる)、古いカメラでは同名でフォールバックします。
- .360ファイルはサムネイルまたはスクリーンネイルのエンドポイントをまったくサポートしていません。そこで.THM関連ファイルにフォールバックし、最終手段としてファイル自体をダウンロードします。そして、.360はデュアル魚眼であるため、サムネイルを前面レンズにクロップします。これはcompute()を介したバックグラウンドアイソレートで行われるため、UIがカクつくことはありません。
転送状態は、ファイルストリームと転送ストリームを購読し、完了した転送を2秒間表示し、エラーを5秒間表面化するRiverpodノティファイアを通じて公開されます。特筆すべきは、制御とファイル転送が同じカメラに対して2つの独立したDioクライアントを使用するため、ステータスポールとキープアライブが継続している間でもダウンロードを実行できることです。
7. どのチュートリアルにも載っていない2つの落とし穴
最新のAndroidでは、デフォルトでCleartext 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パスには同等のものがありません。そして、OSが一般的に固定された10.5.5.9アドレスをAPにルーティングするため、とにかく機能する傾向があります。しかし、これはアーキテクチャの中で最も脆い部分であり、一部のデバイス/Androidバージョンでは最初に壊れる箇所です。これをリリースするなら、ネットワークバインディングを追加してください。100% Dartにこだわることで信頼性を損なう唯一の場所です。
学んだ教訓
- BLEは必要ありません。ユーザーがAPに接続できれば、Open GoPro HTTP APIは純粋なDartで完全な制御と高速転送を提供します。
- SSIDスキャンではなくTCPプローブで検出します。位置情報権限もプラットフォームコードも不要です。
- 30秒ごとにキープアライブを実行しないとセッションが切れます。キープアライブの失敗は切断シグナルとして扱います。
- カメラに修正させましょう。403-available_optionsの自己修復ループは、GoProの全設定マトリックスをモデル化するよりも優れています。
- モード切り替えはステートマシンです。シリアル化し、まず録画を停止し、レンズ移行の遅延を尊重し、ポーリングされた状態よりも意図された状態を信頼してください。
- 関連ファイルと.360の癖は隠れた難所です。LRVのリネーム、サムネイルエンドポイントの欠如、デュアル魚眼のクロッピングなどがあります。
- 2つのインフラの落とし穴が静かにあなたを沈めます。クリアテキストトラフィックの設定、そして(特に)リクエストがセルラー経由で流出しないようにプロセスをカメラネットワークにバインドすることです。
その成果は、WiFi経由でGoProに接続し、すべての制御を行い、ライブ進捗状況と共にギガバイトもの映像をストリーミングで取得できるスマートフォンです。そして、そのほとんどが、http://10.5.5.9:8080と通信するよく構造化されたDartコードだけで実現されています。

