Tiada SDK, tiada tarian `BLE` pairing — hanya IP tetap kamera, sebuah klien Dio, dan beberapa perangkap yang akan diam-diam merosakkan hari anda jika anda tidak mengetahuinya.
Mengapa WiFi, bukan Bluetooth?
Kebanyakan tutorial "sambung ke GoPro" bermula dengan Bluetooth Low Energy: imbas, pasangkan, tukar ciri-ciri GATT, kemudian gunakan BLE untuk menghidupkan WiFi, kemudian pindahkan melalui WiFi juga. Ia berfungsi, tetapi ia melibatkan banyak upacara — dan BLE perlahan dan rumit untuk perkara yang pengguna inginkan, iaitu mengawal kamera dan menarik rakaman daripadanya dengan cepat.
Jadi dalam aplikasi ini kami melangkau BLE sepenuhnya. Pengguna menyertai titik akses WiFi GoPro dari tetapan telefon mereka, dan dari titik itu semua adalah HTTP biasa terhadap API Open GoPro pada alamat tetap:
http://10.5.5.9:8080
Kawalan kamera, tetapan, penyenaraian media, dan muat turun fail semuanya adalah permintaan HTTP GET ke IP tersebut. Keseluruhan integrasi adalah 100% Dart dengan pakej dio — sifar native Kotlin/Swift. Itu tajuk utama, dan seperti yang akan kita lihat di penghujung, ia juga satu-satunya tempat reka bentuknya rapuh.
Aliran:
sertai `GoPro WiFi AP` (tetapan telefon)
→ kesan kamera boleh dicapai (`TCP` probe ke 10.5.5.9:8080)
→ sambung + kenal pasti model (`GET /gopro/camera/state`)
→ mulakan pemasa `keep-alive` (30s) + tinjauan status (5s)
→ kawalan: pengatup / mod / tetapan
→ senarai media (`GET /gopro/media/list`)
→ muat turun dengan `turbo` + kemajuan (`dio.download`)
1. Mengesan kamera tanpa kebenaran imbasan `WiFi`
Naluri pertama adalah untuk menyenaraikan SSID `WiFi` dan mencari yang bermula dengan GoPro. Jangan — itu memerlukan kebenaran lokasi dan API imbasan `WiFi` khusus platform.
GoPro sentiasa berada pada IP yang sama, jadi pengesanan hanyalah: bolehkah saya membuka soket TCP ke 10.5.5.9:8080? Tiada HTTP, tiada kebenaran, tiada 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 tinjauan itu setiap 2 saat dan menyiarkan perubahan keadaan melalui `stream`. Skrin sambungan melanggan dan bertukar kepada "bersambung" sebaik sahaja soket terbuka. Oleh kerana tiada penemuan peranti sebenar, perkhidmatan ini mensintesis `CameraDevice` maya (berdasarkan IP tetap) supaya selebihnya abstraksi kamera tidak perlu tahu perbezaannya.
2. Klien HTTP: Dio dan `endpoint` Open GoPro
Klien ini adalah pembalut Dio yang nipis. Perhatikan `connect timeout` yang pendek (anda berada di LAN — jika perlahan, ia rosak) dan `receive timeout` yang murah hati untuk panggilan kawalan:
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, malah arahan dan perubahan tetapan. Berikut adalah `endpoint` yang digunakan oleh aplikasi ini:
| Tujuan | Kaedah + Laluan | Kueri |
|---|---|---|
| Mula pengatup (rakam / foto) | GET /gopro/camera/shutter/start | — |
| Hentikan pengatup | GET /gopro/camera/shutter/stop | — |
| `Keep-alive` | GET /gopro/camera/keep_alive | — |
| Keadaan penuh (status + tetapan) | GET /gopro/camera/state | — |
| Tetapkan kumpulan pratetap | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Tukar tetapan | GET /gopro/camera/setting | `setting`, `option` |
| Senarai media | GET /gopro/media/list | — |
| Muat turun fail | GET /videos/DCIM/{dir}/{file} | (bait) |
| Thumbnail / Screennail | GET /gopro/media/thumbnail / .../screennail | `path` |
| Pemindahan `Turbo` | GET /gopro/media/turbo_transfer | p (1/0) |
| Padam kumpulan .360 (lama) | GET /gp/gpControl/command/storage/delete/group | p |
Satu butiran menarik: kedua-dua keluarga moden `/gopro/...` dan keluarga lama `/gp/gpControl/...` wujud bersama. Penyingkiran fail kumpulan `chaptered.360/GS`, yang tidak dapat dikendalikan dengan bersih oleh API baharu, adalah satu-satunya keadaan di mana kami menggunakan `endpoint` lama.
Corak penyembuhan kendiri 403 (helah paling bersih di sini)
Anda tidak boleh tahu terlebih dahulu tetapan mana yang sah dalam mod kamera semasa — resolusi yang sah dalam 16:9 adalah tidak sah dalam 9:16, pilihan FPS bergantung pada resolusi, dan sebagainya. GoPro memberitahu anda ini dengan cara yang sukar: ia mengembalikan HTTP 403 dengan senarai pilihan yang akan sah, dan HTTP 500 apabila ia hanya sibuk seketika.
Jadi kami mengurai badan 403 menjadi ralat bertaip dan mencuba semula 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;
}
}
}
}
}
Pilihan yang dilaporkan oleh kamera digunakan oleh lapisan perkhidmatan untuk menulis semula senarai keupayaannya sendiri jika ia mengesan `SettingRejectedError`. Untuk setiap model, kami bermula dengan jadual keupayaan yang optimistik dan kod keras dan membenarkan kamera untuk membetulkan kami sepanjang waktu jalanan. Ia lebih praktikal dan membetulkan diri daripada cuba mensimulasikan keseluruhan matriks tetapan GoPro terlebih dahulu.
3. `Keep-alive`: pemasa yang tidak boleh anda lupakan
Selepas kira-kira 60 saat tidak aktif, `GoPro WiFi AP` menamatkan sesi anda. Sambungan anda yang sempurna akan terputus secara senyap di tengah-tengah sesi jika anda tidak mengaktifkannya. Oleh itu, kami memulakan dua pemasa semasa sambungan:
- `Keep-alive` setiap 30 s → GET /gopro/camera/keep_alive
- Tinjauan status setiap 5 s → 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` yang gagal juga merupakan isyarat pemutusan kami — ia adalah cara paling boleh dipercayai untuk menyedari pengguna keluar dari julat WiFi.
4. Kawalan kamera: pengatup, mod, dan tarian lensa MAX 2
Pengatup adalah bahagian yang mudah — mulakan, tunggu 500 ms, tinjau semula keadaan. Penangkapan foto adalah pengatup/mula yang sama; sama ada anda mendapat video atau foto bergantung pada mod semasa.
Penukaran mod menjadi menarik, kerana "mod" pada GoPro MAX 2 sebenarnya adalah dua tetapan: kumpulan pratetap (Video = 1000, Photo = 1001, Timelapse = 1002) ditambah lensa (Tetapan 194: 0 = lensa tunggal/HERO, 1 = 360). Untuk melakukannya dengan betul memerlukan beberapa peraturan tidak jelas yang terbina dalam kod:
- Serialize perubahan mod. Tekanan sub-mod pantas diantri melalui rantai `Completer` supaya mereka tidak bersaing satu sama lain ke dalam keadaan yang tidak konsisten.
- Hentikan pengatup terlebih dahulu. Firmware menolak perubahan pratetap dengan HTTP 400 semasa merakam, jadi kami secara proaktif menghentikan pengekodan sebelum bertukar.
- Susunan dan kelewatan adalah penting. Pada MAX 2: tetapkan kumpulan pratetap terlebih dahulu (+400 ms), kemudian lensa melalui Tetapan 194 (+1500 ms). Jeda 1.5 saat itu adalah nyata — ia adalah peralihan pemasangan lensa fizikal.
- Percayakan keadaan yang dimaksudkan, bukan keadaan yang ditinjau. Kami menjejaki `_lastIntendedIs360` / `_lastIntendedPresetGroup` dan membandingkannya, kerana tinjauan status 5 saat boleh memberikan anda gambaran lama di tengah-tengah peralihan.
Terdapat juga keunikan GoPro yang sangat licik yang perlu disebut: ID resolusi bergantung pada nisbah aspek. 4K adalah pilihan 1 dalam 16:9, 109 dalam 9:16, dan 112 dalam 4:3. Jadi selepas menukar nisbah aspek, kami meminta semula keupayaan — ID yang sama sebaliknya akan bermaksud resolusi yang berbeza.
5. Menyenaraikan media
`GET /gopro/media/list` mengembalikan struktur yang dikumpulkan mengikut direktori dengan kunci yang ringkas dan terkenal: d = direktori, fs = fail, n = nama fail, s = saiz, cre/mod = cap waktu `unix-second`, g = ID kumpulan, glrv = saiz proksi-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 daripada awalan nama fail + sambungan: GS = video 360, GX = video HERO, GT = `timelapse`, .360 = video sfera, .GPR/.RAW = foto mentah. Awalan ini penting kemudian, kerana fail .360 berkelakuan berbeza daripada yang lain.
6. Muat turun `streaming`: turbo, kemajuan, dan fail iringan
Pemindahan bait sebenar adalah `dio.download()` terus ke laluan fail, dengan `receive timeout` yang panjang kerana klip .360 bersaiz 1 GB mengambil masa yang 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 teras itu, perkhidmatan pemindahan mengatur butiran dunia nyata:
Pemindahan `Turbo`. Sebelum memuat turun, kami menghidupkan mod `turbo` (`turbo_transfer?p=1`) — GoPro bertukar kepada `WiFi` 5 GHz untuk pemindahan yang lebih pantas — dan kami mematikannya dalam blok `finally` supaya ia sentiasa dibersihkan, walaupun berlaku ralat.
Kemajuan + kelajuan, terhad. Mengira kelajuan pemindahan pada setiap `onReceiveProgress callback` adalah membazir dan tidak stabil, jadi kami mengira semula 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 bendera `_isCancelled` koperasi; apabila dibatalkan, kami memadamkan fail separa dan mengeluarkan acara dibatalkan.
Fail iringan. Ini adalah bahagian yang tidak akan anda temui dalam dokumen. Untuk .MP4 lensa tunggal, kami juga menarik .LRV (proksi resolusi rendah yang dirakam GoPro bersama video penuh) dan `thumbnail` .THM, kemudian mengindeks LRV untuk main semula dalam aplikasi yang pantas. Dua perangkap penamaan berlaku di sini:
- HERO8+ menamakan semula proksi: GX######.MP4 / GH######.MP4 menjadi GL######.LRV (watak kedua bertukar kepada L), dengan `fallback` nama yang sama untuk kamera lama.
- Fail .360 tidak menyokong `endpoint thumbnail` atau `screennail` sama sekali. Kami kembali kepada fail iringan .THM, dan sebagai langkah terakhir memuat turun fail itu sendiri. Dan kerana .360 adalah `dual-fisheye`, kami memotong `thumbnail` ke lensa hadapan — pada `background isolate` melalui `compute()`, jadi `UI` tidak pernah tersekat.
Keadaan pemindahan didedahkan melalui `notifier Riverpod` yang melanggan `stream` fail dan pemindahan, memastikan pemindahan yang selesai kelihatan selama 2 saat, dan memaparkan ralat selama 5. Perlu diingat: kawalan dan pemindahan fail menggunakan dua klien Dio bebas ke kamera yang sama, jadi muat turun boleh berjalan sementara tinjauan status dan `keep-alive` diteruskan.
7. Dua perangkap yang tiada dalam mana-mana tutorial
HTTP `Cleartext` disekat secara lalai pada Android moden
`http://10.5.5.9:8080` adalah teks biasa, dan Android 9+ menyekat trafik `cleartext` secara lalai. Anda perlu membenarkannya secara eksplisit:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
ditambah `network_security_config.xml` yang konfigurasi asasnya membenarkan `cleartext` (anda masih boleh memaksa HTTPS untuk domain awan/backend anda sendiri). Jika terlepas ini, setiap panggilan GoPro akan gagal dengan ralat sambungan yang mengelirukan.
Pengikatan rangkaian yang hilang — titik rapuh reka bentuk
Inilah bahagian yang jujur. Pada Android moden, apabila anda menyertai `GoPro AP`, telefon biasanya mengekalkan selular sebagai rangkaian lalai kerana `GoPro AP` tiada internet. Ini bermakna permintaan HTTP ke 10.5.5.9 boleh dihalakan melalui selular dan gagal — walaupun anda "bersambung" ke kamera.
Penyelesaian kukuh adalah natif: panggil `ConnectivityManager.bindProcessToNetwork(goProNetwork)` sebelum pemindahan dan `bindProcessToNetwork(null)` selepas itu. Jalur GoPro `pure-Dart` tiada yang setara — dan ia cenderung berfungsi juga kerana OS secara amnya menghalakan alamat tetap 10.5.5.9 ke AP. Tetapi ia adalah bahagian paling rapuh dalam seni bina, dan pada sesetengah peranti/versi Android, ia adalah perkara pertama yang rosak. Jika anda menghantar ini, tambahkan pengikatan rangkaian — ia adalah satu-satunya tempat di mana kekal 100% Dart merugikan anda kebolehpercayaan.
Pengajaran yang dipelajari
- Anda tidak memerlukan BLE. Jika pengguna boleh menyertai AP, API HTTP Open GoPro memberikan anda kawalan penuh dan pemindahan pantas dalam Dart tulen.
- Kesan dengan `TCP probe`, bukan imbasan SSID — tiada kebenaran lokasi, tiada kod platform.
- `Keep-alive` setiap 30 s atau sesi akan tamat. Anggap `keep-alive` yang gagal sebagai isyarat pemutusan anda.
- Biarkan kamera membetulkan anda. Gelung penyembuhan kendiri `403-available_options` mengatasi pemodelan keseluruhan matriks tetapan GoPro.
- Penukaran mod adalah mesin keadaan — `serialize` ia, hentikan rakaman dahulu, hormati kelewatan peralihan lensa, dan percayakan keadaan yang dimaksudkan berbanding keadaan yang ditinjau.
- Fail iringan dan keunikan .360 adalah di mana masalah tersembunyi — penamaan semula LRV, tiada `endpoint thumbnail`, pemotongan `dual-fisheye`.
- Dua perangkap infrastruktur akan menjatuhkan anda secara senyap: konfigurasi trafik `cleartext`, dan (terutamanya) mengikat proses ke rangkaian kamera supaya permintaan tidak terlepas melalui selular.
Hasilnya: telefon yang bersambung ke GoPro melalui WiFi, mengendalikan setiap kawalan, dan menstrim gigabait rakaman daripadanya dengan kemajuan langsung — dan hampir kesemuanya hanyalah Dart yang tersusun rapi berkomunikasi dengan `http://10.5.5.9:8080`.

