Pas de SDK, pas de danse d'appairage BLE — juste l'IP fixe de la caméra, un client Dio, et une poignée de pièges qui ruineront discrètement votre journée si vous ne les connaissez pas.
Pourquoi le WiFi, pas le Bluetooth ?
La plupart des tutoriels "se 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émonie — et le BLE est lent et compliqué pour ce que les utilisateurs veulent réellement, à savoir contrôler la caméra et en extraire rapidement les séquences.
Ainsi, dans cette application, nous sautons entièrement 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 en HTTP simple 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 le point principal, et comme nous le verrons à la fin, c'est aussi le seul endroit où la conception est fragile.
Le flux :
rejoindre le GoPro WiFi AP (paramètres du téléphone)
→ détecter que la caméra est accessible (TCP probe vers 10.5.5.9:8080)
→ connecter + identifier le modèle (GET /gopro/camera/state)
→ démarrer les minuteurs keep-alive (30s) + status poll (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 autorisations de WiFi-scan
Le premier réflexe est d'énumérer les SSID WiFi et de chercher un commençant par GoPro. Ne le faites pas — cela entraîne des autorisations de localisation et des API de WiFi-scan spécifiques à la plateforme.
GoPro est toujours à la même IP, donc la détection est simplement : puis-je ouvrir un socket TCP vers 10.5.5.9:8080 ? Pas de HTTP, pas d'autorisations, pas de parsing de 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 stream. L'écran de connexion s'abonne et passe à "connecté" dès l'ouverture du socket. Comme il n'y a pas de véritable découverte de périphérique, le service synthétise un CameraDevice virtuel (indexé 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 endpoints Open GoPro
Le client est un mince wrapper Dio. Notez le court timeout de connexion (vous êtes sur un LAN — si c'est lent, c'est cassé) et un généreux receive timeout 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 une requête HTTP GET, même les commandes et les changements de paramètres. Voici les endpoints que cette application utilise réellement :
| Objectif | Méthode + Chemin | Requête |
|---|---|---|
| Démarrer l'obturateur (enregistrer / 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 / screennail | 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 joli détail : la famille moderne /gopro/... et la famille héritée /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 où nous recourons à l'endpoint ancien.
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, et ainsi de suite. GoPro vous le dit à la dure : elle renvoie HTTP 403 avec la liste des options qui auraient été valides, et HTTP 500 quand elle est juste momentanément occupée.
Nous parsons donc le corps du 403 en une erreur typée et retentons 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;
}
}
}
}
}
Les options signalé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 et codée en dur et permettons à la caméra de nous corriger tout au long de 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 GoPro WiFi AP met fin à votre session. Votre connexion parfaitement fonctionnelle meurt discrètement au milieu d'une session si vous ne la maintenez pas. Ainsi, nous démarrons deux minuteurs à la connexion :
- Keep-alive toutes les 30 s → GET /gopro/camera/keep_alive
- Status poll toutes les 5 s → GET /gopro/camera/state
void _startKeepAlive() {
_keepAliveTimer?.cancel();
_keepAliveTimer = Timer.periodic(
GoProHttpConstants.keepAliveInterval,
(_) => _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
}
}
Un keep-alive échoué 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 shutter/start; que vous obteniez 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, Timelapse = 1002) plus un objectif (Setting 194 : 0 = objectif unique/HERO, 1 = 360). Réussir cela a nécessité plusieurs règles non évidentes intégrées au code :
- Sérialiser les changements de mode. Les pressions rapides sur les sous-modes sont mises en file d'attente via une chaîne Completer afin qu'elles ne puissent pas entrer en concurrence et provoquer un état incohérent.
- Arrêter l'obturateur en premier. Le firmware rejette les changements de préréglages avec HTTP 400 pendant l'enregistrement, nous arrêtons donc proactivement l'encodage avant de changer.
- L'ordre et les délais sont importants. Sur MAX 2 : définir d'abord le groupe de préréglages (+400 ms), puis l'objectif via Setting 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 voulu, pas à l'état interrogé. Nous suivons _lastIntendedIs360 / _lastIntendedPresetGroup et comparons avec ceux-ci, car le status poll de 5 secondes peut vous donner une capture d'écran périmée en pleine transition.
Il y a aussi une particularité GoPro vraiment sournoise qui mérite d'être signalée : les ID de résolution dépendent du rapport d'aspect. 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 réinterrogeons les capacités — le même ID signifierait autrement une résolution différente.
5. Liste des 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 Unix en secondes, g = id de groupe, glrv = 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)); // newest first
Le type de média est déduit du préfixe + extension du nom de fichier : 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 tous les autres.
6. Téléchargements en streaming : turbo, progression et fichiers compagnons
Le transfert d'octets réel est dio.download() directement vers un chemin de fichier, avec un long timeout 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 cœur, 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ée. Le calcul de la vitesse de transfert à chaque callback onReceiveProgress est inutile et saccadé, 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 flag coopératif _isCancelled ; en cas d'annulation, nous supprimons le fichier partiel et émettons un événement annulé.
Fichiers compagnons. C'est la partie que vous ne trouverez pas dans la documentation. Pour un fichier .MP4 à objectif unique, nous téléchargeons également le .LRV (un proxy basse résolution que GoPro enregistre en même temps que 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 dénomination se présentent ici :
- HERO8+ renomme le proxy : GX######.MP4 / GH######.MP4 devient GL######.LRV (le deuxième caractère est remplacé par L), avec un fallback de même nom pour les caméras plus anciennes.
- Les fichiers .360 ne prennent pas du tout en charge les endpoints thumbnail ou screennail. Nous utilisons le compagnon .THM par défaut, 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 background isolate via compute(), de sorte que l'UI ne se bloque jamais.
L'état du transfert est exposé via un Riverpod notifier qui s'abonne aux streams de fichiers et de transferts, maintient un transfert terminé visible pendant 2 secondes et signale 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 status poll 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 les Android modernes
http://10.5.5.9:8080 est en texte clair, et Android 9+ bloque le trafic cleartext 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 cleartext (vous pouvez toujours forcer HTTPS pour vos propres domaines cloud/backend). Manquez cela et chaque appel GoPro échouera avec une erreur de connexion confuse.
La liaison réseau manquante — le point fragile de la conception
Voici la partie honnête. Sur Android moderne, lorsque vous vous connectez au GoPro AP, le téléphone maintient généralement la connexion cellulaire comme réseau par défaut car le GoPro AP n'a pas d'accès Internet. Cela signifie qu'une requête HTTP vers 10.5.5.9 peut être routée via la connexion 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 purement Dart n'a pas d'équivalent — et cela a tendance à fonctionner quand même car le système d'exploitation route généralement l'adresse fixe 10.5.5.9 vers l'AP. 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 tombe en panne. Si vous déployez cela, 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 l'AP, l'API HTTP Open GoPro vous donne un contrĂ´le total et des transferts rapides en pur Dart.
- Détecter par TCP probe, pas par scan SSID — pas d'autorisation de localisation, pas de code de 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 state machine — sérialisez-le, arrêtez l'enregistrement en premier, respectez les délais de transition de l'objectif, et faites confiance à l'état voulu plutôt qu'à l'état interrogé.
- Les fichiers compagnons et les particularités du .360 sont là où les difficultés sont cachées — renommage du LRV, pas d'endpoint de vignette, recadrage double-fisheye.
- Deux pièges d'infrastructure vous couleront silencieusement : la configuration du trafic cleartext et (surtout) la liaison du processus au réseau de la caméra afin que les requêtes ne s'échappent pas via la connexion cellulaire.
Le résultat : un téléphone qui se connecte à une GoPro via WiFi, pilote toutes les commandes et diffuse des gigabytes de séquences avec une progression en direct — et presque tout cela est juste du Dart bien structuré qui communique avec http://10.5.5.9:8080.

