Tanpa SDK, tanpa kerumitan BLE pairing — hanya IP tetap kamera, klien Dio, dan beberapa kendala yang diam-diam akan merusak harimu jika tidak kamu ketahui.
Mengapa WiFi, bukan Bluetooth?
Kebanyakan tutorial "menghubungkan ke GoPro" dimulai dengan Bluetooth Low Energy: memindai, memasangkan, bertukar karakteristik GATT, lalu menggunakan BLE untuk mengaktifkan WiFi, kemudian mentransfer melalui WiFi juga. Ini berhasil, tetapi banyak sekali prosedur — dan BLE lambat serta rumit untuk hal yang sebenarnya diinginkan pengguna, yaitu mengendalikan kamera dan mengambil rekamannya dengan cepat.
Jadi, di aplikasi ini kami melewati BLE sepenuhnya. Pengguna bergabung dengan titik akses WiFi GoPro dari pengaturan ponsel mereka, dan mulai 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 native Kotlin/Swift. Itu intinya, dan seperti yang akan kita lihat di akhir, ini juga satu-satunya titik di mana desainnya rapuh.
Alur kerjanya:
bergabung dengan GoPro WiFi AP (pengaturan ponsel)
→ deteksi kamera dapat dijangkau (TCP probe ke 10.5.5.9:8080)
→ sambungkan + identifikasi model (GET /gopro/camera/state)
→ mulai timer keep-alive (30s) + status poll (5s)
→ kontrol: shutter / mode / pengaturan
→ daftar media (GET /gopro/media/list)
→ unduh dengan turbo + progress (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 khusus platform.
GoPro selalu berada di IP yang sama, jadi deteksinya hanyalah: bisakah saya membuka soket TCP ke 10.5.5.9:8080? Tanpa HTTP, tanpa izin, tanpa parsing 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 stream. Layar koneksi berlangganan dan beralih ke "terhubung" saat soket terbuka. Karena tidak ada penemuan perangkat yang sebenarnya, layanan ini mensintesis CameraDevice virtual (berkunci pada IP tetap) sehingga abstraksi kamera lainnya tidak perlu mengetahui perbedaannya.
2. Klien HTTP: Dio dan endpoint Open GoPro
Klien adalah wrapper Dio yang tipis. Perhatikan timeout koneksi yang singkat (Anda berada di LAN — jika lambat, berarti rusak) dan timeout penerimaan 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 + Jalur | 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 (lama) | GET /gp/gpControl/command/storage/delete/group | p |
Detail yang bagus: baik keluarga /gopro/... modern maupun keluarga /gp/gpControl/... lama 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 menjadi ilegal dalam 9:16, opsi FPS bergantung pada resolusi, dan seterusnya. GoPro memberitahu Anda ini dengan cara yang sulit: ia mengembalikan HTTP 403 dengan daftar opsi yang seharusnya valid, dan HTTP 500 ketika sedang sibuk sesaat.
Jadi, kami mengurai body 403 menjadi error yang diketik 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;
}
}
rethrow;
}
}
}
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 kaku dan membiarkan kamera mengoreksi kami selama runtime. Ini lebih praktis dan dapat mengoreksi diri sendiri daripada mencoba mensimulasikan seluruh matriks pengaturan GoPro di awal.
3. Keep-alive: timer yang tidak boleh Anda lupakan
Setelah sekitar 60 detik tidak aktif, GoPro WiFi AP akan mengakhiri sesi Anda. Koneksi Anda yang sempurna diam-diam akan terputus di tengah sesi jika Anda tidak 'menusuknya'. Oleh karena itu, kami memulai dua timer saat terhubung:
- Keep-alive setiap 30 detik → GET /gopro/camera/keep_alive
- Status poll 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 koneksi kami — ini adalah cara paling andal untuk menyadari bahwa 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, Photo = 1001, Timelapse = 1002) ditambah lensa (Pengaturan 194: 0 = lensa tunggal/HERO, 1 = 360). Untuk melakukannya dengan benar diperlukan beberapa aturan yang tidak terlalu jelas yang ditanamkan dalam kode:
- Serialkan perubahan mode. Ketukan sub-mode yang cepat diantrekan melalui rantai Completer agar tidak saling berebut ke status 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 melalui Pengaturan 194 (+1500 ms). Jeda 1,5 detik itu nyata — itu adalah transisi rakitan 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 bergantung pada rasio aspek. 4K adalah opsi 1 dalam 16:9, 109 dalam 9:16, dan 112 dalam 4:3. Jadi, setelah mengubah rasio aspek, kami meminta ulang kemampuan — ID yang sama akan berarti resolusi yang berbeda.
5. Mencantumkan 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 = stempel waktu unix-detik, g = ID grup, glrv = ukuran proxy resolusi rendah.
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, progress, dan file pendamping
Transfer byte sebenarnya adalah dio.download() langsung ke jalur file, dengan receive timeout yang lama karena klip .360 berukuran 1 GB membutuhkan waktu lama:
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 mengatur 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.
Progress + kecepatan, dibatasi. Menghitung kecepatan transfer pada setiap callback onReceiveProgress akan memboroskan 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 membatalkan, kami menghapus file parsial dan memancarkan 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 di dalam aplikasi. Ada dua masalah penamaan yang menyulitkan 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 kembali ke file 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 melalui compute(), sehingga UI tidak pernah tersendat.
Status transfer diekspos melalui notifikasi Riverpod yang berlangganan stream file dan transfer, menjaga transfer yang selesai terlihat selama 2 detik, dan menampilkan error selama 5 detik. Perlu dicatat: kontrol dan transfer file menggunakan dua klien Dio independen ke kamera yang sama, sehingga unduhan dapat berjalan sementara polling status dan keep-alive terus berjalan.
7. Dua kendala yang tidak ada di tutorial mana pun
HTTP cleartext diblokir secara default di Android modern
http://10.5.5.9:8080 adalah plaintext, 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 yang jujur. Di Android modern, ketika Anda bergabung dengan GoPro AP, ponsel biasanya mempertahankan seluler sebagai jaringan default karena GoPro AP tidak memiliki internet. Itu berarti permintaan HTTP ke 10.5.5.9 dapat dialihkan 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 murni-Dart tidak memiliki padanan — dan cenderung tetap berfungsi karena OS umumnya mengarahkan alamat 10.5.5.9 yang tetap ke AP. Tetapi itu adalah bagian paling rapuh dari arsitektur, dan pada beberapa perangkat/versi Android itu adalah hal pertama yang rusak. Jika Anda merilis ini, tambahkan network binding — ini adalah satu-satunya tempat di mana tetap 100% Dart mengorbankan keandalan Anda.
Pelajaran yang didapat
- Anda tidak memerlukan BLE. Jika pengguna dapat bergabung dengan AP, Open GoPro HTTP API memberi Anda kontrol penuh dan transfer cepat dalam Dart murni.
- Deteksi dengan TCP probe, bukan pemindaian SSID — tanpa izin lokasi, tanpa kode platform.
- Keep-alive setiap 30 detik atau sesi akan berakhir. Perlakukan kegagalan keep-alive sebagai sinyal pemutusan koneksi Anda.
- Biarkan kamera mengoreksi Anda. Loop penyembuhan diri 403-available_options lebih baik daripada memodelkan seluruh matriks pengaturan GoPro.
- Pengalihan mode adalah mesin status — serialkan, hentikan perekaman terlebih dahulu, hargai penundaan transisi lensa, dan percayai status yang dimaksudkan daripada status yang di-polling.
- File pendamping dan keanehan .360 adalah tempat masalah tersembunyi — penggantian nama LRV, tidak ada endpoint thumbnail, pemotongan dual-fisheye.
- Dua kendala infrastruktur akan diam-diam menenggelamkan Anda: konfigurasi lalu lintas cleartext, dan (terutama) binding proses ke jaringan kamera sehingga permintaan tidak lolos melalui seluler.
Imbalannya: ponsel yang terhubung ke GoPro melalui WiFi, mengendalikan setiap fitur, dan melakukan streaming gigabyte rekaman darinya dengan progress langsung — dan hampir semuanya hanyalah Dart yang terstruktur dengan baik berbicara dengan http://10.5.5.9:8080.

