SDK도, BLE 페어링 과정도 필요 없습니다. 그저 카메라의 고정 IP, Dio 클라이언트, 그리고 미리 알지 못하면 당신의 하루를 조용히 망칠 몇 가지 주의사항만 있으면 됩니다.
왜 Bluetooth가 아닌 WiFi인가요?
대부분의 "GoPro 연결" 튜토리얼은 Bluetooth Low Energy로 시작합니다: 스캔, 페어링, GATT 특성 교환, 그리고 BLE를 사용하여 WiFi를 켠 다음, 어쨌든 WiFi를 통해 전송합니다. 작동은 하지만 많은 절차가 필요하며, BLE는 사용자들이 실제로 원하는 것, 즉 카메라를 제어하고 영상을 빠르게 가져오는 데 있어 느리고 번거롭습니다.
따라서 이 앱에서는 BLE를 완전히 건너뜁니다. 사용자는 휴대폰 설정에서 GoPro의 WiFi AP에 연결하고, 그 시점부터 모든 것이 카메라의 Open GoPro API에 대한 일반 HTTP 요청입니다 (고정된 주소):
http://10.5.5.9:8080
카메라 제어, 설정, 미디어 목록 확인, 파일 다운로드는 모두 해당 IP 주소로 보내는 HTTP GET 요청입니다. 전체 통합은 dio 패키지를 사용하여 100% Dart로 이루어지며, 네이티브 Kotlin/Swift 코드는 전혀 없습니다. 이것이 핵심이며, 마지막에 보겠지만 이 설계가 취약한 유일한 부분이기도 합니다.
흐름:
GoPro WiFi AP 연결 (휴대폰 설정)
→ 카메라 연결 가능 여부 감지 (10.5.5.9:8080으로 TCP 프로브)
→ 연결 + 모델 식별 (GET /gopro/camera/state)
→ Keep-alive (30초) + 상태 폴링 (5초) 타이머 시작
→ 제어: 셔터 / 모드 / 설정
→ 미디어 목록 (GET /gopro/media/list)
→ 터보 + 진행 상황과 함께 다운로드 (dio.download)
1. WiFi 스캔 권한 없이 카메라 감지하기
첫 번째 본능은 WiFi SSID를 열거하고 GoPro로 시작하는 것을 찾는 것입니다. 그렇게 하지 마십시오 — 그렇게 하면 위치 권한과 플랫폼별 WiFi 스캔 API가 필요해집니다.
GoPro는 항상 동일한 IP에 있으므로, 감지는 간단합니다: 10.5.5.9:8080으로 TCP 소켓을 열 수 있는가? 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 래퍼입니다. 짧은 연결 타임아웃 (LAN에 있으므로 느리다면 문제가 있는 것입니다)과 제어 호출을 위한 충분한 수신 타임아웃에 유의하십시오.
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 | — |
| Keep-alive | 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/... 계열이 공존한다는 것입니다. 새로운 API가 깔끔하게 처리할 수 없는 chaptered.360/GS 그룹 파일 제거는 우리가 오래된 엔드포인트에 의존하는 유일한 상황입니다.
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)); // linear backoff
continue;
}
}
}
}
카메라가 보고한 옵션은 서비스 계층에서 SettingRejectedError를 감지할 경우 자체 기능 목록을 다시 작성하는 데 사용됩니다. 각 모델에 대해, 우리는 낙관적인 하드 코딩된 기능 테이블로 시작하고 런타임 내내 카메라가 우리를 수정하도록 허용합니다. 이는 GoPro의 전체 설정 매트릭스를 미리 시뮬레이션하려는 시도보다 더 실용적이고 자체 수정적입니다.
3. Keep-alive: 잊지 말아야 할 타이머
약 60초간 활동이 없으면 GoPro WiFi AP가 세션을 종료합니다. 건드리지 않으면 완벽하게 잘 작동하던 연결이 세션 중간에 조용히 끊어집니다. 따라서 연결 시 두 개의 타이머를 시작합니다:
- 30초마다 Keep-alive → GET /gopro/camera/keep_alive
- 5초마다 상태 폴링 → 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
}
}
Keep-alive 실패는 또한 우리의 연결 해제 신호입니다 — 이는 사용자가 WiFi 범위 밖으로 나갔음을 감지하는 가장 신뢰할 수 있는 방법입니다.
4. 카메라 제어: 셔터, 모드, 그리고 MAX 2 렌즈 전환
셔터는 쉬운 부분입니다 — 시작하고 500ms 기다린 후 상태를 다시 폴링합니다. 사진 캡처도 동일한 셔터/시작 명령입니다; 비디오를 얻을지 사진을 얻을지는 현재 모드에 따라 달라집니다.
모드 전환은 흥미로운 부분인데, GoPro MAX 2의 "모드"는 사실 두 가지 설정으로 이루어지기 때문입니다: 프리셋 그룹 (Video = 1000, Photo = 1001, Timelapse = 1002)과 렌즈 (설정 194: 0 = 단일 렌즈/HERO, 1 = 360)입니다. 이를 제대로 구현하는 데에는 코드에 숨겨진 몇 가지 비직관적인 규칙이 필요했습니다:
- 모드 변경을 직렬화합니다. 빠른 서브 모드 탭은 Completer 체인을 통해 큐에 추가되어 서로 경쟁하여 일관성 없는 상태로 들어가지 않도록 합니다.
- 셔터를 먼저 중지합니다. 펌웨어는 녹화 중 프리셋 변경을 HTTP 400으로 거부하므로, 전환하기 전에 인코딩을 사전에 중지합니다.
- 순서와 지연 시간이 중요합니다. MAX 2에서는: 프리셋 그룹을 먼저 설정하고 (+400 ms), 그 다음 설정 194를 통해 렌즈를 설정합니다 (+1500 ms). 이 1.5초의 일시 정지는 실제 물리적인 렌즈 어셈블리 전환 때문입니다.
- 폴링된 상태가 아닌 의도된 상태를 신뢰합니다. 우리는 _lastIntendedIs360 / _lastIntendedPresetGroup을 추적하고 이들과 비교하는데, 5초 상태 폴링이 전환 중에 오래된 스냅샷을 제공할 수 있기 때문입니다.
언급할 만한 정말 교묘한 GoPro의 특이한 점도 있습니다: 해상도 ID는 화면 비율에 따라 달라집니다. 4K는 16:9에서 옵션 1, 9:16에서 109, 4:3에서 112입니다. 따라서 화면 비율을 변경한 후에는 기능을 다시 쿼리합니다 — 그렇지 않으면 동일한 ID가 다른 해상도를 의미할 수 있기 때문입니다.
5. 미디어 목록
GET /gopro/media/list는 매우 간결한 키로 디렉토리별로 그룹화된 구조를 반환합니다: d = 디렉토리, fs = 파일, n = 파일명, s = 크기, cre/mod = 유닉스 초 타임스탬프, g = 그룹 ID, 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)); // newest first
미디어 유형은 파일명 접두사 + 확장자에서 추론됩니다: GS = 360 비디오, GX = HERO 비디오, GT = 타임랩스, .360 = 구형 비디오, .GPR/.RAW = 원본 사진. 이 접두사들은 나중에 중요해지는데, .360 파일은 다른 모든 파일과 다르게 동작하기 때문입니다.
6. 스트리밍 다운로드: 터보, 진행 상황, 그리고 동반 파일
실제 바이트 전송은 dio.download()를 사용하여 파일 경로로 직접 이루어지며, 1GB .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 GHz WiFi로 전환합니다 — 에러가 발생해도 항상 정리되도록 finally 블록에서 끕니다.
진행 상황 + 속도, 제한. 모든 onReceiveProgress 콜백에서 전송 속도를 계산하는 것은 낭비적이고 불안정하므로, 우리는 500ms마다 한 번씩만 다시 계산합니다:
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()를 통해 백그라운드 격리 환경에서 처리하여 UI가 끊기지 않도록 합니다.
전송 상태는 파일 및 전송 스트림을 구독하고, 완료된 전송을 2초 동안 표시하며, 5초 동안 오류를 나타내는 Riverpod notifier를 통해 노출됩니다. 주목할 점은 제어 및 파일 전송이 동일한 카메라에 대해 두 개의 독립적인 Dio 클라이언트를 사용하므로, 상태 폴링과 Keep-alive가 계속되는 동안에도 다운로드가 실행될 수 있다는 것입니다.
7. 어떤 튜토리얼에도 없는 두 가지 주의사항
최신 Android에서는 기본적으로 Cleartext HTTP가 차단됩니다
http://10.5.5.9:8080은 일반 텍스트이며, Android 9 이상에서는 기본적으로 Cleartext 트래픽을 차단합니다. 명시적으로 허용해야 합니다:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
기본 설정이 Cleartext를 허용하는 network_security_config.xml도 필요합니다 (자체 클라우드/백엔드 도메인에는 여전히 HTTPS를 강제할 수 있습니다). 이를 놓치면 모든 GoPro 호출이 혼란스러운 연결 오류로 실패합니다.
누락된 네트워크 바인딩 — 설계의 취약점
솔직히 말씀드리자면, 최신 Android에서 GoPro AP에 연결하면 휴대폰은 일반적으로 GoPro AP에 인터넷이 없기 때문에 셀룰러를 기본 네트워크로 유지합니다. 이는 카메라에 "연결"되어 있음에도 불구하고 10.5.5.9로의 HTTP 요청이 셀룰러를 통해 라우팅되어 실패할 수 있음을 의미합니다.
견고한 해결책은 네이티브입니다: 전송 전에 ConnectivityManager.bindProcessToNetwork(goProNetwork)를 호출하고, 전송 후에 bindProcessToNetwork(null)을 호출합니다. 순수 Dart GoPro 경로는 동등한 것이 없으며 — OS가 일반적으로 고정된 10.5.5.9 주소를 AP로 라우팅하기 때문에 어쨌든 작동하는 경향이 있습니다. 하지만 이것이 아키텍처의 가장 취약한 부분이며, 일부 장치/Android 버전에서는 가장 먼저 고장나는 부분입니다. 이 기능을 출시하려면 네트워크 바인딩을 추가하십시오 — 100% Dart를 유지하는 것이 안정성을 희생시키는 유일한 부분입니다.
배운 점
- BLE는 필요 없습니다. 사용자가 AP에 연결할 수 있다면, Open GoPro HTTP API는 순수 Dart로 완전한 제어와 빠른 전송을 제공합니다.
- SSID 스캔이 아닌 TCP 프로브로 감지하세요 — 위치 권한, 플랫폼 코드가 필요 없습니다.
- 30초마다 Keep-alive를 보내지 않으면 세션이 종료됩니다. Keep-alive 실패를 연결 해제 신호로 간주하세요.
- 카메라가 당신을 수정하게 하십시오. 403-available_options 자체 복구 루프가 GoPro의 전체 설정 매트릭스를 모델링하는 것보다 효율적입니다.
- 모드 전환은 상태 머신입니다 — 직렬화하고, 먼저 녹화를 중지하고, 렌즈 전환 지연을 준수하며, 폴링된 상태보다 의도된 상태를 신뢰하십시오.
- 동반 파일과 .360 특이사항은 숨겨진 문제점들입니다 — LRV 이름 변경, 썸네일 엔드포인트 없음, 듀얼 어안 렌즈 크롭.
- 두 가지 인프라 주의사항이 조용히 당신을 좌초시킬 것입니다: Cleartext 트래픽 설정, 그리고 (특히) 요청이 셀룰러를 통해 나가지 않도록 프로세스를 카메라 네트워크에 바인딩하는 것입니다.
그 결과: WiFi를 통해 GoPro에 연결하고, 모든 제어를 수행하며, 실시간 진행 상황과 함께 기가바이트의 영상을 스트리밍으로 가져올 수 있는 휴대폰 앱 — 그리고 이 모든 것이 http://10.5.5.9:8080과 통신하는 잘 구조화된 Dart 코드에 불과합니다.

