Walang SDK, walang kumplikadong pagpapares ng BLE — tanging ang nakapirming IP ng camera, isang Dio client, at ilang mga 'gotchas' na tahimik na sisira sa araw mo kung hindi mo alam ang tungkol sa mga ito.
Bakit WiFi, hindi Bluetooth?
Karamihan sa mga tutorial tungkol sa "pagkonekta sa isang GoPro" ay nagsisimula sa Bluetooth Low Energy: i-scan, ipares, magpalitan ng mga katangian ng GATT, pagkatapos ay gamitin ang BLE para i-on ang WiFi, pagkatapos ay ilipat ang data sa WiFi pa rin. Gumagana ito, ngunit maraming seremonya — at mabagal at mahirap ang BLE para sa tunay na gusto ng mga user, na kontrolin ang camera at mabilis na kunin ang footage mula rito.
Kaya sa app na ito, tuluyan naming nilalaktawan ang BLE. Sumasali ang user sa WiFi access point ng GoPro mula sa mga setting ng kanilang telepono, at mula noon, ang 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 — walang native na Kotlin/Swift. Iyan ang pangunahing punto, at tulad ng makikita natin sa huli, ito rin ang nag-iisang lugar kung saan mahina ang disenyo.
Ang daloy:
sumali sa GoPro WiFi AP (mga setting ng telepono)
→ 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 mga pahintulot sa WiFi-scan
Ang unang instinct ay mag-enumerate ng mga WiFi SSID at hanapin ang isa na nagsisimula sa GoPro. Huwag — dahil ito ay maghihingi ng mga pahintulot sa lokasyon at platform-specific na WiFi-scan API.
Ang GoPro ay laging nasa parehong IP, kaya ang pagtukoy ay simple lang: maaari ba akong magbukas ng TCP socket sa 10.5.5.9:8080? Walang HTTP, walang pahintulot, 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);
}
}
}
Ini-poll namin iyan tuwing 2 segundo at nagbro-broadcast ng mga pagbabago sa estado sa isang stream. Ang connection screen ay nagsu-subscribe at nagiging "connected" sa sandaling magbukas ang socket. Dahil walang tunay na pagtuklas ng device, ang serbisyo ay gumagawa ng isang virtual na CameraDevice (batay sa nakapirming IP) upang hindi na kailangang malaman ng iba pang abstraction ng camera 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 LAN ka — kung mabagal, sira ito) 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 uniporme: ang lahat ay isang HTTP GET, kahit ang mga command at pagbabago sa setting. 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 estado (status + settings) | GET /gopro/camera/state | — |
| Itakda ang preset group | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Baguhin ang setting | GET /gopro/camera/setting | setting, option |
| Listahan ng media | GET /gopro/media/list | — |
| I-download ang 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 magkasama ang modernong pamilya ng /gopro/... at ang legacy na pamilya ng /gp/gpControl/.... Ang pagtanggal ng chaptered.360/GS group files, na hindi kayang hawakan nang malinis ng bagong API, 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 — isang resolution na legal sa 16:9 ay illegal sa 9:16, ang mga opsyon sa FPS ay nakadepende sa resolution, at iba pa. Sinasabi sa iyo ito ng GoPro sa mahirap na paraan: ibinabalik nito ang HTTP 403 kasama ang listahan ng mga opsyong dapat ay valid, at HTTP 500 kapag sandali lang itong abala.
Kaya ini-parse namin ang 403 body sa isang typed error at muling susubukan 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 muling isulat ang sarili nitong capability list kung makakita ito ng SettingRejectedError. Para sa bawat modelo, nagsisimula kami sa isang optimistic, hard-coded na capability table at pinapayagan ang camera na itama kami sa buong runtime. Mas praktikal at self-correcting ito kaysa subukang gayahin ang buong settings matrix ng GoPro nang maaga.
3. Keep-alive: ang timer na hindi mo dapat kalimutan
Pagkatapos ng humigit-kumulang 60 segundo ng kawalan ng aktibidad, tinatapos ng GoPro WiFi AP ang iyong session. Ang iyong maayos na koneksyon ay tahimik na mamamatay sa gitna ng isang session kung hindi mo ito gagambalain. Kaya, nagsisimula kami ng dalawang timer sa pagkonekta:
- Keep-alive tuwing 30 s → GET /gopro/camera/keep_alive
- Status poll tuwing 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 signal din ng pagkadiskonekta namin — ito ang pinaka-maaasahang paraan para mapansin na lumabas ang user sa saklaw ng WiFi.
4. Pagkontrol ng Camera: shutter, mode, at ang MAX 2 lens dance
Ang shutter ang madaling bahagi — simulan, maghintay ng 500 ms, muling i-poll ang estado. Ang pagkuha ng larawan ay ang parehong shutter/start; kung video o larawan ang makuha mo ay nakadepende sa kasalukuyang mode.
Dito nagiging interesante ang pagpapalit ng mode, dahil ang "mode" sa isang GoPro MAX 2 ay talagang dalawang setting: isang preset group (Video = 1000, Photo = 1001, Timelapse = 1002) kasama ang isang lens (Setting 194: 0 = single-lens/HERO, 1 = 360). Ang paggawa nito nang tama ay nangailangan ng ilang hindi halatang panuntunan na naka-embed sa code:
- I-serialize ang mga pagbabago sa mode. Ang mabilis na pag-tap sa sub-mode ay nakapila sa pamamagitan ng isang Completer chain upang hindi sila magsalungatan sa isang hindi magkatugmang 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 pagkakasunud-sunod 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 — ito ang paglipat ng pisikal na lens assembly.
- Pagkatiwalaan ang nilalayon na estado, hindi ang polled state. Sinusubaybayan namin ang _lastIntendedIs360 / _lastIntendedPresetGroup at ikinukumpara sa mga ito, dahil ang 5-segundong status poll ay maaaring magbigay sa iyo ng lumang snapshot sa gitna ng transisyon.
Mayroon ding talagang nakakalokong kapritso ng GoPro na karapat-dapat banggitin: ang mga resolution ID ay nakadepende 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, muling ikino-query namin ang mga kakayahan — ang parehong ID ay mangangahulugan ng ibang resolution.
5. Paglilista ng media
Ang GET /gopro/media/list ay nagbabalik ng istrukturang naka-grupo sa direktoryo na may mga sikat na maikling key: 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 hinuhusgahan mula sa filename prefix + extension: GS = 360 video, GX = HERO video, GT = timelapse, .360 = spherical video, .GPR/.RAW = raw photo. Mahalaga ang mga prefix na ito sa huli, dahil ang mga .360 file ay kumilos nang iba kaysa sa lahat ng iba pa.
6. Streaming downloads: turbo, progress, at companion files
Ang aktwal na paglipat 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, ini-orchestrate ng transfer service ang mga detalye sa totoong mundo:
Turbo transfer. Bago mag-download, i-o-on namin ang turbo mode (turbo_transfer?p=1) — lumilipat 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 + speed, pinabagal. Ang pag-compute ng bilis ng paglilipat sa bawat onReceiveProgress callback ay magastos at magulo, kaya muli namin itong kinokompyut tuwing 500 ms lamang:
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 pagkansela ay isang kooperatibong _isCancelled flag; sa pagkansela, binubura namin ang partial file at naglalabas ng cancelled event.
Companion files. Ito ang bahaging hindi mo mahahanap sa mga docs. Para sa isang single-lens na .MP4, kinukuha din namin ang .LRV (isang low-res proxy na ire-record ng GoPro kasama ang buong video) at ang .THM thumbnail, pagkatapos ay i-i-index ang LRV para sa mabilis na in-app playback. Dalawang 'gotchas' sa pagpapangalan ang makikita rito:
- HERO8+ ay pinapalitan ang pangalan ng proxy: Ang GX######.MP4 / GH######.MP4 ay nagiging GL######.LRV (ang pangalawang character ay nagiging L), na may parehong pangalan na fallback para sa mas lumang mga camera.
- Ang mga .360 file ay hindi sumusuporta sa thumbnail o screennail endpoints. Bumabalik kami sa .THM companion, at bilang huling resort ay i-da-download ang file mismo. At dahil ang .360 ay dual-fisheye, i-cr-crop namin ang thumbnail sa front lens — sa isang background isolate sa pamamagitan ng compute(), kaya ang UI ay hindi nagiging choppy.
Ang transfer state ay inilalabas sa pamamagitan ng isang Riverpod notifier na nagsu-subscribe sa mga file at transfer stream, nagpapanatili ng isang tapos na transfer na nakikita sa loob ng 2 segundo, at naglalabas ng mga error sa loob ng 5 segundo. Kapansin-pansin: ang control at file-transfer ay gumagamit ng dalawang independenteng 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 naka-block bilang default sa modernong Android
Ang http://10.5.5.9:8080 ay plaintext, at blinock ng Android 9+ ang cleartext traffic bilang default. Kailangan mo itong tahasang payagan:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
kasama ang network_security_config.xml na ang base config ay nagpapahintulot ng cleartext (maaari mo pa ring ipilit ang HTTPS para sa sarili mong cloud/backend domains). Mawala ito at 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 sumali 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 ma-route sa cellular at mabigo — kahit na "konektado" ka sa camera.
Ang matibay na solusyon ay native: tawagan ang ConnectivityManager.bindProcessToNetwork(goProNetwork) bago ang mga paglilipat at bindProcessToNetwork(null) pagkatapos. Ang isang pure-Dart na landas ng GoPro ay walang katumbas — at madalas 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 mga device/bersyon ng Android, ito ang unang bagay na nasisira. Kung ilalabas mo ito, idagdag ang network binding — ito ang nag-iisang lugar kung saan ang pananatiling 100% Dart ay nagkakahalaga sa iyo ng pagiging maaasahan.
Mga Natutunan
- Hindi mo kailangan ng BLE. Kung makakasali ang user sa AP, binibigyan ka ng Open GoPro HTTP API ng buong kontrol at mabilis na paglilipat sa purong Dart.
- Tukuyin sa pamamagitan ng TCP probe, hindi SSID scan — walang pahintulot sa lokasyon, walang platform code.
- Keep-alive tuwing 30 s o mamamatay ang session. Ituring ang isang nabigong keep-alive bilang iyong signal ng pagkadiskonekta.
- Hayaan ang camera na itama ka. Ang 403-available_options self-healing loop ay mas mahusay kaysa sa pagmomodelo ng buong settings matrix ng GoPro.
- Ang pagpapalit ng mode ay isang state machine — i-serialize ito, ihinto muna ang pagre-record, igalang ang mga pagkaantala sa paglipat ng lens, at pagtiwalaan ang nilalayon na estado kaysa sa polled state.
- Ang mga companion file at .360 quirks ang nagtatago ng mga problema — LRV renaming, walang thumbnail endpoint, dual-fisheye cropping.
- Dalawang gotcha sa imprastraktura ang tahimik na magpapabagsak sa iyo: cleartext traffic config, at (lalo na) ang pag-bind ng proseso sa camera network upang hindi makalabas ang mga request sa cellular.
Ang benepisyo: isang telepono na kumokonekta sa GoPro sa pamamagitan ng WiFi, nagmamaneho ng bawat kontrol, at naglilipat ng gigabytes ng footage mula rito na may live progress — at halos lahat ng ito ay maayos na nakabalangkas na Dart na nakikipag-usap sa http://10.5.5.9:8080.

