Без SDK, без складнощів з BLE-з'єднанням — лише фіксований IP камери, Dio клієнт і кілька нюансів, які можуть зіпсувати вам день, якщо ви про них не знаєте.
Чому WiFi, а не Bluetooth?
Більшість посібників із "підключення до GoPro" починаються з Bluetooth Low Energy: сканування, сполучення, обмін характеристиками GATT, потім використання BLE для ввімкнення WiFi, а потім все одно передача через WiFi. Це працює, але це багато зайвих кроків — і BLE повільний та примхливий для того, що користувачі насправді хочуть: керувати камерою та швидко завантажувати з неї матеріал.
Тому в цьому застосунку ми повністю пропускаємо BLE. Користувач підключається до точки доступу WiFi камери GoPro зі свого телефону, і з цього моменту все відбувається за звичайним HTTP до Open GoPro API камери за фіксованою адресою:
http://10.5.5.9:8080
Керування камерою, налаштування, перегляд медіа та завантаження файлів — все це HTTP GET запити до цього IP. Вся інтеграція на 100% виконана на Dart з використанням пакету dio — нуль нативного Kotlin/Swift. Це головне, і, як ми побачимо наприкінці, це також єдине місце, де дизайн є крихким.
Послідовність дій:
підключення до GoPro WiFi AP (налаштування телефону)
→ виявлення доступності камери (TCP зондування на 10.5.5.9:8080)
→ підключення + ідентифікація моделі (GET /gopro/camera/state)
→ запуск таймерів keep-alive (30 с) + опитування статусу (5 с)
→ керування: затвор / режим / налаштування
→ список медіа (GET /gopro/media/list)
→ завантаження з турбо режимом + прогрес (dio.download)
1. Виявлення камери без дозволів WiFi-сканування
Перший інстинкт — перерахувати WiFi SSID та шукати той, що починається з GoPro. Не робіть цього — це вимагає дозволів на місцезнаходження та специфічних для платформи WiFi-scan API.
GoPro завжди має один і той самий IP, тому виявлення полягає лише в: чи можу я відкрити TCP сокет на 10.5.5.9:8080? Без HTTP, без дозволів, без парсингу 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);
}
}
}
Ми опитуємо це кожні 2 секунди та транслюємо зміни стану через потік. Екран підключення підписується і переходить у стан "підключено" в момент відкриття сокета. Оскільки реального виявлення пристроїв немає, сервіс синтезує віртуальний CameraDevice (з ключем від фіксованого IP), тому решта абстракції камери не потребує знання різниці.
2. HTTP клієнт: Dio та кінцеві точки Open GoPro
Клієнт — це тонка обгортка Dio. Зверніть увагу на короткий таймаут підключення (ви в локальній мережі — якщо повільно, значить щось зламано) і великий таймаут прийому для керуючих викликів:
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 приємно уніфікований: все є HTTP GET запитом, навіть команди та зміни налаштувань. Ось кінцеві точки, які цей застосунок фактично використовує:
| Призначення | Метод + Шлях | Запит |
|---|---|---|
| Запустити затвор (відео / фото) | GET /gopro/camera/shutter/start | — |
| Зупинити затвор | GET /gopro/camera/shutter/stop | — |
| Підтримка активності | GET /gopro/camera/keep_alive | — |
| Повний стан (статус + налаштування) | GET /gopro/camera/state | — |
| Встановити групу пресетів | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Змінити налаштування | GET /gopro/camera/setting | setting, option |
| Список медіа | GET /gopro/media/list | — |
| Завантажити файл | GET /videos/DCIM/{dir}/{file} | (bytes) |
| Ескіз / знімок екрана | GET /gopro/media/thumbnail / .../screennail | path |
| Турбо передача | GET /gopro/media/turbo_transfer | p (1/0) |
| Видалити групу .360 (застаріле) | GET /gp/gpControl/command/storage/delete/group | p |
Приємна деталь: співіснують як сучасне сімейство /gopro/..., так і застаріле сімейство /gp/gpControl/.... Видалення згрупованих файлів chaptered.360/GS, з якими новий API не може впоратися чисто, є єдиною ситуацією, коли ми вдаємося до старого кінцевої точки.
Шаблон самовідновлення 403 (найчистіший трюк тут)
Ви не можете заздалегідь знати, які налаштування є дійсними в поточному режимі камери — роздільна здатність, яка дозволена у 16:9, є незаконною у 9:16, опції FPS залежать від роздільної здатності тощо. GoPro повідомляє про це у жорсткий спосіб: вона повертає HTTP 403 зі списком опцій, які були б дійсними, та HTTP 500, коли вона просто тимчасово зайнята.
Тому ми аналізуємо тіло 403 у типізовану помилку та повторюємо 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)); // лінійне відстрочення
continue;
}
}
rethrow;
}
}
}
Заявлені камерою опції використовуються сервісним рівнем для перезапису власного списку можливостей, якщо виявлено SettingRejectedError. Для кожної моделі ми починаємо з оптимістичної, жорстко закодованої таблиці можливостей і дозволяємо камері коригувати нас протягом виконання. Це більш практично та самокоректуюче, ніж спроби заздалегідь змоделювати всю матрицю налаштувань GoPro.
3. Keep-alive: таймер, про який не можна забувати
Після приблизно 60 секунд бездіяльності GoPro WiFi AP завершує вашу сесію. Ваше ідеальне з'єднання тихо зникає в середині сесії, якщо ви його не «штурхаєте». Отже, ми запускаємо два таймери при підключенні:
- Keep-alive кожні 30 с → GET /gopro/camera/keep_alive
- Опитування статусу кожні 5 с → GET /gopro/camera/state
void _startKeepAlive() {
_keepAliveTimer?.cancel();
_keepAliveTimer = Timer.periodic(
GoProHttpConstants.keepAliveInterval, // 30с
(_) => _sendKeepAlive(),
);
}
Future<void> _sendKeepAlive() async {
if (!isConnected || _isDisposed) return;
try {
await _httpClient.keepAlive();
} catch (e) {
if (!_isDisposed) _handleDisconnection(); // невдалий keep-alive == ми відключені
}
}
Невдалий keep-alive також є нашим сигналом відключення — це найнадійніший спосіб помітити, що користувач вийшов із зони дії WiFi.
4. Керування камерою: затвор, режим та танець об'єктива MAX 2
Затвор — це проста частина — старт, почекати 500 мс, повторно опитати стан. Зйомка фото — це той самий затвор/старт; чи отримаєте ви відео або фото, залежить від поточного режиму.
Перемикання режимів стає цікавим, тому що "режим" на GoPro MAX 2 — це насправді два налаштування: група пресетів (Відео = 1000, Фото = 1001, Timelapse = 1002) плюс об'єктив (Налаштування 194: 0 = монооб'єктив/HERO, 1 = 360). Правильне налаштування вимагало кількох неочевидних правил, закладених у код:
- Серіалізуйте зміни режиму. Швидкі натискання підрежимів ставляться в чергу через ланцюжок Completer, щоб вони не могли змагатися один з одним, призводячи до непослідовного стану.
- Спочатку зупиніть затвор. Прошивка відхиляє зміни пресетів з HTTP 400 під час запису, тому ми завчасно припиняємо кодування перед перемиканням.
- Порядок і затримки мають значення. На MAX 2: спочатку встановіть групу пресетів (+400 мс), потім об'єктив через Налаштування 194 (+1500 мс). Ця 1.5-секундна пауза реальна — це фізичний перехід об'єктива.
- Довіряйте бажаному стану, а не опитуваному стану. Ми відстежуємо _lastIntendedIs360 / _lastIntendedPresetGroup та порівнюємо з ними, оскільки 5-секундний опитування статусу може надати застарілий знімок у середині переходу.
Також є справді хитрий нюанс GoPro, який варто відзначити: ідентифікатори роздільної здатності залежать від співвідношення сторін. 4K — це опція 1 у 16:9, 109 у 9:16 і 112 у 4:3. Тому після зміни співвідношення сторін ми повторно запитуємо можливості — інакше той самий ідентифікатор означав би іншу роздільну здатність.
5. Перегляд медіа
GET /gopro/media/list повертає структуру, згруповану за директоріями, з відомо короткими ключами: d = директорія, fs = файли, n = ім'я файлу, s = розмір, cre/mod = часові мітки в секундах Unix, g = ідентифікатор групи, glrv = розмір проксі-файлу низької роздільної здатності.
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)); // найновіші перші
Тип медіа виводиться з префікса файлу + розширення: GS = 360 відео, GX = HERO відео, GT = timelapse, .360 = сферичне відео, .GPR/.RAW = RAW фото. Ці префікси важливі пізніше, тому що файли .360 поводяться інакше, ніж усі інші.
6. Потокове завантаження: турбо, прогрес та супутні файли
Фактична передача байтів відбувається через dio.download() безпосередньо до шляху файлу, з довгим таймаутом прийому, оскільки 1 ГБ .360-кліпу займає деякий час:
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)),
);
}
Навколо цього ядра сервіс передачі організовує реальні деталі:
Турбо передача. Перед завантаженням ми вмикаємо турбо режим (turbo_transfer?p=1) — GoPro переключається на 5 ГГц WiFi для помітно швидшої передачі — і вимикаємо його в блоці finally, щоб він завжди був очищений, навіть у разі помилки.
Прогрес + швидкість, обмежені. Обчислення швидкості передачі при кожному onReceiveProgress виклику є марнотратним та нестабільним, тому ми перераховуємо її лише кожні 500 мс:
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));
}
Скасування відбувається за допомогою кооперативного прапорця _isCancelled; при скасуванні ми видаляємо частковий файл та генеруємо подію скасування.
Супутні файли. Це частина, яку ви не знайдете в документації. Для .MP4 з одним об'єктивом ми також завантажуємо .LRV (проксі-файл низької роздільної здатності, який GoPro записує разом з повним відео) та мініатюру .THM, а потім індексуємо LRV для швидкого відтворення в застосунку. Тут є два підводних камені в іменуванні:
- HERO8+ перейменовує проксі-файл: GX######.MP4 / GH######.MP4 стає GL######.LRV (другий символ змінюється на L), з резервним варіантом тієї ж назви для старих камер.
- Файли .360 взагалі не підтримують кінцеві точки для мініатюр або знімків екрана. Ми вдаємося до супутнього .THM, а в крайньому випадку завантажуємо сам файл. І оскільки .360 є подвійним риб'ячим оком, ми обрізаємо мініатюру до переднього об'єктива — у фоновому режимі через compute(), щоб інтерфейс користувача ніколи не "підвисав".
Стан передачі виводиться через Riverpod notifier, який підписується на потоки файлів і передачі, утримує завершену передачу видимою протягом 2 секунд і відображає помилки протягом 5. Варто зазначити: керування та передача файлів використовують два незалежні Dio клієнти до однієї камери, тому завантаження може відбуватися, поки тривають опитування статусу та keep-alive.
7. Два нюанси, яких немає в жодному посібнику
Cleartext HTTP заблоковано за замовчуванням на сучасному Android
http://10.5.5.9:8080 є звичайним текстом, і Android 9+ за замовчуванням блокує cleartext трафік. Ви повинні явно дозволити його:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
плюс network_security_config.xml, базова конфігурація якого дозволяє cleartext (ви все ще можете примусово використовувати HTTPS для власних хмарних/бек-енд доменів). Пропустіть це, і кожен виклик GoPro завершиться з незрозумілою помилкою з'єднання.
Відсутня прив'язка мережі — вразливе місце дизайну
Ось чесна частина. На сучасному Android, коли ви підключаєтеся до GoPro AP, телефон зазвичай зберігає мобільний зв'язок як мережу за замовчуванням, оскільки GoPro AP не має доступу до Інтернету. Це означає, що HTTP-запит до 10.5.5.9 може бути направлений через мобільний зв'язок і зазнати невдачі — навіть якщо ви "підключені" до камери.
Надійне рішення — нативне: викликати ConnectivityManager.bindProcessToNetwork(goProNetwork) перед передачею та bindProcessToNetwork(null) після. Чистий Dart-шлях GoPro не має еквівалента — і він все одно, як правило, працює, тому що ОС зазвичай направляє фіксовану адресу 10.5.5.9 до AP. Але це найбільш крихка частина архітектури, і на деяких пристроях/версіях Android це перше, що ламається. Якщо ви це розгортаєте, додайте прив'язку мережі — це єдине місце, де 100% Dart коштує вам надійності.
Вивчені уроки
- Вам не потрібен BLE. Якщо користувач може приєднатися до AP, Open GoPro HTTP API дає вам повний контроль та швидку передачу в чистому Dart.
- Виявляйте за допомогою TCP зондування, а не сканування SSID — без дозволу на місцезнаходження, без платформенного коду.
- Keep-alive кожні 30 секунд, інакше сесія зникне. Вважайте невдалий keep-alive сигналом роз'єднання.
- Дозвольте камері коригувати вас. Цикл самовідновлення 403-available_options краще, ніж моделювання всієї матриці налаштувань GoPro.
- Перемикання режимів — це кінцевий автомат: серіалізуйте його, спочатку зупиніть запис, дотримуйтесь затримок переходу об'єктива та довіряйте бажаному стану, а не опитуваному.
- Супутні файли та особливості .360 — це місця, де "зариті" проблеми: перейменування LRV, відсутність кінцевої точки для мініатюр, обрізка подвійного риб'ячого ока.
- Дві інфраструктурні "підводні камені" тихо вас потоплять: конфігурація cleartext трафіку та (особливо) прив'язка процесу до мережі камери, щоб запити не виходили через стільниковий зв'язок.
Результат: телефон, який підключається до GoPro через WiFi, керує кожною функцією та передає гігабайти відзнятого матеріалу з live-прогресом — і майже все це просто добре структурований Dart, який спілкується з http://10.5.5.9:8080.

