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 ์ฝ๋์ ๋ถ๊ณผํฉ๋๋ค.

