Kein SDK, kein BLE-Pairing-Tanz – nur die feste IP der Kamera, ein Dio-Client und eine Handvoll Fallstricke, die einem unbemerkt den Tag verderben, wenn man sie nicht kennt.
Warum WLAN, nicht Bluetooth?
Die meisten Tutorials zum "Verbinden mit einer GoPro" beginnen mit Bluetooth Low Energy: scannen, koppeln, GATT-Merkmale austauschen, dann BLE verwenden, um WLAN einzuschalten, und dann sowieso über WLAN übertragen. Es funktioniert, ist aber sehr umständlich – und BLE ist langsam und fummelig für das, was Benutzer wirklich wollen: die Kamera steuern und Aufnahmen schnell herunterladen.
In dieser App verzichten wir daher komplett auf BLE. Der Benutzer verbindet sich ĂĽber die Telefoneinstellungen mit dem WLAN-Access Point der GoPro, und von diesem Zeitpunkt an ist alles reines HTTP gegen die Open GoPro API der Kamera unter einer festen Adresse:
http://10.5.5.9:8080
Kamerasteuerung, Einstellungen, Medienauflistung und Dateidownload sind alles HTTP GET-Anfragen an diese IP. Die gesamte Integration ist zu 100% in Dart mit dem dio-Paket implementiert – null natives Kotlin/Swift. Das ist die Schlagzeile, und wie wir am Ende sehen werden, ist es auch die einzige Stelle, an der das Design fragil ist.
Der Ablauf:
GoPro WLAN AP beitreten (Telefoneinstellungen)
→ Kamera als erreichbar erkennen (TCP-Probe an 10.5.5.9:8080)
→ verbinden + Modell identifizieren (GET /gopro/camera/state)
→ Keep-alive (30s) + Status-Abfrage (5s) Timer starten
→ Steuerung: Auslöser / Modus / Einstellungen
→ Medien auflisten (GET /gopro/media/list)
→ Download mit Turbo + Fortschritt (dio.download)
1. Kamera ohne WLAN-Scan-Berechtigungen erkennen
Der erste Impuls ist, WLAN SSIDs aufzulisten und nach einer zu suchen, die mit GoPro beginnt. Tun Sie das nicht – das zieht Standortberechtigungen und plattformspezifische WLAN-Scan-APIs nach sich.
GoPro befindet sich immer unter derselben IP, daher ist die Erkennung einfach: Kann ich einen TCP-Socket zu 10.5.5.9:8080 öffnen? Kein HTTP, keine Berechtigungen, kein 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);
}
}
}
Wir fragen dies alle 2 Sekunden ab und übertragen Statusänderungen über einen Stream. Der Verbindungsbildschirm abonniert und wechselt zu "verbunden", sobald der Socket geöffnet wird. Da es keine echte Geräteerkennung gibt, synthetisiert der Dienst ein virtuelles CameraDevice (basierend auf der festen IP), sodass der Rest der Kameraabstraktion den Unterschied nicht kennen muss.
2. Der HTTP-Client: Dio und die Open GoPro Endpunkte
Der Client ist ein schlanker Dio-Wrapper. Beachten Sie das kurze Verbindungstimeout (Sie befinden sich in einem LAN – wenn es langsam ist, ist es defekt) und ein großzügiges Empfangstimeout für Steuerungsaufrufe:
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'},
));
}
Die Open GoPro API ist erfreulich einheitlich: Alles ist ein HTTP GET, selbst Befehle und Einstellungsänderungen. Hier sind die Endpunkte, die diese App tatsächlich verwendet:
| Zweck | Methode + Pfad | Abfrage |
|---|---|---|
| Auslöser starten (Aufnahme / Foto) | GET /gopro/camera/shutter/start | — |
| Auslöser stoppen | GET /gopro/camera/shutter/stop | — |
| Keep-alive | GET /gopro/camera/keep_alive | — |
| Gesamtstatus (Status + Einstellungen) | GET /gopro/camera/state | — |
| Voreinstellungsgruppe festlegen | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Einstellung ändern | GET /gopro/camera/setting | setting, option |
| Medienliste | GET /gopro/media/list | — |
| Datei herunterladen | GET /videos/DCIM/{dir}/{file} | (bytes) |
| Thumbnail / Screennail | GET /gopro/media/thumbnail / .../screennail | path |
| Turbo-Ăśbertragung | GET /gopro/media/turbo_transfer | p (1/0) |
| .360-Gruppe löschen (Legacy) | GET /gp/gpControl/command/storage/delete/group | p |
Ein schönes Detail: Sowohl die moderne /gopro/...-Familie als auch die Legacy /gp/gpControl/...-Familie koexistieren. Das Entfernen von chaptered.360/GS-Gruppendateien, die die neue API nicht sauber handhaben kann, ist die einzige Situation, in der wir auf den alten Endpunkt zurückgreifen.
Das 403-Selbstheilungsmuster (der eleganteste Trick hier)
Sie können nicht im Voraus wissen, welche Einstellungen im aktuellen Modus der Kamera gültig sind – eine Auflösung, die in 16:9 gültig ist, ist in 9:16 ungültig, FPS-Optionen hängen von der Auflösung ab und so weiter. GoPro teilt Ihnen dies auf die harte Tour mit: Es gibt HTTP 403 mit der Liste der Optionen zurück, die gültig gewesen wären, und HTTP 500, wenn es nur vorübergehend beschäftigt ist.
Wir parsen daher den 403-Body in einen typisierten Fehler und versuchen es bei 500 erneut:
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;
}
}
}
}
}
Die von der Kamera gemeldeten Optionen werden von der Dienstschicht verwendet, um ihre eigene Fähigkeitsliste neu zu schreiben, wenn sie SettingRejectedError erkennt. Für jedes Modell beginnen wir mit einer optimistischen, fest codierten Fähigkeitstabelle und erlauben der Kamera, uns während der Laufzeit zu korrigieren. Das ist praktischer und selbstkorrigierender, als zu versuchen, die gesamte Einstellungsmatrix von GoPro im Voraus zu simulieren.
3. Keep-alive: Der Timer, den Sie nicht vergessen dĂĽrfen
Nach etwa 60 Sekunden Inaktivität beendet der GoPro WLAN AP Ihre Sitzung. Ihre eigentlich gute Verbindung stirbt leise mitten in einer Sitzung, wenn Sie sie nicht wachhalten. Daher starten wir beim Verbinden zwei Timer:
- Keep-alive alle 30 s → GET /gopro/camera/keep_alive
- Statusabfrage alle 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
}
}
Ein fehlgeschlagenes Keep-alive ist auch unser Trennungssignal – es ist der zuverlässigste Weg zu bemerken, dass der Benutzer den WLAN-Bereich verlassen hat.
4. Kamerasteuerung: Auslöser, Modus und der MAX 2 Objektiv-Tanz
Der Auslöser ist der einfache Teil – starten, 500 ms warten, Status erneut abfragen. Die Fotoaufnahme ist der gleiche Auslöser/Start; ob Sie ein Video oder ein Foto erhalten, hängt vom aktuellen Modus ab.
Der Moduswechsel wird interessant, denn ein "Modus" bei einer GoPro MAX 2 sind eigentlich zwei Einstellungen: eine Voreinstellungsgruppe (Video = 1000, Foto = 1001, Zeitraffer = 1002) plus ein Objektiv (Einstellung 194: 0 = Einzellinse/HERO, 1 = 360). Dies richtig umzusetzen, erforderte mehrere nicht-offensichtliche Regeln, die in den Code integriert wurden:
- Moduswechsel serialisieren. Schnelle Untermodus-Taps werden über eine Completer-Kette in eine Warteschlange gestellt, damit sie sich nicht gegenseitig in einen inkonsistenten Zustand versetzen können.
- Zuerst den Auslöser stoppen. Die Firmware lehnt Voreinstellungsänderungen mit HTTP 400 während der Aufnahme ab, daher stoppen wir das Encoding proaktiv vor dem Wechsel.
- Reihenfolge und Verzögerungen sind wichtig. Bei MAX 2: Zuerst die Voreinstellungsgruppe einstellen (+400 ms), dann das Objektiv über Einstellung 194 (+1500 ms). Diese 1,5-sekündige Pause ist real – es ist die physikalische Objektivanordnung, die wechselt.
- Vertrauen Sie dem beabsichtigten Zustand, nicht dem abgefragten Zustand. Wir verfolgen _lastIntendedIs360 / _lastIntendedPresetGroup und vergleichen damit, da die 5-Sekunden-Statusabfrage Ihnen mitten im Ăśbergang einen veralteten Schnappschuss liefern kann.
Es gibt auch eine wirklich hinterhältige GoPro-Eigenheit, die erwähnenswert ist: Auflösungs-IDs hängen vom Seitenverhältnis ab. 4K ist Option 1 in 16:9, 109 in 9:16 und 112 in 4:3. Nach dem Ändern des Seitenverhältnisses fragen wir daher die Fähigkeiten erneut ab – dieselbe ID würde sonst eine andere Auflösung bedeuten.
5. Medien auflisten
GET /gopro/media/list gibt eine nach Verzeichnissen gruppierte Struktur mit den bekanntermaßen knappen Schlüsseln zurück: d = Verzeichnis, fs = Dateien, n = Dateiname, s = Größe, cre/mod = Unix-Sekunden-Zeitstempel, g = Gruppen-ID, glrv = die Größe des Low-Res-Proxy.
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
Der Medientyp wird aus dem Dateinamenpräfix + der Erweiterung abgeleitet: GS = 360-Video, GX = HERO-Video, GT = Zeitraffer, .360 = sphärisches Video, .GPR/.RAW = Rohfoto. Diese Präfixe sind später wichtig, da sich .360-Dateien anders verhalten als alles andere.
6. Streaming-Downloads: Turbo, Fortschritt und Begleitdateien
Die eigentliche Byte-Ăśbertragung erfolgt mit dio.download() direkt in einen Dateipfad, mit einem langen Empfangstimeout, da ein 1 GB groĂźes .360-Clip eine Weile dauert:
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))
,
);
}
Um diesen Kern herum orchestriert der Ăśbertragungsdienst die praktischen Details:
Turbo-Übertragung. Vor dem Download aktivieren wir den Turbomodus (turbo_transfer?p=1) – GoPro wechselt auf 5 GHz WLAN für merklich schnellere Übertragungen – und wir deaktivieren ihn in einem finally-Block, damit er auch im Fehlerfall immer bereinigt wird.
Fortschritt + Geschwindigkeit, gedrosselt. Die Berechnung der Ăśbertragungsgeschwindigkeit bei jedem onReceiveProgress-Callback ist verschwenderisch und ruckelig, daher berechnen wir sie nur alle 500 ms neu:
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));
}
Abbruch erfolgt über ein kooperatives _isCancelled-Flag; beim Abbruch löschen wir die teilweise heruntergeladene Datei und senden ein Abbruchereignis.
Begleitdateien. Das ist der Teil, den Sie in den Dokumenten nicht finden werden. Für ein Single-Lens .MP4 laden wir auch die .LRV (einen Low-Res-Proxy, den GoPro neben dem vollständigen Video aufzeichnet) und das .THM-Thumbnail herunter, um dann die LRV für die schnelle In-App-Wiedergabe zu indizieren. Zwei Namensfallen lauern hier:
- HERO8+ benennen den Proxy um: GX######.MP4 / GH######.MP4 wird zu GL######.LRV (der zweite Buchstabe ändert sich zu L), mit einem Fallback auf denselben Namen für ältere Kameras.
- .360-Dateien unterstützen die Thumbnail- oder Screennail-Endpunkte überhaupt nicht. Wir greifen auf die .THM-Begleitdatei zurück, und als letzten Ausweg laden wir die Datei selbst herunter. Und da eine .360-Datei Dual-Fisheye ist, beschneiden wir das Thumbnail auf das vordere Objektiv – auf einem Hintergrund-Isolate via compute(), damit die UI niemals ruckelt.
Der Übertragungsstatus wird über einen Riverpod-Notifier zugänglich gemacht, der die Datei- und Übertragungsstreams abonniert, eine abgeschlossene Übertragung für 2 Sekunden sichtbar hält und Fehler für 5 Sekunden anzeigt. Bemerkenswert: Steuerung und Dateiübertragung verwenden zwei unabhängige Dio-Clients für dieselbe Kamera, sodass ein Download laufen kann, während die Statusabfrage und Keep-alive weiterlaufen.
7. Zwei Fallstricke, die in keinem Tutorial stehen
Klartext-HTTP ist auf modernen Android-Geräten standardmäßig blockiert
http://10.5.5.9:8080 ist Klartext, und Android 9+ blockiert Klartext-Traffic standardmäßig. Sie müssen es explizit erlauben:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
plus eine network_security_config.xml, deren Basiskonfiguration Klartext erlaubt (Sie können weiterhin HTTPS für Ihre eigenen Cloud-/Backend-Domains erzwingen). Verpassen Sie dies, und jeder GoPro-Anruf schlägt mit einem verwirrenden Verbindungsfehler fehl.
Die fehlende Netzwerkbindung – die Bruchstelle des Designs
Hier kommt der ehrliche Teil. Auf modernen Android-Geräten, wenn Sie dem GoPro AP beitreten, behält das Telefon normalerweise Mobilfunk als Standardnetzwerk bei, da der GoPro AP kein Internet hat. Das bedeutet, dass eine HTTP-Anfrage an 10.5.5.9 über Mobilfunk weitergeleitet werden und fehlschlagen kann – obwohl Sie mit der Kamera "verbunden" sind.
Die robuste Lösung ist nativ: Rufen Sie ConnectivityManager.bindProcessToNetwork(goProNetwork) vor Übertragungen und bindProcessToNetwork(null) danach auf. Ein reiner Dart-GoPro-Pfad hat kein Äquivalent – und er funktioniert in der Regel trotzdem, weil das Betriebssystem die feste Adresse 10.5.5.9 im Allgemeinen an den AP routet. Aber es ist der anfälligste Teil der Architektur, und auf einigen Geräten/Android-Versionen ist es das Erste, was kaputtgeht. Wenn Sie dies ausliefern, fügen Sie die Netzwerkbindung hinzu – es ist die einzige Stelle, an der 100% Dart zu bleiben, Sie an Zuverlässigkeit kostet.
Gelernte Lektionen
- Sie brauchen kein BLE. Wenn der Benutzer dem AP beitreten kann, bietet Ihnen die Open GoPro HTTP API volle Kontrolle und schnelle Ăśbertragungen in reinem Dart.
- Erkennen Sie per TCP-Probe, nicht per SSID-Scan – keine Standortberechtigung, kein Plattform-Code.
- Keep-alive alle 30 s, sonst stirbt die Sitzung. Betrachten Sie ein fehlgeschlagenes Keep-alive als Ihr Trennungssignal.
- Lassen Sie sich von der Kamera korrigieren. Die 403-available_options-Selbstheilungsschleife ist besser als die Modellierung der gesamten Einstellungsmatrix von GoPro.
- Der Moduswechsel ist eine Zustandsmaschine – serialisieren Sie ihn, stoppen Sie zuerst die Aufnahme, beachten Sie die Verzögerungen beim Objektivwechsel und vertrauen Sie dem beabsichtigten Zustand mehr als dem abgefragten Zustand.
- Begleitdateien und .360-Eigenheiten sind die versteckten Fallstricke – LRV-Umbenennung, kein Thumbnail-Endpunkt, Dual-Fisheye-Zuschnitt.
- Zwei infrastrukturelle Fallstricke werden Sie unbemerkt zu Fall bringen: die Klartext-Traffic-Konfiguration und (insbesondere) die Bindung des Prozesses an das Kameranetzwerk, damit Anfragen nicht ĂĽber Mobilfunk entweichen.
Der Lohn: ein Telefon, das sich über WLAN mit einer GoPro verbindet, jede Steuerung ausführt und Gigabytes an Filmmaterial mit Live-Fortschritt überträgt – und fast alles davon ist einfach gut strukturierter Dart-Code, der mit http://10.5.5.9:8080 kommuniziert.

