אין 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)
→ הורדה עם turbo + התקדמות (dio.download)
1. איתור המצלמה ללא הרשאות סריקת WiFi
האינסטינקט הראשון הוא לספור WiFi SSIDs ולחפש אחד שמתחיל ב-GoPro. אל תעשו זאת — זה גורר איתו הרשאות מיקום ו-WiFi-scan APIs ספציפיים לפלטפורמה.
GoPro תמיד נמצאת באותו IP, כך שהאיתור הוא פשוט: האם אני יכול לפתוח TCP socket ל-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);
}
}
}
אנו מבצעים בדיקה (poll) כל 2 שניות ומשדרים שינויי מצב על פני stream. מסך החיבור נרשם ומשתנה למצב "מחובר" ברגע שה-socket נפתח. מכיוון שאין גילוי התקנים אמיתי, השירות מסנתז CameraDevice וירטואלי (מבוסס על ה-IP הקבוע) כך ששאר שכבת ההפשטה של המצלמה לא צריכה לדעת את ההבדל.
2. לקוח ה-HTTP: Dio ונקודות הקצה של Open GoPro
הלקוח הוא עטיפת Dio דקה. שימו לב ל-connect timeout הקצר (אתם ב-LAN — אם זה איטי, זה מקולקל) ו-receive timeout נדיב עבור קריאות בקרה:
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, אפילו פקודות ושינויי הגדרות. להלן נקודות הקצה שהאפליקציה הזו משתמשת בהן בפועל:
| מטרה | Method + Path | Query |
|---|---|---|
| הפעלת צמצם (הקלטה / צילום) | 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) |
| תמונה ממוזערת / screennail | GET /gopro/media/thumbnail / .../screennail | path |
| העברת טורבו | GET /gopro/media/turbo_transfer | p (1/0) |
| מחיקת קבוצת 360. (legacy) | 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 שניות של חוסר פעילות, ה-GoPro WiFi AP מסיים את הסשן שלכם. החיבור התקין שלכם מתנתק בשקט באמצע סשן אם לא תדחפו אותו. לפיכך, אנו מפעילים שני טיימרים בעת חיבור:
- Keep-alive כל 30 שניות → GET /gopro/camera/keep_alive
- בדיקת סטטוס (Status poll) כל 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). כדי לעשות זאת נכון נדרשו מספר כללים לא מובנים שהוטמעו בקוד:
- בצעו סריאליזציה (Serialize) לשינויי מצב. לחיצות מהירות על תת-מצבים נכנסות לתור דרך שרשרת Completer כך שהן לא יכולות להתחרות זו בזו למצב לא עקבי.
- עצרו את הצמצם תחילה. הקושחה (Firmware) דוחה שינויי קביעות מוגדרות מראש (preset) עם HTTP 400 בזמן הקלטה, ולכן אנו מפסיקים באופן יזום את הקידוד לפני המעבר.
- סדר ועיכובים חשובים. ב-MAX 2: הגדירו תחילה את קבוצת הקביעות המוגדרות מראש (preset group) (+400 אלפיות השנייה), ואז את העדשה באמצעות Setting 194 (+1500 אלפיות השנייה). ההשהיה של 1.5 שניות אמיתית — זוהי המעבר הפיזי של מכלול העדשות.
- סמכו על המצב המיועד (intended state), לא על המצב הנבדק (polled state). אנו עוקבים אחר _lastIntendedIs360 / _lastIntendedPresetGroup ומבצעים השוואה (diff) מול אלה, מכיוון שבדיקת הסטטוס (status poll) של 5 שניות יכולה להעניק לכם תמונת מצב מיושנת באמצע המעבר.
יש גם תכונה מוזרה ומתוחכמת של GoPro ששווה לציין: מזהי רזולוציה תלויים ביחס הגובה-רוחב. 4K היא אפשרות 1 ב-16:9, 109 ב-9:16, ו-112 ב-4:3. אז לאחר שינוי יחס הגובה-רוחב אנו מבצעים שאילתה חוזרת על היכולות — אותו ID אחרת היה אומר רזולוציה שונה.
5. רישום מדיה
GET /gopro/media/list מחזיר מבנה מקובץ לפי תיקיות עם מפתחות קצרים וידועים לשמצה: d = תיקייה, fs = קבצים, n = שם קובץ, s = גודל, cre/mod = חותמות זמן (timestamps) של שניות יוניקס, 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 = תמונת RAW. לקידומות אלה יש חשיבות בהמשך, מכיוון שקבצי 360. מתנהגים אחרת מכל השאר.
6. הורדות בסטרימינג: טורבו, התקדמות וקבצים נלווים
העברת הבתים בפועל היא dio.download() ישירות לנתיב קובץ, עם receive timeout ארוך מכיוון שקליפ 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 כך שהוא תמיד מנוקה, גם במקרה של שגיאה.
התקדמות + מהירות, מווסתות. חישוב מהירות העברה בכל קריאה חוזרת (callback) של 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() ב-isolate ברקע, כך שממשק המשתמש (UI) לעולם לא נתקע.
מצב ההעברה נחשף דרך Riverpod notifier שנרשם ל-streams של קבצים והעברות, משאיר העברה שהסתיימה גלויה למשך 2 שניות, ומציג שגיאות למשך 5 שניות. ראוי לציין: בקרה והעברת קבצים משתמשות בשני לקוחות Dio נפרדים לאותה מצלמה, כך שהורדה יכולה להתבצע בזמן שבדיקת הסטטוס (status poll) וה-keep-alive ממשיכים.
7. שתי "מלכודות" שלא נמצאות בשום מדריך
HTTP בטקסט רגיל (Cleartext) חסום כברירת מחדל באנדרואיד מודרני
http://10.5.5.9:8080 הוא טקסט רגיל (plaintext), ואנדרואיד 9+ חוסם תעבורת טקסט רגיל (cleartext) מהקופסה. עליכם לאפשר זאת במפורש:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
בתוספת network_security_config.xml שההגדרה הבסיסית שלו מאפשרת cleartext (אתם עדיין יכולים לאכוף HTTPS עבור דומייני הענן/backend שלכם). תפספסו את זה וכל קריאת GoPro תיכשל עם שגיאת חיבור מבלבלת.
קישור הרשת החסר — הנקודה השבירה בעיצוב
הנה החלק הכנה. באנדרואיד מודרני, כאשר אתם מצטרפים ל-GoPro AP, הטלפון בדרך כלל שומר את החיבור הסלולרי כרשת ברירת המחדל מכיוון של-GoPro AP אין אינטרנט. המשמעות היא שבקשת HTTP ל-10.5.5.9 יכולה להיות מנותבת דרך החיבור הסלולרי ולהיכשל — גם אם אתם "מחוברים" למצלמה.
התיקון החזק הוא מקורי: קריאה ל-ConnectivityManager.bindProcessToNetwork(goProNetwork) לפני העברות ול-bindProcessToNetwork(null) לאחר מכן. לנתיב GoPro ב-Dart טהור אין מקבילה — וזה נוטה לעבוד בכל מקרה מכיוון שה-OS בדרך כלל מנתב את הכתובת הקבועה 10.5.5.9 ל-AP. אבל זה החלק השביר ביותר בארכיטקטורה, ובמכשירים/גרסאות אנדרואיד מסוימים זה הדבר הראשון שנשבר. אם אתם מפרסמים זאת, הוסיפו את קישור הרשת (network binding) — זהו המקום היחיד שבו הישארות ב-100% Dart עולה לכם באמינות.
לקחים שנלמדו
- אינכם זקוקים ל-BLE. אם המשתמש יכול להצטרף ל-AP, ה-Open GoPro HTTP API מעניק לכם שליטה מלאה והעברות מהירות ב-Dart טהור.
- אתרו באמצעות בדיקת TCP (TCP probe), לא סריקת SSID — ללא הרשאת מיקום, ללא קוד פלטפורמה.
- Keep-alive כל 30 שניות אחרת הסשן מתנתק. התייחסו ל-keep-alive שנכשל כאות הניתוק שלכם.
- תנו למצלמה לתקן אתכם. לולאת הריפוי העצמי של 403-available_options עדיפה על מידול כל מטריצת ההגדרות של GoPro.
- מעבר מצבים הוא מכונת מצבים (state machine) — בצעו לו סריאליזציה, עצרו הקלטה תחילה, כבדו את עיכובי מעבר העדשות, וסמכו על המצב המיועד (intended state) על פני המצב הנבדק (polled state).
- קבצים נלווים ומוזרויות 360. הם המקום שבו טמונות הבעיות — שינוי שם LRV, אין נקודת קצה לתמונה ממוזערת, חיתוך dual-fisheye.
- שתי "מלכודות" תשתית יפילו אתכם בשקט: תצורת תעבורת cleartext, ו(במיוחד) קישור התהליך לרשת המצלמה כדי שבקשות לא יברחו דרך החיבור הסלולרי.
התמורה: טלפון שמתחבר ל-GoPro דרך WiFi, מפעיל כל פקד, ומזרים גיגה-בייטים של צילומים ממנה עם התקדמות חיה — וכמעט הכל הוא רק Dart בנוי היטב המדבר עם http://10.5.5.9:8080.

