لا يوجد 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 (إعدادات الهاتف)
← اكتشاف إمكانية الوصول إلى الكاميرا (فحص TCP إلى 10.5.5.9:8080)
← الاتصال + تحديد الطراز (GET /gopro/camera/state)
← بدء مؤقتات البقاء نشطًا (30 ثانية) + استقصاء الحالة (5 ثوانٍ)
← التحكم: الغالق / الوضع / الإعدادات
← سرد الوسائط (GET /gopro/media/list)
← تنزيل مع Turbo + التقدم (dio.download)
1. اكتشاف الكاميرا بدون أذونات مسح WiFi
الغريزة الأولى هي تعداد SSIDs الخاصة بـ WiFi والبحث عن واحدة تبدأ بـ 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);
}
}
}
نحن نستقصي ذلك كل ثانيتين ونبث تغييرات الحالة عبر دفق. تشترك شاشة الاتصال وتتحول إلى "متصل" لحظة فتح المقبس. نظرًا لعدم وجود اكتشاف حقيقي للجهاز، تقوم الخدمة بإنشاء 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 موحدة بشكل مبهج: كل شيء هو طلب 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) |
| صورة مصغرة / لقطة شاشة | GET /gopro/media/thumbnail / .../screennail | path |
| نقل Turbo | GET /gopro/media/turbo_transfer | p (1/0) |
| حذف مجموعة .360 (قديم) | GET /gp/gpControl/command/storage/delete/group | p |
تفصيل جميل: عائلة /gopro/... الحديثة وعائلة /gp/gpControl/... القديمة تتعايشان. إزالة ملفات مجموعة 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. البقاء نشطًا: المؤقت الذي يجب ألا تنساه
بعد حوالي 60 ثانية من عدم النشاط، تنهي نقطة وصول GoPro WiFi جلستك. اتصالك الجيد تمامًا يموت بهدوء في منتصف الجلسة إذا لم تحركه. وهكذا، نبدأ مؤقتين عند الاتصال:
- البقاء نشطًا كل 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
}
}
البقاء نشطًا الفاشل هو أيضًا إشارة قطع الاتصال لدينا — إنها الطريقة الأكثر موثوقية لملاحظة خروج المستخدم من نطاق WiFi.
4. التحكم في الكاميرا: الغالق، الوضع، ورقصة عدسة MAX 2
الغالق هو الجزء السهل — ابدأ، انتظر 500 مللي ثانية، أعد استقصاء الحالة. التقاط الصور هو نفس بدء/تشغيل الغالق؛ سواء حصلت على فيديو أو صورة يعتمد على الوضع الحالي.
تبديل الوضع هو الجزء المثير للاهتمام، لأن "الوضع" في GoPro MAX 2 هو في الواقع إعدادان: مجموعة إعدادات مسبقة (فيديو = 1000، صورة = 1001، فاصل زمني = 1002) بالإضافة إلى عدسة (الإعداد 194: 0 = عدسة واحدة/HERO، 1 = 360). للقيام بذلك بشكل صحيح، تطلب الأمر العديد من القواعد غير الواضحة المضمنة في الكود:
- تسلسل تغييرات الوضع. يتم وضع نقرات الوضع الفرعي السريعة في قائمة انتظار عبر سلسلة Completer حتى لا تتسابق مع بعضها البعض وتصل إلى حالة غير متسقة.
- أوقف الغالق أولاً. يرفض Firmware تغييرات الإعدادات المسبقة بـ 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 = الطوابع الزمنية بالثواني Unix، 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. تنزيلات الدفق: Turbo، التقدم، والملفات المصاحبة
يتم نقل البايت الفعلي بواسطة dio.download() مباشرة إلى مسار ملف، مع مهلة استلام طويلة لأن مقطع .360 بحجم 1 جيجابايت يستغرق بعض الوقت:
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. قبل التنزيل، نقوم بتشغيل وضع Turbo (turbo_transfer?p=1) — تنتقل GoPro إلى WiFi بتردد 5 جيجاهرتز لنقل أسرع بشكل ملحوظ — ونقوم بإيقاف تشغيله في كتلة 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 لا تدعم نقاط نهاية الصورة المصغرة أو لقطة الشاشة على الإطلاق. نلجأ إلى الملف المصاحب .THM، وكحل أخير نقوم بتنزيل الملف نفسه. ولأن .360 هو ثنائي عين السمكة، نقوم بقص الصورة المصغرة لتناسب العدسة الأمامية — على عزل خلفي عبر compute()، بحيث لا يتأثر واجهة المستخدم أبدًا.
يتم الكشف عن حالة النقل من خلال Riverpod notifier يشترك في تدفقات الملفات والنقل، ويحافظ على نقل مكتمل مرئي لمدة ثانيتين، ويعرض الأخطاء لمدة 5 ثوانٍ. تجدر الإشارة إلى أن التحكم ونقل الملفات يستخدمان عميلي Dio مستقلين لنفس الكاميرا، لذا يمكن أن يستمر التنزيل بينما يستمر استقصاء الحالة والبقاء نشطًا.
7. مشكلتان خفيتان غير موجودتين في أي برنامج تعليمي
يتم حظر HTTP بالنص الواضح افتراضيًا على Android الحديث
http://10.5.5.9:8080 هو نص واضح، ويحظر Android 9+ حركة مرور النص الواضح بشكل افتراضي. يجب عليك السماح به صراحةً:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
بالإضافة إلى ملف network_security_config.xml الذي يسمح إعداداته الأساسية بالنص الواضح (لا يزال بإمكانك فرض HTTPS لنطاقاتك السحابية/الخلفية الخاصة بك). إذا فاتك هذا، فإن كل مكالمة GoPro ستفشل مع خطأ اتصال مربك.
ربط الشبكة المفقود — النقطة الهشة في التصميم
هذا هو الجزء الصادق. على أجهزة Android الحديثة، عندما تنضم إلى نقطة وصول GoPro، يحتفظ الهاتف عادةً بالشبكة الخلوية كشبكة افتراضية لأن نقطة وصول GoPro ليس لديها إنترنت. وهذا يعني أن طلب HTTP إلى 10.5.5.9 يمكن أن يتم توجيهه عبر الشبكة الخلوية ويفشل — حتى لو كنت "متصلاً" بالكاميرا.
الحل القوي هو الأصلي: استدعاء ConnectivityManager.bindProcessToNetwork(goProNetwork) قبل عمليات النقل وbindProcessToNetwork(null) بعدها. مسار GoPro النقي بـ Dart ليس له مكافئ — ويميل إلى العمل على أي حال لأن نظام التشغيل يوجه عادةً عنوان 10.5.5.9 الثابت إلى نقطة الوصول. ولكنه الجزء الأكثر هشاشة في البنية، وعلى بعض الأجهزة/إصدارات Android هو أول ما يتعطل. إذا قمت بنشر هذا، أضف ربط الشبكة — إنه المكان الوحيد الذي يكلفك فيه البقاء 100% Dart الموثوقية.
الدروس المستفادة
- لا تحتاج إلى BLE. إذا كان بإمكان المستخدم الانضمام إلى نقطة الوصول (AP)، فإن واجهة برمجة تطبيقات Open GoPro HTTP تمنحك تحكمًا كاملاً وعمليات نقل سريعة باستخدام Dart النقي.
- اكتشف بواسطة فحص TCP، وليس مسح SSID — لا يوجد إذن موقع، ولا كود خاص بالنظام الأساسي.
- البقاء نشطًا كل 30 ثانية وإلا فإن الجلسة ستموت. اعتبر البقاء نشطًا الفاشل إشارة قطع الاتصال.
- دع الكاميرا تصححك. حلقة الشفاء الذاتي 403-available_options تتفوق على نمذجة مصفوفة إعدادات GoPro بأكملها.
- تبديل الوضع هو آلة حالات — قم بتسلسله، أوقف التسجيل أولاً، احترم تأخيرات انتقال العدسة، وثق بالحالة المقصودة على الحالة المستقصاة.
- الملفات المصاحبة وخصائص .360 الغريبة هي حيث تكمن المشاكل الحقيقية — إعادة تسمية LRV، عدم وجود نقطة نهاية للصور المصغرة، قص ثنائي عين السمكة.
- مشكلتان خفيتان في البنية التحتية ستفشلانك بصمت: إعداد حركة مرور النص الواضح، و(خاصة) ربط العملية بشبكة الكاميرا حتى لا تتسرب الطلبات عبر الشبكة الخلوية.
المكافأة: هاتف يتصل بـ GoPro عبر WiFi، ويدير كل التحكم، ويبث جيجابايت من اللقطات منه مع تقدم مباشر — وكل ذلك تقريبًا هو مجرد Dart منظم جيدًا يتحدث إلى http://10.5.5.9:8080.

