אין SDK, אין ריקוד צימוד BLE – רק ה-IP הקבוע של המצלמה, לקוח Dio, וקומץ מלכודות קטנות שיהרסו לכם בשקט את היום אם לא תכירו אותן.
למה WiFi, ולא Bluetooth?
רוב מדריכי ה"התחברות ל-GoPro" מתחילים עם Bluetooth Low Energy: סריקה, צימוד, החלפת מאפייני GATT, ואז שימוש ב-BLE כדי להפעיל את ה-WiFi, ואז העברה דרך WiFi בכל מקרה. זה עובד, אבל זו הרבה התעסקות — ו-BLE איטי ומתעתע עבור הדבר שהמשתמשים באמת רוצים, שהוא לשלוט במצלמה ולשלוף ממנה את הצילומים במהירות.
אז באפליקציה הזו אנחנו מדלגים על BLE לחלוטין. המשתמש מצטרף לנקודת הגישה של ה-WiFi של ה-GoPro מהגדרות הטלפון שלו, ומאותה נקודה והלאה הכל הוא HTTP רגיל מול ה-API של Open GoPro של המצלמה בכתובת קבועה:
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)
→ הפעלת טיימרים לשמירה על חיבור (30 שניות) + סקר מצב (5 שניות)
→ שליטה: תריס / מצב / הגדרות
→ רישום מדיה (GET /gopro/media/list)
→ הורדה עם טורבו + התקדמות (dio.download)
1. זיהוי המצלמה ללא הרשאות סריקת WiFi
האינסטינקט הראשון הוא למנות WiFi SSIDs ולחפש אחד שמתחיל ב-GoPro. אל תעשו זאת — זה גורר הרשאות מיקום ו-APIs ספציפיים לפלטפורמה לסריקת WiFi.
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 דקה. שימו לב לזמן קצר של חיבור (אתם ברשת 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'},
));
}
ה-API של Open GoPro אחיד להפליא: הכל הוא בקשת 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) |
| תמונה ממוזערת / screennail | 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)); // linear backoff
continue;
}
}
}
rethrow;
}
}
האפשרויות המדווחות של המצלמה משמשות את שכבת השירות כדי לשכתב את רשימת היכולות שלה אם היא מזהה SettingRejectedError. עבור כל דגם, אנו מתחילים עם טבלת יכולות אופטימית וקודדת קשה ומאפשרים למצלמה לתקן אותנו לאורך זמן הריצה. זה פרקטי יותר ומתקן את עצמו מאשר לנסות לדמות מראש את כל מטריצת ההגדרות של GoPro.
3. Keep-alive: הטיימר שאסור לכם לשכוח
לאחר כ-60 שניות של חוסר פעילות, נקודת הגישה של ה-WiFi ב-GoPro מנתקת את הסשן שלכם. החיבור שלכם, שהיה תקין לחלוטין, מת בשקט באמצע סשן אם לא "תדקרו" אותו. לכן, אנו מפעילים שני טיימרים בהתחברות:
- שמירה על חיבור כל 30 שניות ← 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
התריס הוא החלק הקל — התחלה, המתנה של 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 = חותמות זמן ביוניקס-שניות, 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)); // newest first
סוג המדיה מוסק מקידומת שם הקובץ + סיומת: GS = וידאו 360, GX = וידאו HERO, GT = Timelapse, .360 = וידאו כדורי, .GPR/.RAW = תמונה גולמית. קידומות אלו חשובות בהמשך, מכיוון שקבצי .360 מתנהגים באופן שונה מכל השאר.
6. הורדות בסטרימינג: טורבו, התקדמות וקבצים נלווים
העברת הבייטים בפועל היא dio.download() ישירות לנתיב קובץ, עם זמן קבלת פסיקה ארוך מכיוון שקליפ .360 בגודל 1 GB לוקח זמן:
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 עוברת ל-WiFi בתדר 5 GHz להעברות מהירות יותר באופן ניכר — ואנו מכבים אותו בבלוק 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 אינם תומכים כלל בנקודות הקצה של thumbnail או screennail. אנו נופלים חזרה לקובץ הנלווה .THM, וכמוצא אחרון מורידים את הקובץ עצמו. ומכיוון ש-.360 הוא "dual-fisheye" (עין דג כפולה), אנו חותכים את התמונה הממוזערת לעדשה הקדמית — ברקע נפרד באמצעות compute(), כך שממשק המשתמש לעולם לא נתקע.
מצב ההעברה נחשף באמצעות Riverpod notifier שנרשם לזרימת הקבצים וההעברות, שומר העברה שהסתיימה גלויה למשך 2 שניות, ומציג שגיאות למשך 5. ראוי לציין: הבקרה והעברת הקבצים משתמשות בשני לקוחות Dio נפרדים לאותה מצלמה, כך שהורדה יכולה להתבצע בזמן שסקר המצב וה-keep-alive ממשיכים.
7. שתי מלכודות שלא נמצאות באף מדריך
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) לאחר מכן. לנתיב GoPro ב-Dart טהור אין מקביל — וזה נוטה לעבוד בכל מקרה מכיוון שמערכת ההפעלה בדרך כלל מנתבת את כתובת ה-10.5.5.9 הקבועה לנקודת הגישה. אבל זהו החלק השביר ביותר בארכיטקטורה, ובמכשירים/גרסאות Android מסוימות זה הדבר הראשון שנשבר. אם אתם משיקים את זה, הוסיפו את קישור הרשת — זה המקום היחיד שבו הישארות ב-100% Dart עולה לכם באמינות.
לקחים שנלמדו
- אתם לא צריכים BLE. אם המשתמש יכול להצטרף לנקודת הגישה, ה-Open GoPro HTTP API מעניק לכם שליטה מלאה והעברות מהירות ב-Dart טהור.
- זהו על ידי בדיקת TCP, לא סריקת SSID — ללא הרשאת מיקום, ללא קוד פלטפורמה.
- Keep-alive כל 30 שניות או שהסשן ימות. התייחסו ל-keep-alive כושל כאות הניתוק שלכם.
- תנו למצלמה לתקן אתכם. לולאת הריפוי העצמי של 403-available_options עדיפה על מידול מטריצת ההגדרות כולה של GoPro.
- החלפת מצבים היא מכונת מצבים — סדרו אותה, עצרו את ההקלטה תחילה, כבדו את השהיות מעבר העדשה, וסמכו על המצב המתוכנן על פני המצב שנסקר.
- קבצים נלווים ומוזרויות .360 הם המקום בו הקשיים חבויים — שינוי שם LRV, אין נקודת קצה לתמונות ממוזערות, חיתוך dual-fisheye.
- שתי מלכודות תשתית ישקיעו אתכם בשקט: תצורת תעבורת cleartext, ו(במיוחד) קישור התהליך לרשת המצלמה כך שבקשות לא יברחו דרך הסלולר.
התמורה: טלפון שמתחבר ל-GoPro דרך WiFi, מפעיל כל שליטה, ומזרים גיגה-בייטים של צילומים ממנו עם התקדמות חיה — וכמעט הכל הוא פשוט Dart מובנה היטב המתקשר עם http://10.5.5.9:8080.

