Walang SDK, walang BLE pairing dance — ang nakapirming IP lang ng camera, isang Dio client, at ilang "gotcha" na tahimik na sisira sa iyong araw kung hindi mo alam ang tungkol sa mga ito.
Bakit WiFi, hindi Bluetooth?
Karamihan sa mga "connect to a GoPro" na tutorial ay nagsisimula sa Bluetooth Low Energy: i-scan, i-pair, magpalitan ng GATT characteristics, pagkatapos ay gamitin ang BLE para i-on ang WiFi, pagkatapos ay i-transfer pa rin sa WiFi. Gumagana ito, ngunit maraming proseso — at ang BLE ay mabagal at maselan para sa bagay na talagang gusto ng mga user, na kontrolin ang camera at mabilis na kumuha ng footage mula dito.
Kaya sa app na ito ay tuluyan naming nilalaktawan ang BLE. Ang user ay kumokonekta sa GoPro's WiFi access point mula sa settings ng kanilang telepono, at mula sa puntong iyon, lahat ay simpleng HTTP laban sa Open GoPro API sa isang nakapirming address:
http://10.5.5.9:8080
Ang kontrol ng camera, mga setting, paglilista ng media, at pag-download ng file ay pawang mga HTTP GET request sa IP na iyon. Ang buong integrasyon ay 100% Dart gamit ang dio package — zero native Kotlin/Swift. Iyan ang pangunahing punto, at tulad ng makikita natin sa huli, ito rin ang iisang lugar kung saan mahina ang disenyo.
Ang daloy:
kumonekta sa GoPro WiFi AP (phone settings)
→ tukuyin kung naaabot ang camera (TCP probe sa 10.5.5.9:8080)
→ kumonekta + tukuyin ang modelo (GET /gopro/camera/state)
→ simulan ang keep-alive (30s) + status poll (5s) timers
→ kontrol: shutter / mode / settings
→ ilista ang media (GET /gopro/media/list)
→ i-download gamit ang turbo + progress (dio.download)
1. Pagtukoy sa camera nang walang WiFi-scan permissions
Ang unang instinct ay i-enumerate ang mga WiFi SSID at hanapin ang isa na nagsisimula sa GoPro. Huwag — iyan ay magdadala ng location permissions at platform-specific WiFi-scan API.
Ang GoPro ay laging nasa parehong IP, kaya ang pagtukoy ay simple lang: makapagbukas ba ako ng TCP socket sa 10.5.5.9:8080? Walang HTTP, walang permissions, walang SSID parsing.
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 ay nagpo-poll bawat 2 segundo at nagbro-broadcast ng mga pagbabago sa state sa isang stream. Ang connection screen ay nagsu-subscribe at nagiging "connected" sa sandaling magbukas ang socket. Dahil walang tunay na device discovery, ang serbisyo ay gumagawa ng virtual na CameraDevice (batay sa nakapirming IP) upang ang iba pang abstraction ng camera ay hindi na kailangang malaman ang pagkakaiba.
2. Ang HTTP client: Dio at ang Open GoPro endpoints
Ang client ay isang manipis na Dio wrapper. Tandaan ang maikling connect timeout (nasa isang LAN ka — kung mabagal, may sira) at isang mapagbigay na receive timeout para sa mga control call:
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'},
));
}
Ang Open GoPro API ay nakakatuwang uniform: lahat ay isang HTTP GET, kahit ang mga command at pagbabago sa settings. Narito ang mga endpoint na talagang ginagamit ng app na ito:
| Layunin | Method + Path | Query |
|---|---|---|
| Simulan ang shutter (record / photo) | GET /gopro/camera/shutter/start | — |
| Ihinto ang shutter | GET /gopro/camera/shutter/stop | — |
| Keep-alive | GET /gopro/camera/keep_alive | — |
| Buong state (status + settings) | GET /gopro/camera/state | — |
| Magtakda ng preset group | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Baguhin ang isang setting | GET /gopro/camera/setting | setting, option |
| Listahan ng media | GET /gopro/media/list | — |
| Mag-download ng file | GET /videos/DCIM/{dir}/{file} | (bytes) |
| Thumbnail / screennail | GET /gopro/media/thumbnail / .../screennail | path |
| Turbo transfer | GET /gopro/media/turbo_transfer | p (1/0) |
| Burahin ang .360 group (legacy) | GET /gp/gpControl/command/storage/delete/group | p |
Isang magandang detalye: parehong umiiral ang modernong /gopro/... family at ang legacy na /gp/gpControl/... family. Ang pagtanggal ng chaptered.360/GS group files, na hindi kayang hawakan ng bagong API nang malinis, ay ang tanging sitwasyon kung saan kami gumagamit ng lumang endpoint.
Ang 403 self-healing pattern (ang pinakamalinis na trick dito)
Hindi mo malalaman nang maaga kung aling mga setting ang valid sa kasalukuyang mode ng camera — ang resolution na legal sa 16:9 ay illegal sa 9:16, ang mga opsyon sa FPS ay depende sa resolution, at iba pa. Sinasabi sa iyo ng GoPro ito sa mahirap na paraan: ibinabalik nito ang HTTP 403 na may listahan ng mga opsyong dapat ay valid, at HTTP 500 kapag sandali lang itong abala.
Kaya pinaparse namin ang 403 body sa isang typed error at sinusubukan muli ang 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;
}
}
}
Ang mga iniulat na opsyon ng camera ay ginagamit ng service layer upang i-rewrite ang sarili nitong capability list kung makakita ito ng SettingRejectedError. Para sa bawat modelo, nagsisimula kami sa isang optimistic, hard-coded capability table at pinapayagan ang camera na iwasto kami sa buong runtime. Mas praktikal at self-correcting ito kaysa sa pagtatangkang gayahin ang buong settings matrix ng GoPro nang maaga.
3. Keep-alive: ang timer na hindi mo dapat kalimutan
Pagkalipas ng humigit-kumulang 60 segundo ng inactivity, tatapusin ng GoPro WiFi AP ang iyong session. Tahimik na mamamatay ang iyong perpektong koneksyon sa gitna ng isang session kung hindi mo ito gagawing aktibo. Kaya, magsisimula kami ng dalawang timer sa pagkonekta:
- Keep-alive bawat 30 s → GET /gopro/camera/keep_alive
- Status poll bawat 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
}
}
Ang isang failed keep-alive ay nagsisilbing disconnection signal din namin — ito ang pinakamapagkakatiwalaang paraan upang mapansin na lumabas ang user sa saklaw ng WiFi.
4. Kontrol ng camera: shutter, mode, at ang MAX 2 lens dance
Ang shutter ay ang madaling bahagi — simulan, maghintay ng 500 ms, i-re-poll ang state. Ang pagkuha ng larawan ay ang parehong shutter/start; kung makakakuha ka ng video o larawan ay depende sa kasalukuyang mode.
Ang paglipat ng mode ay kung saan nagiging interesante, dahil ang isang "mode" sa isang GoPro MAX 2 ay talagang dalawang setting: isang preset group (Video = 1000, Photo = 1001, Timelapse = 1002) plus isang lens (Setting 194: 0 = single-lens/HERO, 1 = 360). Ang paggawa nito nang tama ay nangailangan ng ilang hindi halatang panuntunan na inilagay sa code:
- I-serialize ang mga pagbabago sa mode. Ang mabilis na pag-tap sa sub-mode ay pinipila sa pamamagitan ng isang Completer chain upang hindi sila magkapareho ng estado.
- Ihinto muna ang shutter. Tinatanggihan ng Firmware ang mga pagbabago sa preset na may HTTP 400 habang nagre-record, kaya pinipigilan namin ang encoding bago magpalit.
- Mahalaga ang order at mga pagkaantala. Sa MAX 2: itakda muna ang preset group (+400 ms), pagkatapos ang lens sa pamamagitan ng Setting 194 (+1500 ms). Totoo ang 1.5-segundong paghinto na iyan — ito ang paglipat ng physical lens assembly.
- Magtiwala sa intended state, hindi sa polled state. Sinusubaybayan namin ang _lastIntendedIs360 / _lastIntendedPresetGroup at ikinukumpara laban sa mga iyon, dahil ang 5-segundong status poll ay maaaring magbigay sa iyo ng lumang snapshot sa gitna ng transisyon.
Mayroon ding talagang mapanlinlang na quirk ng GoPro na dapat banggitin: ang mga resolution ID ay nakasalalay sa aspect ratio. Ang 4K ay opsyon 1 sa 16:9, 109 sa 9:16, at 112 sa 4:3. Kaya pagkatapos baguhin ang aspect ratio, muli kaming nagtatanong ng mga kakayahan — ang parehong ID ay mangangahulugan ng ibang resolution.
5. Paglilista ng media
Ang GET /gopro/media/list ay nagbabalik ng isang directory-grouped structure na may sikat na maikling keys: d = directory, fs = files, n = filename, s = size, cre/mod = unix-second timestamps, g = group id, glrv = ang low-res-proxy size.
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
Ang uri ng media ay iniuugnay mula sa filename prefix + extension: GS = 360 video, GX = HERO video, GT = timelapse, .360 = spherical video, .GPR/.RAW = raw photo. Ang mga prefix na ito ay mahalaga sa bandang huli, dahil ang mga .360 na file ay kumikilos nang iba kumpara sa iba.
6. Streaming downloads: turbo, progress, at companion files
Ang aktwal na paglilipat ng byte ay dio.download() direkta sa isang file path, na may mahabang receive timeout dahil ang isang 1 GB .360 clip ay tumatagal ng matagal:
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)),
);
}
Sa paligid ng core na iyon, ino-orchestrate ng transfer service ang mga detalye sa totoong mundo:
Turbo transfer. Bago mag-download, isina-aktibo namin ang turbo mode (turbo_transfer?p=1) — Lumipat ang GoPro sa 5 GHz WiFi para sa kapansin-pansing mas mabilis na paglilipat — at pinapatay namin ito sa isang finally block upang laging malinis, kahit na may error.
Progress + bilis, na throttled. Ang pag-compute ng bilis ng transfer sa bawat onReceiveProgress callback ay magastos at magulo, kaya muli lang namin itong kino-compute bawat 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));
}
Ang pag-cancel ay isang cooperative _isCancelled flag; sa pag-cancel, binubura namin ang partial file at naglalabas ng cancelled event.
Companion files. Ito ang bahaging hindi mo mahahanap sa docs. Para sa isang single-lens .MP4, kinukuha din namin ang .LRV (isang low-res proxy na nire-record ng GoPro kasama ng buong video) at ang .THM thumbnail, pagkatapos ay i-index ang LRV para sa mabilis na in-app playback. May dalawang naming "gotcha" dito:
- Ang HERO8+ ay nagpapalit ng pangalan ng proxy: Ang GX######.MP4 / GH######.MP4 ay nagiging GL######.LRV (ang pangalawang character ay nagiging L), na may same-name fallback para sa mga mas lumang camera.
- Ang mga .360 file ay hindi sumusuporta sa thumbnail o screennail endpoints. Gumagamit kami ng .THM companion, at bilang huling paraan, dina-download ang file mismo. At dahil ang isang .360 ay dual-fisheye, kino-crop namin ang thumbnail sa front lens — sa isang background isolate sa pamamagitan ng compute(), kaya ang UI ay hindi nagja-jank.
Ang transfer state ay ipinapakita sa pamamagitan ng isang Riverpod notifier na nagsu-subscribe sa file at transfer streams, pinapanatili ang isang tapos na transfer na nakikita sa loob ng 2 segundo, at ipinapakita ang mga error sa loob ng 5. Mahalagang tandaan: ang kontrol at file-transfer ay gumagamit ng dalawang independiyenteng Dio client sa parehong camera, kaya maaaring tumakbo ang isang download habang nagpapatuloy ang status poll at keep-alive.
7. Dalawang "gotcha" na wala sa anumang tutorial
Ang Cleartext HTTP ay blocked by default sa modernong Android
Ang http://10.5.5.9:8080 ay plaintext, at bini-block ng Android 9+ ang cleartext traffic kaagad. Kailangan mo itong tahasang payagan:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
kasama ang isang network_security_config.xml na ang base config ay nagpapahintulot ng cleartext (maaari mo pa ring ipatupad ang HTTPS para sa sarili mong cloud/backend domains). Kung hindi mo ito gawin, lahat ng tawag sa GoPro ay mabibigo na may nakakalitong error sa koneksyon.
Ang nawawalang network binding — ang mahinang punto ng disenyo
Narito ang tapat na bahagi. Sa modernong Android, kapag kumonekta ka sa GoPro AP, karaniwang pinapanatili ng telepono ang cellular bilang default na network dahil walang internet ang GoPro AP. Nangangahulugan iyan na ang isang HTTP request sa 10.5.5.9 ay maaaring dumaan sa cellular at mabigo — kahit na "konektado" ka sa camera.
Ang matatag na pag-aayos ay native: tawagan ang ConnectivityManager.bindProcessToNetwork(goProNetwork) bago mag-transfer at bindProcessToNetwork(null) pagkatapos. Walang katumbas ang isang pure-Dart GoPro path — at karaniwan itong gumagana pa rin dahil karaniwang niru-route ng OS ang nakapirming 10.5.5.9 address sa AP. Ngunit ito ang pinakamahinang bahagi ng arkitektura, at sa ilang device/bersyon ng Android, ito ang unang nabubura. Kung ilalabas mo ito, idagdag ang network binding — ito ang tanging lugar kung saan ang pagiging 100% Dart ay magdudulot ng problema sa reliability.
Mga natutunan
- Hindi mo kailangan ng BLE. Kung makakakonekta ang user sa AP, binibigyan ka ng Open GoPro HTTP API ng buong kontrol at mabilis na paglilipat sa pure Dart.
- Tukuyin sa pamamagitan ng TCP probe, hindi sa SSID scan — walang location permission, walang platform code.
- Keep-alive bawat 30 s o mamamatay ang session. Ituring ang isang failed keep-alive bilang iyong disconnect signal.
- Hayaan ang camera na iwasto ka. Mas mahusay ang 403-available_options self-healing loop kaysa sa pagmodelo ng buong settings matrix ng GoPro.
- Ang paglipat ng mode ay isang state machine — i-serialize ito, ihinto muna ang pagre-record, igalang ang mga pagkaantala sa lens-transition, at magtiwala sa intended state kaysa sa polled state.
- Ang mga companion file at .360 quirks ang mga hindi nakikita na problema — pagpapalit ng pangalan ng LRV, walang thumbnail endpoint, dual-fisheye cropping.
- Dalawang infrastructure "gotcha" ang tahimik na magpapabagsak sa iyo: cleartext traffic config, at (lalo na) ang pag-bind ng proseso sa network ng camera upang ang mga request ay hindi makalabas sa cellular.
Ang benepisyo: isang telepono na kumokonekta sa isang GoPro sa pamamagitan ng WiFi, nagmamaneho ng bawat kontrol, at nagsu-stream ng gigabytes ng footage mula dito na may live progress — at halos lahat ng ito ay simpleng well-structured na Dart na nakikipag-usap sa http://10.5.5.9:8080.

