SDK도 없고, BLE 페어링 복잡한 과정도 없이 — 카메라의 고정 IP, Dio 클라이언트, 그리고 미리 알지 못하면 조용히 하루를 망칠 수 있는 몇 가지 예상치 못한 문제들만 알면 됩니다.
Bluetooth가 아닌 WiFi를 사용하는 이유?
대부분의 "GoPro 연결" 튜토리얼은 Bluetooth Low Energy로 시작합니다: 스캔, 페어링, GATT 특성 교환, 그리고 BLE를 사용하여 WiFi를 켜고, 어쨌든 WiFi를 통해 전송합니다. 이 방법은 작동하지만 절차가 복잡하며, 사용자가 실제로 원하는 카메라 제어 및 빠른 영상 전송에는 BLE가 느리고 번거롭습니다.
그래서 이 앱에서는 BLE를 완전히 건너뜁니다. 사용자는 휴대폰 설정에서 GoPro의 WiFi 액세스 포인트에 연결하고, 그 시점부터 모든 것은 카메라의 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-scan 권한 없이 카메라 감지
첫 번째 본능은 WiFi SSID를 열거하고 GoPro로 시작하는 것을 찾는 것입니다. 그렇게 하지 마세요 — 이는 위치 권한 및 플랫폼별 WiFi-scan 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;
}
}
rethrow;
}
}
}
카메라가 보고한 옵션은 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에서 "모드"는 실제로는 두 가지 설정이기 때문에 모드 전환은 흥미로운 부분입니다: 프리셋 그룹 (비디오 = 1000, 사진 = 1001, 타임랩스 = 1002)과 렌즈 (설정 194: 0 = 단일 렌즈/HERO, 1 = 360)입니다. 이를 올바르게 구현하기 위해 코드에 여러 가지 명확하지 않은 규칙을 적용했습니다:
- 모드 변경을 직렬화합니다. 빠른 서브 모드 탭은 Completer 체인을 통해 대기열에 추가되어 서로 경쟁하여 일관성 없는 상태에 빠지지 않도록 합니다.
- 먼저 셔터를 중지합니다. 펌웨어는 녹화 중 프리셋 변경을 HTTP 400으로 거부하므로, 전환 전에 선제적으로 인코딩을 중지합니다.
- 순서와 지연이 중요합니다. MAX 2에서는: 프리셋 그룹을 먼저 설정하고 (+400ms), 그 다음에 설정 194를 통해 렌즈를 설정합니다 (+1500ms). 이 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 = 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 코드일 뿐입니다.

