SDK yok, BLE eşleştirme karmaşası yok — sadece kameranın sabit IP'si, bir Dio istemcisi ve bilmezseniz gününüzü sessizce mahvedecek birkaç ince ayrıntı.
Neden Bluetooth değil de WiFi?
Çoğu "GoPro'ya bağlanma" eğitimi Bluetooth Low Energy ile başlar: tarama, eşleştirme, GATT özelliklerini değiş tokuş etme, ardından WiFi'ı açmak için BLE kullanma ve sonra zaten WiFi üzerinden aktarım yapma. Çalışıyor, ancak çok fazla seremoni gerektiriyor — ve BLE, kullanıcıların gerçekten istediği şey olan kamerayı kontrol etme ve görüntüleri hızlı bir şekilde çekme için yavaş ve karmaşıktır.
Bu yüzden bu uygulamada BLE'yi tamamen atlıyoruz. Kullanıcı, telefonunun ayarlarından GoPro'nun WiFi erişim noktasına katılır ve o noktadan itibaren her şey, kameranın sabit bir adresteki Open GoPro API'sine karşı düz HTTP'dir:
http://10.5.5.9:8080
Kamera kontrolü, ayarlar, medya listeleme ve dosya indirme işlemlerinin hepsi o IP'ye yapılan HTTP GET istekleridir. Tüm entegrasyon, dio paketi ile %100 Dart'tır — sıfır native Kotlin/Swift. Bu manşet, ve sonunda göreceğimiz gibi, tasarımın kırılgan olduğu tek yer burasıdır.
Akış:
GoPro WiFi AP'ye katıl (telefon ayarları)
→ kameranın erişilebilir olduğunu tespit et (10.5.5.9:8080'e TCP probu)
→ bağlan + modeli tanımla (GET /gopro/camera/state)
→ keep-alive (30s) + durum sorgulama (5s) zamanlayıcılarını başlat
→ kontrol: deklanşör / mod / ayarlar
→ medyayı listele (GET /gopro/media/list)
→ turbo + ilerleme ile indir (dio.download)
1. WiFi tarama izinleri olmadan kamerayı tespit etme
İlk içgüdü, WiFi SSID'lerini listelemek ve GoPro ile başlayan birini aramaktır. Yapmayın — bu, konum izinlerini ve platforma özgü WiFi tarama API'lerini beraberinde getirir.
GoPro her zaman aynı IP'de bulunur, bu yüzden tespit sadece şudur: 10.5.5.9:8080'e bir TCP soketi açabilir miyim? HTTP yok, izin yok, SSID ayrıştırması yok.
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);
}
}
}
Bunu her 2 saniyede bir sorgularız ve durum değişikliklerini bir stream üzerinden yayınlarız. Bağlantı ekranı abone olur ve soket açıldığı anda "bağlı" durumuna geçer. Gerçek bir cihaz keşfi olmadığı için, servis sanal bir CameraDevice (sabit IP'den türetilmiş) sentezler, böylece kamera soyutlamasının geri kalanının farkı bilmesine gerek kalmaz.
2. HTTP istemcisi: Dio ve Open GoPro endpoint'leri
İstemci, ince bir Dio sarmalayıcısıdır. Kısa bağlantı zaman aşımına (bir LAN üzerindesiniz — yavaşsa, bozuktur) ve kontrol çağrıları için cömert bir alma zaman aşımına dikkat edin:
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 hoş bir şekilde tekdüzedir: komutlar ve ayar değişiklikleri dahil her şey bir HTTP GET isteğidir. İşte bu uygulamanın gerçekten kullandığı endpoint'ler:
| Amaç | Metot + Yol | Sorgu |
|---|---|---|
| Deklanşörü başlat (kayıt / fotoğraf) | GET /gopro/camera/shutter/start | — |
| Deklanşörü durdur | GET /gopro/camera/shutter/stop | — |
| Keep-alive | GET /gopro/camera/keep_alive | — |
| Tam durum (durum + ayarlar) | GET /gopro/camera/state | — |
| Ön ayar grubunu ayarla | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Bir ayarı değiştir | GET /gopro/camera/setting | setting, option |
| Medya listesi | GET /gopro/media/list | — |
| Bir dosya indir | GET /videos/DCIM/{dir}/{file} | (bytes) |
| Küçük resim / ekran görüntüsü | GET /gopro/media/thumbnail / .../screennail | path |
| Turbo aktarım | GET /gopro/media/turbo_transfer | p (1/0) |
| .360 grubunu sil (eski) | GET /gp/gpControl/command/storage/delete/group | p |
Güzel bir ayrıntı: hem modern /gopro/... ailesi hem de eski /gp/gpControl/... ailesi bir arada bulunur. Yeni API'nin düzgün bir şekilde işleyemediği bölümlenmiş .360/GS grup dosyalarının kaldırılması, eski endpoint'e başvurduğumuz tek durumdur.
403 kendini onarma deseni (buradaki en temiz numara)
Kameranın mevcut modunda hangi ayarların geçerli olduğunu önceden bilemezsiniz — 16:9'da geçerli olan bir çözünürlük 9:16'da geçersizdir, FPS seçenekleri çözünürlüğe bağlıdır vb. GoPro bunu size zor yoldan söyler: geçerli olabilecek seçeneklerin listesiyle birlikte HTTP 403 döndürür ve anlık olarak meşgul olduğunda HTTP 500 döndürür.
Bu yüzden 403 gövdesini tipik bir hataya ayrıştırırız ve 500'ü yeniden deneriz:
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;
}
}
}
}
}
Kameranın bildirdiği seçenekler, servis katmanı tarafından SettingRejectedError tespit ettiğinde kendi yetenek listesini yeniden yazmak için kullanılır. Her model için, iyimser, sabit kodlu bir yetenek tablosuyla başlarız ve çalışma süresi boyunca kameranın bizi düzeltmesine izin veririz. Bu, GoPro'nun tüm ayar matrisini önceden simüle etmeye çalışmaktan daha pratik ve kendini düzelten bir yaklaşımdır.
3. Keep-alive: unutmamanız gereken zamanlayıcı
Yaklaşık 60 saniye hareketsizlikten sonra, GoPro WiFi AP oturumunuzu sonlandırır. Eğer dürtelemezseniz, kusursuz bağlantınız bir oturumun ortasında sessizce ölür. Bu nedenle, bağlantıda iki zamanlayıcı başlatırız:
- Her 30 saniyede bir Keep-alive → GET /gopro/camera/keep_alive
- Her 5 saniyede bir Durum sorgulama → 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
}
}
Başarısız bir keep-alive aynı zamanda bağlantı kesme sinyalimizdir — kullanıcının WiFi menzilinden çıktığını fark etmenin en güvenilir yoludur.
4. Kamera kontrolü: deklanşör, mod ve MAX 2 lens hareketi
Deklanşör kolay kısım — başlat, 500 ms bekle, durumu tekrar sorgula. Fotoğraf çekimi aynı deklanşör/başlat işlemidir; video mu yoksa fotoğraf mı çektiğiniz mevcut moda bağlıdır.
Mod değiştirme ilginçleşiyor, çünkü bir GoPro MAX 2'deki "mod" aslında iki ayardır: bir ön ayar grubu (Video = 1000, Fotoğraf = 1001, Hızlandırılmış Çekim = 1002) ve bir lens (Ayar 194: 0 = tek lensli/HERO, 1 = 360). Bunu doğru yapmak, koda işlenmiş birkaç bariz olmayan kural gerektirdi:
- Mod değişikliklerini serileştirin. Hızlı alt mod dokunuşları, Completer zinciri aracılığıyla sıraya alınır, böylece tutarsız bir duruma girmek için birbirleriyle yarışamazlar.
- Önce deklanşörü durdurun. Firmware, kayıt sırasında ön ayar değişikliklerini HTTP 400 ile reddeder, bu yüzden değiştirmeden önce kodlamayı proaktif olarak durdururuz.
- Sıra ve gecikmeler önemlidir. MAX 2'de: önce ön ayar grubunu ayarlayın (+400 ms), ardından Ayar 194 aracılığıyla lensi ayarlayın (+1500 ms). Bu 1.5 saniyelik duraklama gerçek — fiziksel lens düzeneği geçiş yapıyor.
- Sorgulanan duruma değil, amaçlanan duruma güvenin. _lastIntendedIs360 / _lastIntendedPresetGroup değerlerini izler ve onlara göre farkı alırız, çünkü 5 saniyelik durum sorgulaması geçişin ortasında size eski bir anlık görüntü verebilir.
Bahsetmeye değer, gerçekten sinsi bir GoPro tuhaflığı da var: çözünürlük ID'leri en boy oranına bağlıdır. 4K, 16:9'da seçenek 1, 9:16'da 109 ve 4:3'te 112'dir. Bu yüzden en boy oranını değiştirdikten sonra yetenekleri tekrar sorgularız — aksi takdirde aynı ID farklı bir çözünürlük anlamına gelirdi.
5. Medya listeleme
GET /gopro/media/list, ünlü kısaltmalı anahtarlara sahip dizin gruplandırılmış bir yapı döndürür: d = dizin, fs = dosyalar, n = dosya adı, s = boyut, cre/mod = unix-saniye zaman damgaları, g = grup ID'si, glrv = düşük çözünürlüklü proxy boyutu.
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
Medya türü, dosya adı önekinden + uzantıdan çıkarılır: GS = 360 video, GX = HERO video, GT = hızlandırılmış çekim, .360 = küresel video, .GPR/.RAW = raw fotoğraf. Bu önekler daha sonra önemlidir, çünkü .360 dosyaları diğer her şeyden farklı davranır.
6. Akış indirmeleri: turbo, ilerleme ve yardımcı dosyalar
Gerçek bayt aktarımı, uzun bir alma zaman aşımı ile doğrudan bir dosya yoluna dio.download() şeklindedir, çünkü 1 GB'lık bir .360 klibi biraz zaman alır:
Future<void> downloadMediaToFile(
String directory, String filename, String destPath,
{void Function(int received, int total)? onProgress}) async {
await _dio
'/videos/DCIM/$directory/$filename',
destPath,
onReceiveProgress: onProgress,
options: Options(receiveTimeout: const Duration(minutes: 30)),
);
}
Bu çekirdeğin etrafında, aktarım hizmeti gerçek dünya ayrıntılarını düzenler:
Turbo aktarım. İndirmeden önce turbo modu açarız (turbo_transfer?p=1) — GoPro, belirgin şekilde daha hızlı aktarımlar için 5 GHz WiFi'a geçer — ve bir finally bloğunda kapatırız, böylece hata durumunda bile her zaman temizlenir.
İlerleme + hız, kısılmış. Her onReceiveProgress geri çağrısında aktarım hızını hesaplamak israflı ve dalgalıdır, bu yüzden onu yalnızca her 500 ms'de bir yeniden hesaplarız:
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));
}
İptal, işbirlikçi bir _isCancelled bayrağıdır; iptal edildiğinde kısmi dosyayı siler ve iptal edildi olayını yayınlarız.
Yardımcı dosyalar. Bu, dokümanlarda bulamayacağınız kısımdır. Tek lensli bir .MP4 için ayrıca .LRV'yi (GoPro'nun tam videonun yanında kaydettiği düşük çözünürlüklü bir proxy) ve .THM küçük resmini çeker, ardından hızlı uygulama içi oynatma için LRV'yi dizine ekleriz. Burada iki isimlendirme inceliği var:
- HERO8+ proxy'yi yeniden adlandırır: GX######.MP4 / GH######.MP4, GL######.LRV olur (ikinci karakter L'ye dönüşür), daha eski kameralar için aynı adla bir yedekleme ile.
- .360 dosyaları küçük resim veya ekran görüntüsü endpoint'lerini hiç desteklemez. .THM yardımcı dosyasına geri döneriz ve son çare olarak dosyanın kendisini indiririz. Ve bir .360 çift balık gözü olduğu için, küçük resmi ön lense kırparız — compute() aracılığıyla bir arka plan izolatında, böylece UI asla takılmaz.
Aktarım durumu, dosya ve aktarım stream'lerine abone olan, tamamlanmış bir aktarımı 2 saniye boyunca görünür tutan ve hataları 5 saniye boyunca gösteren bir Riverpod notifier aracılığıyla ifşa edilir. Şunu belirtmekte fayda var: kontrol ve dosya aktarımı aynı kameraya iki bağımsız Dio istemcisi kullanır, böylece durum sorgulaması ve keep-alive devam ederken bir indirme işlemi de çalışabilir.
7. Hiçbir eğitimde olmayan iki ince ayrıntı
Şeffaf metin HTTP, modern Android'de varsayılan olarak engellenir
http://10.5.5.9:8080 düz metindir ve Android 9+ varsayılan olarak şeffaf metin trafiğini engeller. Buna açıkça izin vermeniz gerekir:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
artı temel yapılandırması şeffaf metine izin veren bir network_security_config.xml (kendi bulut/arka uç alan adlarınız için hala HTTPS'yi zorlayabilirsiniz). Bunu atlarsanız, her GoPro çağrısı kafa karıştırıcı bir bağlantı hatasıyla başarısız olur.
Eksik ağ bağlama — tasarımın kırılgan noktası
İşte dürüst kısım. Modern Android'de, GoPro AP'sine katıldığınızda, telefon genellikle hücresel bağlantıyı varsayılan ağ olarak tutar çünkü GoPro AP'sinde internet yoktur. Bu, 10.5.5.9'a yapılan bir HTTP isteğinin hücresel üzerinden yönlendirilip başarısız olabileceği anlamına gelir — kameraya "bağlı" olsanız bile.
Sağlam çözüm native'dir: aktarımlardan önce ConnectivityManager.bindProcessToNetwork(goProNetwork) çağrısı yapın ve sonrasında bindProcessToNetwork(null) çağırın. Saf Dart bir GoPro yolu eşdeğerine sahip değildir — ve yine de genellikle çalışır çünkü OS sabit 10.5.5.9 adresini AP'ye yönlendirir. Ancak mimarinin en kırılgan kısmıdır ve bazı cihazlarda/Android sürümlerinde bozulan ilk şeydir. Eğer bunu yayınlarsanız, ağ bağlamayı ekleyin — %100 Dart kalmanın size güvenilirlik kaybettirdiği tek yer burasıdır.
Öğrenilen dersler
- BLE'ye ihtiyacınız yok. Kullanıcı AP'ye katılabiliyorsa, Open GoPro HTTP API size tam kontrol ve saf Dart'ta hızlı aktarımlar sağlar.
- SSID taramasıyla değil, TCP probuyla tespit edin — konum izni yok, platform kodu yok.
- Her 30 saniyede bir keep-alive yapın, aksi takdirde oturum ölür. Başarısız bir keep-alive'ı bağlantı kesme sinyaliniz olarak kabul edin.
- Kameranın sizi düzeltmesine izin verin. 403-available_options kendini onarma döngüsü, GoPro'nun tüm ayar matrisini modellemekten daha iyidir.
- Mod değiştirme bir durum makinesidir — serileştirin, önce kaydı durdurun, lens geçiş gecikmelerine uyun ve sorgulanan duruma göre amaçlanan duruma güvenin.
- Yardımcı dosyalar ve .360 tuhaflıkları, LRV yeniden adlandırma, küçük resim endpoint'i olmaması, çift balık gözü kırpma gibi zorlukları barındırır.
- İki altyapı inceliği sizi sessizce batırır: şeffaf metin trafik yapılandırması ve (özellikle) isteklerin hücresel üzerinden kaçmaması için süreci kamera ağına bağlama.
Karşılığı: WiFi üzerinden bir GoPro'ya bağlanan, her kontrolü yöneten ve canlı ilerlemeyle gigabaytlarca görüntü akışı sağlayan bir telefon — ve neredeyse tamamı sadece iyi yapılandırılmış Dart'ın http://10.5.5.9:8080 ile konuşmasından ibaret.

