Ingen SDK, ingen BLE-parringsdans – bare kameraets faste IP, en Dio-klient og en håndfuld faldgruber, der stille og roligt vil ødelægge din dag, hvis du ikke kender til dem.
Hvorfor WiFi, ikke Bluetooth?
De fleste "forbind til et GoPro"-vejledninger starter med Bluetooth Low Energy: scan, par, udveksl GATT-egenskaber, og brug derefter BLE til at tænde for WiFi, og overfør alligevel via WiFi. Det virker, men det er meget omstændeligt – og BLE er langsomt og besværligt til det, brugerne faktisk ønsker, nemlig at styre kameraet og hurtigt hente optagelserne fra det.
Så i denne app springer vi BLE helt over. Brugeren forbinder til GoPros WiFi-adgangspunkt fra telefonens indstillinger, og fra det tidspunkt er alt almindelig HTTP mod kameraets Open GoPro API på en fast adresse:
http://10.5.5.9:8080
Kamerastyring, indstillinger, medieliste og filoverførsel er alle HTTP GET-anmodninger til denne IP. Hele integrationen er 100% Dart med Dio-pakken — nul native Kotlin/Swift. Det er overskriften, og som vi vil se til sidst, er det også det ene sted, designet er skrøbeligt.
Forløbet:
forbind til GoPro WiFi AP (telefonindstillinger)
→ opdag kameraet er tilgængeligt (TCP-sonde til 10.5.5.9:8080)
→ forbind + identificer model (GET /gopro/camera/state)
→ start keep-alive (30s) + status polling (5s) timere
→ kontrol: udløser / tilstand / indstillinger
→ liste medier (GET /gopro/media/list)
→ download med turbo + fremskridt (dio.download)
1. Registrering af kameraet uden WiFi-scannings-tilladelser
Den første instinkt er at opremse WiFi SSID'er og lede efter en, der starter med GoPro. Lad være – det medfører lokationstilladelser og platformspecifikke WiFi-scannings-API'er.
GoPro bor altid på den samme IP, så detektion er blot: kan jeg åbne en TCP-socket til 10.5.5.9:8080? Ingen HTTP, ingen tilladelser, ingen 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);
}
}
}
Vi forespørger dette hvert 2. sekund og sender statusændringer via en stream. Forbindelsesskærmen abonnerer og skifter til "forbundet" i det øjeblik, socket'en åbner. Da der ikke er nogen reel enhedsfund, syntetiserer tjenesten en virtuel CameraDevice (baseret på den faste IP), så resten af kameraabstraktionen ikke behøver at kende forskellen.
2. HTTP-klienten: Dio og Open GoPro endepunkterne
Klienten er en tynd Dio-wrapper. Bemærk den korte forbindelsestimeout (du er på et LAN — hvis det er langsomt, er det ødelagt) og en generøs modtagelsestimeout for kontrolkald:
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'et er dejligt ensartet: alt er en HTTP GET, selv kommandoer og indstillingsændringer. Her er de endepunkter, denne app faktisk bruger:
| Formål | Metode + Sti | Forespørgsel |
|---|---|---|
| Start udløser (optag / foto) | GET /gopro/camera/shutter/start | — |
| Stop udløser | GET /gopro/camera/shutter/stop | — |
| Keep-alive | GET /gopro/camera/keep_alive | — |
| Fuld status (status + indstillinger) | GET /gopro/camera/state | — |
| Indstil forudindstillet gruppe | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Skift en indstilling | GET /gopro/camera/setting | setting, option |
| Medieliste | GET /gopro/media/list | — |
| Download en fil | GET /videos/DCIM/{dir}/{file} | (bytes) |
| Miniature / screennail | GET /gopro/media/thumbnail / .../screennail | path |
| Turbooverførsel | GET /gopro/media/turbo_transfer | p (1/0) |
| Slet .360 gruppe (ældre) | GET /gp/gpControl/command/storage/delete/group | p |
En fin detalje: både den moderne /gopro/...-familie og den ældre /gp/gpControl/...-familie eksisterer side om side. Fjernelsen af chaptered.360/GS gruppefiler, som det nye API ikke kan håndtere rent, er den eneste situation, hvor vi tyer til det gamle endepunkt.
Det 403 selvhelende mønster (det smarteste trick her)
Du kan ikke på forhånd vide, hvilke indstillinger der er gyldige i kameraets nuværende tilstand – en opløsning, der er lovlig i 16:9, er ulovlig i 9:16, FPS-muligheder afhænger af opløsningen, og så videre. GoPro fortæller dig dette på den hårde måde: det returnerer HTTP 403 med listen over muligheder, der ville have været gyldige, og HTTP 500, når det blot er midlertidigt optaget.
Så vi parser 403-bodyen til en indtastet fejl og prøver 500 igen:
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;
}
}
}
}
}
Kameraets rapporterede muligheder bruges af servicelaget til at omskrive sin egen kapacitetsliste, hvis det registrerer SettingRejectedError. For hver model starter vi med en optimistisk, hardcoded kapacitetstabel og lader kameraet korrigere os under kørslen. Det er mere praktisk og selvkorrigerende end at forsøge at simulere GoPros samlede indstillingsmatrix på forhånd.
3. Keep-alive: timeren du ikke må glemme
Efter cirka 60 sekunders inaktivitet afslutter GoPro WiFi AP'et din session. Din perfekte forbindelse dør stille og roligt midt i en session, hvis du ikke "poker" den. Derfor starter vi to timere ved forbindelse:
- Keep-alive hvert 30. sekund → GET /gopro/camera/keep_alive
- Status forespørgsel hvert 5. sekund → 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(); // en mislykket keep-alive == vi er afbrudt
}
}
En mislykket keep-alive er også vores frakoblingssignal – det er den mest pålidelige måde at opdage, at brugeren er gået ud af WiFi-rækkevidde.
4. Kamerastyring: udløser, tilstand og MAX 2 objektiv-dansen
Udløseren er den nemme del – start, vent 500 ms, forespørg status igen. Fototagning er den samme udløser/start; om du får en video eller et foto afhænger af den aktuelle tilstand.
Tilstandsændring er her, det bliver interessant, for en "tilstand" på et GoPro MAX 2 er reelt to indstillinger: en forudindstillet gruppe (Video = 1000, Foto = 1001, Timelapse = 1002) plus et objektiv (Indstilling 194: 0 = enkeltobjektiv/HERO, 1 = 360). At få dette rigtigt krævede flere ikke-åbenlyse regler indbygget i koden:
- Serialiser tilstandsændringer. Hurtige undertilstands-tryk sættes i kø via en Completer-kæde, så de ikke kan køre om kap ind i en inkonsistent tilstand.
- Stop udløseren først. Firmware afviser forudindstillede ændringer med HTTP 400 under optagelse, så vi stopper proaktivt kodning, før vi skifter.
- Rækkefølge og forsinkelser er vigtige. På MAX 2: indstil forudindstillet gruppe først (+400 ms), derefter objektivet via Indstilling 194 (+1500 ms). Denne 1,5-sekunds pause er reel – det er den fysiske objektivmontering, der skifter.
- Stol på den tilsigtede tilstand, ikke den forespurgte tilstand. Vi sporer _lastIntendedIs360 / _lastIntendedPresetGroup og differentierer mod dem, fordi 5-sekunders status-forespørgslen kan give dig et forældet øjebliksbillede midt i overgangen.
Der er også en ægte snedig GoPro-finurlighed, der er værd at nævne: opløsnings-ID'er afhænger af billedformatet. 4K er mulighed 1 i 16:9, 109 i 9:16 og 112 i 4:3. Så efter at have ændret billedformat forespørger vi kapaciteter igen – det samme ID ville ellers betyde en anden opløsning.
5. Visning af medier
GET /gopro/media/list returnerer en mappegrupperet struktur med berømt kortfattede nøgler: d = mappe, fs = filer, n = filnavn, s = størrelse, cre/mod = unix-sekund tidsstempler, g = gruppe-id, glrv = størrelsen på lavopløsnings-proxyen.
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)); // nyeste først
Medietype udledes fra filnavnets præfiks + udvidelse: GS = 360 video, GX = HERO video, GT = timelapse, .360 = sfærisk video, .GPR/.RAW = råfoto. Disse præfikser er vigtige senere, fordi .360 filer opfører sig anderledes end alt andet.
6. Streaming downloads: turbo, fremskridt og ledsagefiler
Den faktiske byteoverførsel er dio.download() direkte til en filsti, med en lang modtagelsestimeout, da et 1 GB .360 klip tager et stykke tid:
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)),
);
}
Omkring den kerne orkestrerer overførselstjenesten de virkelige detaljer:
Turbooverførsel. Før download tænder vi turbo-tilstand (turbo_transfer?p=1) – GoPro skifter til 5 GHz WiFi for mærkbart hurtigere overførsler – og vi slukker det i en finally-blok, så det altid ryddes op, selv ved fejl.
Fremskridt + hastighed, begrænset. Beregning af overførselshastighed ved hvert onReceiveProgress-callback er spildfuldt og uroligt, så vi genberegner det kun hvert 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));
}
Annullering er et kooperativt _isCancelled flag; ved annullering sletter vi den delvise fil og udsender en annulleret hændelse.
Ledsagefiler. Dette er den del, du ikke finder i dokumentationen. For en enkeltlinse .MP4 trækker vi også .LRV (en lavopløsnings-proxy GoPro optager sammen med den fulde video) og .THM miniaturen, og indekserer derefter LRV'en for hurtig in-app afspilning. To navngivningsfaldgruber bider her:
- HERO8+ omdøber proxyen: GX######.MP4 / GH######.MP4 bliver til GL######.LRV (det andet tegn skifter til L), med en samme-navn fallback for ældre kameraer.
- .360 filer understøtter slet ikke miniature- eller screennail-endepunkterne. Vi falder tilbage til .THM-ledsageren, og som en sidste udvej downloader vi selve filen. Og fordi en .360 er dual-fisheye, beskærer vi miniaturen til frontlinsen – på en baggrunds-isolate via compute(), så UI aldrig hakker.
Overførselstilstanden eksponeres via en Riverpod notifier, der abonnerer på fil- og overførsels-streams, holder en afsluttet overførsel synlig i 2 sekunder og viser fejl i 5. Værd at bemærke: kontrol og filoverførsel bruger to uafhængige Dio-klienter til det samme kamera, så en download kan køre, mens status-forespørgslen og keep-alive fortsætter.
7. To faldgruber, der ikke er i nogen vejledning
Klartekst HTTP er blokeret som standard på moderne Android
http://10.5.5.9:8080 er klartekst, og Android 9+ blokerer klarteksttrafik som standard. Du skal udtrykkeligt tillade det:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
plus en network_security_config.xml, hvis basiskonfiguration tillader klartekst (du kan stadig tvinge HTTPS til dine egne cloud/backend-domæner). Overser du dette, vil hvert GoPro-kald fejle med en forvirrende forbindelsesfejl.
Den manglende netværksbinding — designets skrøbelige punkt
Her er den ærlige del. På moderne Android, når du forbinder til GoPro AP'en, beholder telefonen normalt mobilnetværket som standardnetværk, fordi GoPro AP'en ikke har internet. Det betyder, at en HTTP-anmodning til 10.5.5.9 kan blive sendt ud via mobilnetværket og fejle – selvom du er "forbundet" til kameraet.
Den robuste løsning er native: kald ConnectivityManager.bindProcessToNetwork(goProNetwork) før overførsler og bindProcessToNetwork(null) efter. En ren Dart GoPro-sti har ingen ækvivalent – og det plejer alligevel at virke, fordi OS'et generelt router den faste 10.5.5.9 adresse til AP'en. Men det er den mest skrøbelige del af arkitekturen, og på nogle enheder/Android-versioner er det det første, der går i stykker. Hvis du sender dette, skal du tilføje netværksbindingen – det er det ene sted, hvor det koster dig pålidelighed at forblive 100% Dart.
Erfaringer
- Du behøver ikke BLE. Hvis brugeren kan forbinde til AP'en, giver Open GoPro HTTP API'et dig fuld kontrol og hurtige overførsler i ren Dart.
- Registrer via TCP-sonde, ikke SSID-scanning — ingen lokationstilladelse, ingen platformskode.
- Keep-alive hvert 30. sekund, ellers dør sessionen. Betragt en mislykket keep-alive som dit frakoblingssignal.
- Lad kameraet korrigere dig. Den 403-tilgængelige_options selvhelende løkke slår at modellere GoPros samlede indstillingsmatrix.
- Tilstandsændring er en tilstandsmaskine — serialiser den, stop optagelsen først, respekter objektivovergangs-forsinkelserne, og stol på den tilsigtede tilstand frem for den forespurgte tilstand.
- Ledsagefiler og .360-finurligheder er, hvor hemmelighederne er begravet – LRV-omdøbning, intet miniature-endepunkt, dual-fisheye-beskæring.
- To infrastruktur-faldgruber vil stille og roligt sænke dig: klarteksttrafik-konfiguration, og (især) binding af processen til kameranetværket, så anmodninger ikke undslipper via mobilnetværket.
Udbyttet: en telefon, der forbinder til et GoPro via WiFi, styrer hver eneste kontrol, og streamer gigabytes af optagelser fra det med live fremskridt – og næsten alt dette er blot velstruktureret Dart, der kommunikerer med http://10.5.5.9:8080.

