Sin SDK, sin el baile de emparejamiento BLE — solo la IP fija de la cámara, un cliente Dio y un puñado de trampas que arruinarán tu día silenciosamente si no las conoces.
¿Por qué WiFi y no Bluetooth?
La mayoría de los tutoriales para "conectar a una GoPro" comienzan con Bluetooth Low Energy: escanear, emparejar, intercambiar características GATT, luego usar BLE para encender el WiFi, para finalmente transferir por WiFi de todos modos. Funciona, pero es mucha ceremonia — y BLE es lento y complicado para lo que los usuarios realmente quieren, que es controlar la cámara y descargar el material rápidamente.
Así que en esta aplicación omitimos BLE por completo. El usuario se une al punto de acceso WiFi de la GoPro desde la configuración de su teléfono, y a partir de ese momento todo es HTTP simple contra la Open GoPro API de la cámara en una dirección fija:
http://10.5.5.9:8080
El control de la cámara, la configuración, el listado de medios y la descarga de archivos son todas solicitudes HTTP GET a esa IP. Toda la integración es 100% Dart con el paquete dio — cero Kotlin/Swift nativo. Ese es el titular, y como veremos al final, también es el único punto donde el diseño es frágil.
El flujo:
unirse al AP WiFi de GoPro (configuración del teléfono)
→ detectar que la cámara es accesible (sondeo TCP a 10.5.5.9:8080)
→ conectar + identificar modelo (GET /gopro/camera/state)
→ iniciar temporizadores de keep-alive (30s) + sondeo de estado (5s)
→ control: obturador / modo / ajustes
→ listar medios (GET /gopro/media/list)
→ descargar con turbo + progreso (dio.download)
1. Detectando la cámara sin permisos de escaneo WiFi
El primer instinto es enumerar los SSIDs de WiFi y buscar uno que comience con GoPro. No lo hagas — eso arrastra permisos de ubicación y APIs de escaneo WiFi específicas de la plataforma.
GoPro siempre vive en la misma IP, así que la detección es simple: ¿puedo abrir un socket TCP a 10.5.5.9:8080? Sin HTTP, sin permisos, sin análisis 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);
}
}
Sondeamos eso cada 2 segundos y transmitimos los cambios de estado a través de un stream. La pantalla de conexión se suscribe y cambia a "conectado" en el momento en que se abre el socket. Debido a que no hay un descubrimiento de dispositivo real, el servicio sintetiza un CameraDevice virtual (basado en la IP fija) para que el resto de la abstracción de la cámara no necesite saber la diferencia.
2. El cliente HTTP: Dio y los endpoints de Open GoPro
El cliente es un envoltorio ligero de Dio. Ten en cuenta el corto timeout de conexión (estás en una LAN — si es lento, está roto) y un generoso receive timeout para las llamadas de control:
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'},
));
}
La Open GoPro API es deliciosamente uniforme: todo es una solicitud HTTP GET, incluso los comandos y cambios de configuración. Aquí están los endpoints que esta aplicación realmente utiliza:
| Propósito | Método + Ruta | Consulta |
|---|---|---|
| Iniciar obturador (grabar / foto) | GET /gopro/camera/shutter/start | — |
| Detener obturador | GET /gopro/camera/shutter/stop | — |
| Keep-alive | GET /gopro/camera/keep_alive | — |
| Estado completo (estado + ajustes) | GET /gopro/camera/state | — |
| Establecer grupo de preajustes | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Cambiar un ajuste | GET /gopro/camera/setting | setting, option |
| Lista de medios | GET /gopro/media/list | — |
| Descargar un archivo | GET /videos/DCIM/{dir}/{file} | (bytes) |
| Miniatura / screennail | GET /gopro/media/thumbnail / .../screennail | path |
| Transferencia turbo | GET /gopro/media/turbo_transfer | p (1/0) |
| Eliminar grupo .360 (legado) | GET /gp/gpControl/command/storage/delete/group | p |
Un buen detalle: tanto la familia moderna /gopro/... como la familia legada /gp/gpControl/... coexisten. La eliminación de archivos de grupo chaptered.360/GS, que la nueva API no puede manejar limpiamente, es la única situación en la que recurrimos al endpoint antiguo.
El patrón de auto-curación 403 (el truco más limpio aquí)
No puedes saber de antemano qué ajustes son válidos en el modo actual de la cámara — una resolución que es legal en 16:9 es ilegal en 9:16, las opciones de FPS dependen de la resolución, y así sucesivamente. GoPro te lo dice de la manera difícil: devuelve HTTP 403 con la lista de opciones que habrían sido válidas, y HTTP 500 cuando está momentáneamente ocupado.
Así que parseamos el cuerpo del 403 en un error tipificado y reintentamos el 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;
}
}
}
Las opciones reportadas por la cámara son utilizadas por la capa de servicio para reescribir su propia lista de capacidades si detecta SettingRejectedError. Para cada modelo, comenzamos con una tabla de capacidades optimista y codificada, y permitimos que la cámara nos corrija durante el tiempo de ejecución. Es más práctico y autocorrectivo que intentar simular toda la matriz de ajustes de GoPro de antemano.
3. Keep-alive: el temporizador que no debes olvidar
Después de unos 60 segundos de inactividad, el AP WiFi de GoPro finaliza tu sesión. Tu conexión perfectamente buena muere silenciosamente en medio de una sesión si no la "mantienes viva". Así, iniciamos dos temporizadores al conectar:
- Keep-alive cada 30 s → GET /gopro/camera/keep_alive
- Sondeo de estado cada 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 fallido es también nuestra señal de desconexión — es la forma más fiable de notar que el usuario salió del rango WiFi.
4. Control de cámara: obturador, modo y el baile de lentes de la MAX 2
Shutter es la parte fácil — iniciar, esperar 500 ms, volver a sondear el estado. La captura de fotos es el mismo shutter/start; si obtienes un video o una foto depende del modo actual.
El cambio de modo es donde se pone interesante, porque un "modo" en una GoPro MAX 2 es en realidad dos ajustes: un grupo de preajustes (Video = 1000, Foto = 1001, Timelapse = 1002) más una lente (Setting 194: 0 = lente única/HERO, 1 = 360). Hacer esto correctamente requirió varias reglas no obvias incorporadas en el código:
- Serializar los cambios de modo. Los toques rápidos de sub-modo se encolan a través de una cadena de Completer para que no compitan entre sí y caigan en un estado inconsistente.
- Detener el obturador primero. El firmware rechaza los cambios de preajuste con HTTP 400 mientras se graba, por lo que detenemos proactivamente la codificación antes de cambiar.
- El orden y los retrasos importan. En MAX 2: establece primero el grupo de preajustes (+400 ms), luego la lente a través de Setting 194 (+1500 ms). Esa pausa de 1.5 segundos es real — es la transición física del conjunto de la lente.
- Confía en el estado intencionado, no en el estado sondeado. Rastreamos _lastIntendedIs360 / _lastIntendedPresetGroup y comparamos con ellos, porque el sondeo de estado de 5 segundos puede darte una instantánea obsoleta a mitad de la transición.
También hay una peculiaridad de GoPro realmente sigilosa que vale la pena mencionar: los IDs de resolución dependen de la relación de aspecto. 4K es la opción 1 en 16:9, 109 en 9:16 y 112 en 4:3. Así que después de cambiar la relación de aspecto, volvemos a consultar las capacidades — el mismo ID significaría de otra manera una resolución diferente.
5. Listando medios
GET /gopro/media/list devuelve una estructura agrupada por directorios con claves famosamente concisas: d = directorio, fs = archivos, n = nombre de archivo, s = tamaño, cre/mod = marcas de tiempo en segundos unix, g = id de grupo, glrv = tamaño del proxy de baja resolución.
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
El tipo de medio se infiere del prefijo + extensión del nombre de archivo: GS = video 360, GX = video HERO, GT = timelapse, .360 = video esférico, .GPR/.RAW = foto raw. Estos prefijos importan más tarde, porque los archivos .360 se comportan de manera diferente a todo lo demás.
6. Descargas en streaming: turbo, progreso y archivos complementarios
La transferencia real de bytes es dio.download() directamente a una ruta de archivo, con un largo receive timeout porque un clip .360 de 1 GB tarda un tiempo:
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)),
);
}
Alrededor de ese núcleo, el servicio de transferencia orquesta los detalles del mundo real:
Transferencia turbo. Antes de descargar, activamos el modo turbo (turbo_transfer?p=1) — GoPro cambia a WiFi de 5 GHz para transferencias notablemente más rápidas — y lo desactivamos en un bloque finally para que siempre se limpie, incluso en caso de error.
Progreso + velocidad, limitada. Calcular la velocidad de transferencia en cada callback de onReceiveProgress es ineficiente e inestable, así que la recalculamos solo cada 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));
}
La cancelación es un flag cooperativo _isCancelled; al cancelar, eliminamos el archivo parcial y emitimos un evento de cancelación.
Archivos complementarios. Esta es la parte que no encontrarás en los documentos. Para un .MP4 de lente única, también descargamos el .LRV (un proxy de baja resolución que GoPro graba junto al video completo) y la miniatura .THM, luego indexamos el LRV para una reproducción rápida en la aplicación. Aquí hay dos trampas de nombres a tener en cuenta:
- HERO8+ renombra el proxy: GX######.MP4 / GH######.MP4 se convierte en GL######.LRV (el segundo carácter cambia a L), con un fallback de mismo nombre para cámaras más antiguas.
- Los archivos .360 no admiten los endpoints de thumbnail o screennail en absoluto. Recurrimos al archivo complementario .THM, y como último recurso descargamos el propio archivo. Y debido a que un .360 es de doble ojo de pez, recortamos la miniatura a la lente frontal — en un isolate de fondo vía compute(), para que la UI nunca se congele.
El estado de transferencia se expone a través de un notifier de Riverpod que se suscribe a los streams de archivos y transferencia, mantiene una transferencia finalizada visible durante 2 segundos y muestra errores durante 5. Cabe destacar: el control y la transferencia de archivos utilizan dos clientes Dio independientes a la misma cámara, por lo que una descarga puede ejecutarse mientras el sondeo de estado y el keep-alive continúan.
7. Dos trampas que no están en ningún tutorial
HTTP en texto claro está bloqueado por defecto en Android moderno
http://10.5.5.9:8080 es texto plano, y Android 9+ bloquea el tráfico cleartext por defecto. Tienes que permitirlo explícitamente:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
además de un network_security_config.xml cuya configuración base permite el cleartext (todavía puedes forzar HTTPS para tus propios dominios de la nube/backend). Si te pierdes esto, cada llamada a GoPro fallará con un confuso error de conexión.
El binding de red faltante — el punto frágil del diseño
Aquí está la parte honesta. En Android moderno, cuando te unes al AP de GoPro, el teléfono suele mantener la red celular como la red predeterminada porque el AP de GoPro no tiene internet. Esto significa que una solicitud HTTP a 10.5.5.9 puede ser enrutada a través de la red celular y fallar — aunque estés "conectado" a la cámara.
La solución robusta es nativa: llamar a ConnectivityManager.bindProcessToNetwork(goProNetwork) antes de las transferencias y bindProcessToNetwork(null) después. Una ruta de GoPro puramente Dart no tiene un equivalente — y tiende a funcionar de todos modos porque el OS generalmente enruta la dirección fija 10.5.5.9 al AP. Pero es la parte más frágil de la arquitectura, y en algunos dispositivos/versiones de Android es lo primero que falla. Si implementas esto, añade el binding de red — es el único lugar donde permanecer 100% Dart te cuesta fiabilidad.
Lecciones aprendidas
- No necesitas BLE. Si el usuario puede unirse al AP, la Open GoPro HTTP API te da control total y transferencias rápidas en Dart puro.
- Detecta por sondeo TCP, no por escaneo de SSID — sin permiso de ubicación, sin código de plataforma.
- Mantén el keep-alive cada 30 segundos o la sesión morirá. Trata un keep-alive fallido como tu señal de desconexión.
- Deja que la cámara te corrija. El bucle de auto-curación de 403-available_options supera la modelización de toda la matriz de ajustes de GoPro.
- El cambio de modo es una máquina de estados — serialízalo, detén la grabación primero, respeta los retrasos de transición de la lente y confía en el estado intencionado sobre el estado sondeado.
- Los archivos complementarios y las peculiaridades de .360 son donde están los secretos — renombrado de LRV, ningún endpoint de miniatura, recorte de doble ojo de pez.
- Dos trampas de infraestructura te hundirán silenciosamente: la configuración de tráfico cleartext y (especialmente) el binding del proceso a la red de la cámara para que las solicitudes no se escapen por la red celular.
La recompensa: un teléfono que se conecta a una GoPro por WiFi, controla cada función y transmite gigabytes de metraje con progreso en vivo — y casi todo es simplemente Dart bien estructurado comunicándose con http://10.5.5.9:8080.

