Tidak ada SDK, tidak ada kerumitan pemasangan BLE — hanya IP kamera yang tetap, klien Dio, dan beberapa 'jebakan' yang diam-diam akan merusak harimu jika kamu tidak mengetahuinya.
Mengapa WiFi, bukan Bluetooth?
Sebagian besar tutorial 'menghubungkan ke GoPro' dimulai dengan Bluetooth Low Energy: memindai, memasangkan, bertukar karakteristik GATT, lalu menggunakan BLE untuk menyalakan WiFi, kemudian mentransfer melalui WiFi juga. Ini berhasil, tetapi banyak sekali 'seremoninya' — dan BLE lambat serta rumit untuk hal yang sebenarnya diinginkan pengguna, yaitu mengontrol kamera dan mengambil rekaman darinya dengan cepat.
Jadi, dalam aplikasi ini kami sepenuhnya melewati BLE. Pengguna bergabung dengan titik akses WiFi GoPro dari pengaturan ponsel mereka, dan sejak saat itu semuanya adalah HTTP biasa terhadap API Open GoPro kamera pada alamat tetap:
http://10.5.5.9:8080
Kontrol kamera, pengaturan, daftar media, dan unduhan file semuanya adalah permintaan HTTP GET ke IP tersebut. Seluruh integrasi ini 100% Dart dengan paket Dio — nol Kotlin/Swift asli. Itu adalah intinya, dan seperti yang akan kita lihat di akhir, ini juga satu-satunya tempat di mana desainnya rapuh.
Alurnya:
bergabung dengan AP WiFi GoPro (pengaturan ponsel)
→ mendeteksi kamera dapat dijangkau (probe TCP ke 10.5.5.9:8080)
→ menghubungkan + mengidentifikasi model (GET /gopro/camera/state)
→ memulai pengatur waktu keep-alive (30 detik) + polling status (5 detik)
→ kontrol: shutter / mode / pengaturan
→ mencantumkan media (GET /gopro/media/list)
→ mengunduh dengan turbo + progres (dio.download)
1. Mendeteksi kamera tanpa izin pemindaian WiFi
Insting pertama adalah menghitung SSID WiFi dan mencari yang diawali dengan GoPro. Jangan — itu akan melibatkan izin lokasi dan API pemindaian WiFi yang spesifik platform.
GoPro selalu berada di IP yang sama, jadi deteksinya hanyalah: bisakah saya membuka soket TCP ke 10.5.5.9:8080? Tidak ada HTTP, tidak ada izin, tidak ada penguraian 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);
}
}
}
Kami melakukan polling setiap 2 detik dan menyiarkan perubahan status melalui sebuah stream. Layar koneksi berlangganan dan beralih ke "terhubung" saat soket terbuka. Karena tidak ada penemuan perangkat yang sebenarnya, layanan ini mensintesis CameraDevice virtual (berdasarkan IP tetap) sehingga abstraksi kamera lainnya tidak perlu mengetahui perbedaannya.
2. Klien HTTP: Dio dan endpoint Open GoPro
Kliennya adalah wrapper Dio yang tipis. Perhatikan connect timeout yang singkat (Anda berada di LAN — jika lambat, berarti rusak) dan receive timeout yang cukup lama untuk panggilan kontrol:
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'},
));
}
API Open GoPro sangat seragam: semuanya adalah HTTP GET, bahkan perintah dan perubahan pengaturan. Berikut adalah endpoint yang sebenarnya digunakan aplikasi ini:
| Tujuan | Metode + Path | Kueri |
|---|---|---|
| Mulai shutter (rekam / foto) | GET /gopro/camera/shutter/start | — |
| Hentikan shutter | GET /gopro/camera/shutter/stop | — |
| Keep-alive | GET /gopro/camera/keep_alive | — |
| Status penuh (status + pengaturan) | GET /gopro/camera/state | — |
| Atur grup preset | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Ubah pengaturan | GET /gopro/camera/setting | setting, option |
| Daftar media | GET /gopro/media/list | — |
| Unduh file | GET /videos/DCIM/{dir}/{file} | (bytes) |
| Thumbnail / screennail | GET /gopro/media/thumbnail / .../screennail | path |
| Transfer Turbo | GET /gopro/media/turbo_transfer | p (1/0) |
| Hapus grup .360 (warisan) | GET /gp/gpControl/command/storage/delete/group | p |
Detail yang bagus: baik keluarga modern /gopro/... maupun keluarga warisan /gp/gpControl/... hidup berdampingan. Penghapusan file grup chaptered.360/GS, yang tidak dapat ditangani dengan bersih oleh API baru, adalah satu-satunya situasi di mana kami menggunakan endpoint lama.
Pola penyembuhan diri 403 (trik paling bersih di sini)
Anda tidak dapat mengetahui terlebih dahulu pengaturan mana yang valid dalam mode kamera saat ini — resolusi yang legal dalam 16:9 ilegal dalam 9:16, opsi FPS tergantung pada resolusi, dan seterusnya. GoPro memberitahukan hal ini dengan cara yang sulit: ia mengembalikan HTTP 403 dengan daftar opsi yang seharusnya valid, dan HTTP 500 ketika hanya sesaat sibuk.
Jadi kami mengurai body 403 menjadi error berjenis dan mencoba kembali 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;
}
}
}
}
}
Opsi yang dilaporkan kamera digunakan oleh lapisan layanan untuk menulis ulang daftar kemampuannya sendiri jika mendeteksi SettingRejectedError. Untuk setiap model, kami memulai dengan tabel kemampuan optimis yang dikodekan secara permanen dan memungkinkan kamera untuk mengoreksi kami selama runtime. Ini lebih praktis dan korektif sendiri daripada mencoba mensimulasikan seluruh matriks pengaturan GoPro di awal.
3. Keep-alive: pengatur waktu yang tidak boleh Anda lupakan
Setelah sekitar 60 detik tidak aktif, AP WiFi GoPro mengakhiri sesi Anda. Koneksi Anda yang sempurna diam-diam mati di tengah sesi jika Anda tidak mengaktifkannya. Oleh karena itu, kami memulai dua pengatur waktu saat terhubung:
- Keep-alive setiap 30 detik → GET /gopro/camera/keep_alive
- Polling status setiap 5 detik → 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
}
}
Kegagalan keep-alive juga merupakan sinyal pemutusan kami — ini adalah cara paling andal untuk menyadari pengguna keluar dari jangkauan WiFi.
4. Kontrol kamera: shutter, mode, dan 'tarian' lensa MAX 2
Shutter adalah bagian yang mudah — mulai, tunggu 500 ms, polling ulang status. Pengambilan foto adalah shutter/start yang sama; apakah Anda mendapatkan video atau foto tergantung pada mode saat ini.
Pengalihan mode menjadi menarik, karena "mode" pada GoPro MAX 2 sebenarnya adalah dua pengaturan: grup preset (Video = 1000, Foto = 1001, Timelapse = 1002) ditambah lensa (Pengaturan 194: 0 = lensa tunggal/HERO, 1 = 360). Mendapatkan ini dengan benar membutuhkan beberapa aturan yang tidak jelas yang tertanam dalam kode:
- Serialkan perubahan mode. Ketukan sub-mode yang cepat diantrekan melalui rantai Completer sehingga tidak bisa saling berpacu ke dalam keadaan yang tidak konsisten.
- Hentikan shutter terlebih dahulu. Firmware menolak perubahan preset dengan HTTP 400 saat merekam, jadi kami secara proaktif menghentikan encoding sebelum beralih.
- Urutan dan penundaan itu penting. Pada MAX 2: atur grup preset terlebih dahulu (+400 ms), kemudian lensa via Pengaturan 194 (+1500 ms). Jeda 1,5 detik itu nyata — ini adalah transisi perakitan lensa fisik.
- Percayai status yang dimaksudkan, bukan status yang di-polling. Kami melacak _lastIntendedIs360 / _lastIntendedPresetGroup dan membandingkannya, karena polling status 5 detik dapat memberikan Anda snapshot usang di tengah transisi.
Ada juga keanehan GoPro yang sangat licik yang patut disebutkan: ID resolusi tergantung pada rasio aspek. 4K adalah opsi 1 di 16:9, 109 di 9:16, dan 112 di 4:3. Jadi setelah mengubah rasio aspek, kami meminta ulang kemampuan — ID yang sama sebaliknya akan berarti resolusi yang berbeda.
5. Mendaftar media
GET /gopro/media/list mengembalikan struktur yang dikelompokkan berdasarkan direktori dengan kunci-kunci yang terkenal ringkas: d = direktori, fs = file, n = nama file, s = ukuran, cre/mod = timestamp unix-detik, g = ID grup, glrv = ukuran low-res-proxy.
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
Jenis media disimpulkan dari awalan + ekstensi nama file: GS = video 360, GX = video HERO, GT = timelapse, .360 = video sferis, .GPR/.RAW = foto raw. Awalan ini penting nanti, karena file .360 berperilaku berbeda dari yang lainnya.
6. Unduhan streaming: turbo, progres, dan file pendamping
Transfer byte sebenarnya adalah dio.download() langsung ke path file, dengan receive timeout yang panjang karena klip .360 berukuran 1 GB membutuhkan waktu:
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)),
);
}
Di sekitar inti tersebut, layanan transfer mengorkestrasi detail dunia nyata:
Transfer Turbo. Sebelum mengunduh, kami mengaktifkan mode turbo (turbo_transfer?p=1) — GoPro beralih ke WiFi 5 GHz untuk transfer yang jauh lebih cepat — dan kami mematikannya di blok finally agar selalu dibersihkan, bahkan saat terjadi kesalahan.
Progres + kecepatan, dibatasi. Menghitung kecepatan transfer pada setiap callback onReceiveProgress adalah pemborosan dan tidak stabil, jadi kami menghitung ulang hanya setiap 500 ms:
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));
}
Pembatalan adalah flag _isCancelled kooperatif; saat dibatalkan, kami menghapus file parsial dan mengeluarkan peristiwa dibatalkan.
File pendamping. Ini adalah bagian yang tidak akan Anda temukan di dokumen. Untuk .MP4 lensa tunggal, kami juga mengambil .LRV (proxy resolusi rendah yang direkam GoPro bersama video lengkap) dan thumbnail .THM, lalu mengindeks LRV untuk pemutaran cepat dalam aplikasi. Dua 'jebakan' penamaan terjadi di sini:
- HERO8+ mengganti nama proxy: GX######.MP4 / GH######.MP4 menjadi GL######.LRV (karakter kedua berubah menjadi L), dengan fallback nama yang sama untuk kamera lama.
- File .360 sama sekali tidak mendukung endpoint thumbnail atau screennail. Kami menggunakan fallback ke pendamping .THM, dan sebagai upaya terakhir, mengunduh file itu sendiri. Dan karena .360 adalah dual-fisheye, kami memotong thumbnail ke lensa depan — pada background isolate via compute(), sehingga UI tidak pernah tersendat.
Status transfer diekspos melalui notifikasi Riverpod yang berlangganan stream file dan transfer, menjaga transfer yang selesai tetap terlihat selama 2 detik, dan menampilkan error selama 5 detik. Perlu diperhatikan: kontrol dan transfer file menggunakan dua klien Dio independen ke kamera yang sama, sehingga unduhan dapat berjalan sementara polling status dan keep-alive terus berlanjut.
7. Dua 'jebakan' yang tidak ada dalam tutorial mana pun
HTTP cleartext diblokir secara default di Android modern
http://10.5.5.9:8080 adalah teks biasa, dan Android 9+ secara default memblokir lalu lintas cleartext. Anda harus secara eksplisit mengizinkannya:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
ditambah network_security_config.xml yang konfigurasi dasarnya mengizinkan cleartext (Anda masih dapat memaksa HTTPS untuk domain cloud/backend Anda sendiri). Lewati ini dan setiap panggilan GoPro akan gagal dengan error koneksi yang membingungkan.
Binding jaringan yang hilang — titik rapuh desain
Inilah bagian jujurnya. Pada Android modern, ketika Anda bergabung dengan AP GoPro, ponsel biasanya mempertahankan seluler sebagai jaringan default karena AP GoPro tidak memiliki internet. Itu berarti permintaan HTTP ke 10.5.5.9 dapat dirutekan melalui seluler dan gagal — meskipun Anda "terhubung" ke kamera.
Perbaikan yang kuat adalah native: panggil ConnectivityManager.bindProcessToNetwork(goProNetwork) sebelum transfer dan bindProcessToNetwork(null) setelahnya. Jalur GoPro pure-Dart tidak memiliki padanan — dan cenderung berfungsi karena OS umumnya merutekan alamat 10.5.5.9 yang tetap ke AP. Tetapi ini adalah bagian paling rapuh dari arsitektur, dan pada beberapa perangkat/versi Android, ini adalah hal pertama yang rusak. Jika Anda meluncurkan ini, tambahkan binding jaringan — ini adalah satu-satunya tempat di mana tetap 100% Dart merugikan Anda keandalan.
Pelajaran yang dipetik
- Anda tidak membutuhkan BLE. Jika pengguna dapat bergabung dengan AP, API HTTP Open GoPro memberikan Anda kontrol penuh dan transfer cepat dalam Dart murni.
- Deteksi dengan probe TCP, bukan pemindaian SSID — tidak ada izin lokasi, tidak ada kode platform.
- Keep-alive setiap 30 detik atau sesi akan mati. Perlakukan kegagalan keep-alive sebagai sinyal putus koneksi Anda.
- Biarkan kamera mengoreksi Anda. Loop penyembuhan diri 403-available_options mengalahkan pemodelan seluruh matriks pengaturan GoPro.
- Pengalihan mode adalah state machine — serialkan, hentikan perekaman terlebih dahulu, hargai penundaan transisi lensa, dan percayai status yang dimaksudkan di atas status yang di-polling.
- File pendamping dan keanehan .360 adalah tempat 'mayat-mayat dikubur' — penggantian nama LRV, tidak ada endpoint thumbnail, pemotongan dual-fisheye.
- Dua 'jebakan' infrastruktur akan diam-diam menenggelamkan Anda: konfigurasi lalu lintas cleartext, dan (terutama) mengikat proses ke jaringan kamera sehingga permintaan tidak lolos melalui seluler.
Hasilnya: ponsel yang terhubung ke GoPro melalui WiFi, menggerakkan setiap kontrol, dan melakukan streaming gigabyte rekaman darinya dengan progres langsung — dan hampir semuanya hanyalah Dart yang terstruktur dengan baik berbicara ke http://10.5.5.9:8080.

