Pas de SDK, pas de danse d'appairage BLE — juste l'IP fixe de la caméra, un client Dio et quelques pièges qui ruineront discrètement votre journée si vous ne les connaissez pas.
Pourquoi le WiFi et non le Bluetooth ?
La plupart des tutoriels « connecter une GoPro » commencent par le Bluetooth Low Energy : scanner, appairer, échanger les caractéristiques GATT, puis utiliser le BLE pour activer le WiFi, puis transférer via WiFi de toute façon. Cela fonctionne, mais c'est beaucoup de cérémonial — et le BLE est lent et compliqué pour ce que les utilisateurs veulent réellement, à savoir contrôler la caméra et en récupérer les séquences rapidement.
Dans cette application, nous ignorons donc complètement le BLE. L'utilisateur se connecte au point d'accès WiFi de la GoPro depuis les paramètres de son téléphone, et à partir de ce moment, tout est du simple HTTP contre l'API Open GoPro de la caméra à une adresse fixe :
http://10.5.5.9:8080
Le contrôle de la caméra, les paramètres, la liste des médias et le téléchargement de fichiers sont tous des requêtes HTTP GET vers cette IP. L'intégration entière est 100% Dart avec le package dio — zéro Kotlin/Swift natif. C'est la principale information, et comme nous le verrons à la fin, c'est aussi le seul endroit où la conception est fragile.
Le flux :
joindre le point d'accès WiFi GoPro (paramètres du téléphone)
→ détecter que la caméra est accessible (sonde TCP vers 10.5.5.9:8080)
→ connecter + identifier le modèle (GET /gopro/camera/state)
→ démarrer les minuteurs keep-alive (30s) + sondage de statut (5s)
→ contrôle : obturateur / mode / paramètres
→ lister les médias (GET /gopro/media/list)
→ télécharger avec turbo + progression (dio.download)
1. Détection de la caméra sans permissions de scan WiFi
Le premier réflexe est d'énumérer les SSID WiFi et de chercher celui qui commence par GoPro. Ne le faites pas — cela implique des permissions de localisation et des API de scan WiFi spécifiques à la plateforme.
GoPro vit toujours à la même IP, donc la détection est simple : puis-je ouvrir un socket TCP vers 10.5.5.9:8080 ? Pas de HTTP, pas de permissions, pas d'analyse 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);
}
}
}
Nous interrogeons cela toutes les 2 secondes et diffusons les changements d'état via un flux. L'écran de connexion s'abonne et passe à « connecté » dès l'ouverture du socket. Puisqu'il n'y a pas de véritable découverte de périphérique, le service synthétise un CameraDevice virtuel (basé sur l'IP fixe) afin que le reste de l'abstraction de la caméra n'ait pas besoin de connaître la différence.
2. Le client HTTP : Dio et les points d'accès Open GoPro
Le client est un mince wrapper Dio. Notez le court délai de connexion (vous êtes sur un LAN — si c'est lent, c'est cassé) et un délai de réception généreux pour les appels de contrôle :
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'},
));
}
L'API Open GoPro est délicieusement uniforme : tout est un HTTP GET, même les commandes et les changements de paramètres. Voici les points d'accès que cette application utilise réellement :
| But | Méthode + Chemin | Requête |
|---|---|---|
| Démarrer l'obturateur (enregistrement / photo) | GET /gopro/camera/shutter/start | — |
| Arrêter l'obturateur | GET /gopro/camera/shutter/stop | — |
| Keep-alive | GET /gopro/camera/keep_alive | — |
| État complet (statut + paramètres) | GET /gopro/camera/state | — |
| Définir le groupe de préréglages | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Modifier un paramètre | GET /gopro/camera/setting | setting, option |
| Liste des médias | GET /gopro/media/list | — |
| Télécharger un fichier | GET /videos/DCIM/{dir}/{file} | (bytes) |
| Vignette / Aperçu d'écran | GET /gopro/media/thumbnail / .../screennail | path |
| Transfert Turbo | GET /gopro/media/turbo_transfer | p (1/0) |
| Supprimer le groupe .360 (hérité) | GET /gp/gpControl/command/storage/delete/group | p |
Un détail intéressant : les familles modernes /gopro/... et héritées /gp/gpControl/... coexistent. La suppression des fichiers de groupe chaptered.360/GS, que la nouvelle API ne peut pas gérer proprement, est la seule situation dans laquelle nous avons recours à l'ancien point d'accès.
Le modèle d'auto-réparation 403 (la meilleure astuce ici)
Vous ne pouvez pas savoir à l'avance quels paramètres sont valides dans le mode actuel de la caméra — une résolution légale en 16:9 est illégale en 9:16, les options de FPS dépendent de la résolution, etc. GoPro vous l'apprend à la dure : elle renvoie HTTP 403 avec la liste des options qui auraient été valides, et HTTP 500 lorsqu'elle est simplement momentanément occupée.
Nous analysons donc le corps de la 403 en une erreur typée et réessayons le 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;
}
}
}
Les options rapportées par la caméra sont utilisées par la couche de service pour réécrire sa propre liste de capacités si elle détecte une SettingRejectedError. Pour chaque modèle, nous commençons avec une table de capacités optimiste, codée en dur, et permettons à la caméra de nous corriger pendant l'exécution. C'est plus pratique et auto-correcteur que d'essayer de simuler l'intégralité de la matrice de paramètres de GoPro à l'avance.
3. Keep-alive : le minuteur Ă ne pas oublier
Après environ 60 secondes d'inactivité, le point d'accès WiFi GoPro met fin à votre session. Votre connexion parfaitement fonctionnelle meurt discrètement au milieu d'une session si vous ne la sollicitez pas. Ainsi, nous démarrons deux minuteurs lors de la connexion :
- Keep-alive toutes les 30 s → GET /gopro/camera/keep_alive
- Sondage de statut toutes les 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(); // un échec de keep-alive == nous sommes déconnectés
}
}
Un échec de keep-alive est aussi notre signal de déconnexion — c'est le moyen le plus fiable de remarquer que l'utilisateur est sorti de la portée WiFi.
4. Contrôle de la caméra : obturateur, mode et la danse de l'objectif MAX 2
L'obturateur est la partie facile — démarrer, attendre 500 ms, réinterroger l'état. La capture de photo est le même déclenchement/démarrage ; si vous obtenez une vidéo ou une photo dépend du mode actuel.
Le changement de mode devient intéressant, car un « mode » sur une GoPro MAX 2 est en réalité deux paramètres : un groupe de préréglages (Vidéo = 1000, Photo = 1001, Accéléré = 1002) plus un objectif (Paramètre 194 : 0 = objectif unique/HERO, 1 = 360). Pour bien faire les choses, plusieurs règles non évidentes ont été intégrées au code :
- Sérialiser les changements de mode. Les tapotements rapides de sous-mode sont mis en file d'attente via une chaîne Completer afin qu'ils ne puissent pas entrer en concurrence et provoquer un état incohérent.
- Arrêter d'abord l'obturateur. Le firmware rejette les changements de préréglages avec HTTP 400 pendant l'enregistrement, nous arrêtons donc proactivement l'encodage avant de basculer.
- L'ordre et les délais comptent. Sur MAX 2 : définir d'abord le groupe de préréglages (+400 ms), puis l'objectif via le paramètre 194 (+1500 ms). Cette pause de 1,5 seconde est réelle — c'est la transition physique de l'assemblage de l'objectif.
- Faire confiance à l'état intentionnel, pas à l'état interrogé. Nous suivons _lastIntendedIs360 / _lastIntendedPresetGroup et effectuons des comparaisons avec ceux-ci, car le sondage d'état de 5 secondes peut vous donner un instantané périmé en pleine transition.
Il y a aussi une particularité GoPro vraiment sournoise à signaler : les ID de résolution dépendent du rapport d'aspect. La 4K est l'option 1 en 16:9, 109 en 9:16 et 112 en 4:3. Donc, après avoir changé le rapport d'aspect, nous interrogeons à nouveau les capacités — le même ID signifierait autrement une résolution différente.
5. Lister les médias
GET /gopro/media/list renvoie une structure groupée par répertoire avec des clés notoirement concises : d = répertoire, fs = fichiers, n = nom de fichier, s = taille, cre/mod = horodatages en secondes Unix, g = ID de groupe, glrv = la taille du proxy basse résolution.
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)); // les plus récents en premier
Le type de média est déduit du préfixe du nom de fichier + extension : GS = vidéo 360, GX = vidéo HERO, GT = timelapse, .360 = vidéo sphérique, .GPR/.RAW = photo RAW. Ces préfixes sont importants plus tard, car les fichiers .360 se comportent différemment de tout le reste.
6. Téléchargements en streaming : turbo, progression et fichiers complémentaires
Le transfert d'octets réel est dio.download() directement vers un chemin de fichier, avec un long délai d'attente de réception car un clip .360 de 1 Go prend un certain temps :
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)),
);
}
Autour de ce noyau, le service de transfert orchestre les détails du monde réel :
Transfert Turbo. Avant le téléchargement, nous activons le mode turbo (turbo_transfer?p=1) — GoPro passe au WiFi 5 GHz pour des transferts nettement plus rapides — et nous le désactivons dans un bloc finally afin qu'il soit toujours nettoyé, même en cas d'erreur.
Progression + vitesse, régulées. Calculer la vitesse de transfert à chaque rappel onReceiveProgress est inefficace et instable, nous le recalculons donc seulement toutes les 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));
}
L'annulation est un drapeau coopératif _isCancelled ; lors de l'annulation, nous supprimons le fichier partiel et émettons un événement d'annulation.
Fichiers complémentaires. C'est la partie que vous ne trouverez pas dans la documentation. Pour un .MP4 à objectif unique, nous extrayons également le .LRV (un proxy basse résolution que GoPro enregistre en parallèle de la vidéo complète) et la vignette .THM, puis nous indexons le LRV pour une lecture rapide dans l'application. Deux pièges de nommage se manifestent ici :
- Les HERO8+ renomment le proxy : GX######.MP4 / GH######.MP4 devient GL######.LRV (le deuxième caractère passe à L), avec une solution de repli du même nom pour les anciennes caméras.
- Les fichiers .360 ne prennent pas du tout en charge les points d'accès de vignette ou d'aperçu d'écran. Nous nous rabattons sur le fichier complémentaire .THM, et en dernier recours, nous téléchargeons le fichier lui-même. Et comme un .360 est un double-fisheye, nous recadrons la vignette sur l'objectif avant — sur un isolat d'arrière-plan via compute(), afin que l'interface utilisateur ne saccade jamais.
L'état de transfert est exposé via un notificateur Riverpod qui s'abonne aux flux de fichiers et de transfert, maintient un transfert terminé visible pendant 2 secondes et affiche les erreurs pendant 5. À noter : le contrôle et le transfert de fichiers utilisent deux clients Dio indépendants vers la même caméra, de sorte qu'un téléchargement peut s'exécuter pendant que le sondage d'état et le keep-alive continuent.
7. Deux pièges qui ne figurent dans aucun tutoriel
Le HTTP en clair est bloqué par défaut sur Android moderne
http://10.5.5.9:8080 est en texte clair, et Android 9+ bloque le trafic en clair par défaut. Vous devez l'autoriser explicitement :
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
plus un fichier network_security_config.xml dont la configuration de base autorise le texte clair (vous pouvez toujours forcer HTTPS pour vos propres domaines cloud/backend). Si vous manquez cela, chaque appel GoPro échouera avec une erreur de connexion déroutante.
La liaison réseau manquante — le point fragile de la conception
Voici la partie honnête. Sur Android moderne, lorsque vous rejoignez le point d'accès GoPro, le téléphone conserve généralement le réseau cellulaire comme réseau par défaut car le point d'accès GoPro n'a pas d'accès à Internet. Cela signifie qu'une requête HTTP vers 10.5.5.9 peut être routée via le cellulaire et échouer — même si vous êtes « connecté » à la caméra.
La solution robuste est native : appelez ConnectivityManager.bindProcessToNetwork(goProNetwork) avant les transferts et bindProcessToNetwork(null) après. Un chemin GoPro pure-Dart n'a pas d'équivalent — et cela a tendance à fonctionner de toute façon car le système d'exploitation route généralement l'adresse fixe 10.5.5.9 vers le point d'accès. Mais c'est la partie la plus fragile de l'architecture, et sur certains appareils/versions d'Android, c'est la première chose qui casse. Si vous livrez ceci, ajoutez la liaison réseau — c'est le seul endroit où rester 100% Dart vous coûte en fiabilité.
Leçons apprises
- Vous n'avez pas besoin de BLE. Si l'utilisateur peut rejoindre le point d'accès, l'API HTTP Open GoPro vous offre un contrôle total et des transferts rapides en pur Dart.
- Détecter par sonde TCP, non par scan SSID — pas de permission de localisation, pas de code plateforme.
- Keep-alive toutes les 30 s ou la session meurt. Traitez un keep-alive échoué comme votre signal de déconnexion.
- Laissez la caméra vous corriger. La boucle d'auto-réparation 403-available_options est plus efficace que de modéliser l'intégralité de la matrice de paramètres de GoPro.
- Le changement de mode est une machine d'état — sérialisez-le, arrêtez l'enregistrement en premier, respectez les délais de transition de l'objectif et faites confiance à l'état intentionnel plutôt qu'à l'état interrogé.
- Les fichiers complémentaires et les particularités du .360 sont là où les difficultés se trouvent — renommage des LRV, pas de point d'accès de vignette, recadrage dual-fisheye.
- Deux pièges d'infrastructure vous couleront silencieusement : la configuration du trafic en clair, et (surtout) la liaison du processus au réseau de la caméra afin que les requêtes ne s'échappent pas via le cellulaire.
Le résultat : un téléphone qui se connecte à une GoPro via WiFi, pilote chaque contrôle et diffuse des gigaoctets de séquences avec une progression en direct — et la quasi-totalité est juste du Dart bien structuré qui communique avec http://10.5.5.9:8080.

