OmArm Zero: بناء ذراع روبوتية بست درجات حرية 6-DOF (5+1 للقابض) مع تحكم عبر الويب باستخدام ESP32

يحتوي هذا المقال على روابط تسويق بالعمولة مُعلَّمة بعلامة *. بصفتي شريكًا في برنامج Amazon Associates، أربح من عمليات الشراء المؤهلة، دون أي تكلفة إضافية عليك. يساعد ذلك على إبقاء الشروحات مجانية.

OmArm Zero ذراع روبوتية مطبوعة بالطباعة ثلاثية الأبعاد (3D) وتعمل بـ ESP32: خمسة مفاصل للذراع إضافة إلى قابض، ويُتحكَّم فيها من المتصفح عبر واي فاي (WiFi).

سلسلة OmArm Zero

الجزء 1: البناء (هذا المقال) · الجزء 2: URDF + RViz + Gazebo · الجزء 3: MoveIt 2 · الجزء 4: الالتقاط والوضع بالرؤية الحاسوبية

The assembled OmArm Zero robotic arm, front and side view, with white printed links and orange base and gripper
The assembled OmArm Zero together with its web interface

يُحمَّل الفيديو من YouTube (Google)، وقد يضع ملفات تعريف الارتباط (Cookies). سياسة الخصوصية

نظرة سريعة

ما ستبنيه: ذراع روبوتية بست درجات حرية (DOF، أي محاور حركة مستقلة): خمسة مفاصل للذراع إضافة إلى قابض بفكين متوازيين، ولكل منها محرك سيرفو واحد. تتحكم فيها عبر واي فاي (WiFi) من هاتفك أو حاسوبك المحمول. مدى الوصول 303 mm، والحمولة 100 g (وبحد أقصى 150 g مع نابض الكتف)، وجميع الأجزاء الهيكلية مطبوعة بالطباعة ثلاثية الأبعاد (3D)، ومزود طاقة واحد بجهد 5 V.

الوقت اللازم: نحو 12 ساعة من الطباعة، إضافة إلى ما بين 8 و12 ساعة من التجميع والاختبار. التجميع الميكانيكي وحده يستغرق نحو ساعتين إلى ثلاث ساعات إذا عملت على مهل.

التكلفة الإجمالية: نحو $48 ثمنًا للقطع، بما في ذلك خيط الطباعة (Filament).

مستوى الصعوبة: متوسط. ستجمّع وصلات ميكانيكية، وتوصّل الإلكترونيات (مع بعض اللحام)، وترفع البرنامج الثابت (Firmware) إلى اللوحة. الخبرة في Arduino مفيدة لكنها ليست شرطًا.

الأدوات: طقم مفاتيح ألن (Allen)، ومفك براغي صليبي صغير (Phillips)، وقاطعة أسلاك، وكاوية لحام (اختيارية لكنها موصى بها)، وقدمة رقمية (Caliper) لفحص التفاوتات، ولقمة مفتاح (Socket) مقاس 3.8 mm أو 4 mm لأذرع السيرفو. ذراع السيرفو (Horn) هو الذراع الصغير أو القرص الذي يُثبَّت بالبراغي على عمود الخرج في السيرفو.

الملفات: حزمة الجزء 1 التي تضم ملفات STL (ملفات طباعة ثلاثية الأبعاد)، ونموذج STEP (تجميعة CAD بصيغة تفتحها معظم برامج CAD)، والبرنامج الثابت (الرابط في نهاية المقال).

1. لماذا نبني ذراعًا روبوتية من الصفر؟

كانت ذراعي السابقة ذراع روبوتية بست درجات حرية (6-DOF) بتحكم عبر البلوتوث مبنية حول Arduino Uno ووحدة البلوتوث HC-05. كانت تعمل، لكن مدى البلوتوث كان محدودًا، وكانت تحتاج إلى لوحتين لأداء ما تستطيعه شريحة واحدة، فبدأت من الصفر. سعر ESP32 هو $5، وفيه واي فاي (Wi-Fi) مدمج، لذا يحل محل Uno وHC-05 معًا.

وكانت النتيجة OmArm Zero: خمسة مفاصل للذراع إضافة إلى قابض، ووزن 535 g، ومدى وصول 303 mm، ونحو $48 ثمنًا للقطع. وعلى خلاف الأذرع التي تُباع عُدّةً جاهزة (Kit)، يعرض هذا الدليل الأرقام التي يقوم عليها كل قرار تصميمي، بدءًا بحساب عزم الدوران الذي يحدد أي سيرفو يوضع في أي مفصل.

تعمل الذراع بلوحة ESP32 المزودة بواي فاي مدمج. تُفتح واجهة الويب الخاصة بها في متصفح هاتفك أو حاسوبك المحمول، وهي مخزنة في ذاكرة الفلاش داخل الشريحة، لذلك لا تحتاج الذراع إلى موجّه (Router) ولا إلى خدمة سحابية. ويتوفر أيضًا التحكم التسلسلي عبر USB، للاختبار وللأجزاء اللاحقة الخاصة بـ ROS 2 (Robot Operating System 2، وهو إطار برمجي للتحكم في الروبوتات).

خططت لـ ROS 2 منذ البداية: البرنامج الثابت التسلسلي يستقبل زوايا المفاصل أصلًا، لذا فإن ربط ROS 2 في الجزء 3 مجرد طبقة رقيقة فوقه. في الجزء 3 يُرفع إلى اللوحة برنامج ثابت تسلسلي خاص بـ ROS 2 (omarm_zero_ros2_serial.ino)، يستقبل أمر MOVE نفسه (القسم 7).

2. متطلبات النظام والمواصفات الهندسية

قبل أن أرسم أول قطعة في CAD، دوّنت ما يجب أن تفعله الذراع.

المتطلبات الوظيفية

  • مدى الوصول: 303 mm من محور الكتف إلى أطراف الفكين.
  • الحمولة: 100 g في التشغيل العادي، و150 g كحد أقصى مع مساعدة النابض عند الكتف.
  • درجات الحرية: خمسة مفاصل للذراع إضافة إلى قابض: دوران القاعدة، ورفع الكتف، والمرفق، ودوران الساعد (J4)، وإمالة المعصم (J5)، وقابض بفكين متوازيين.
  • التحكم: واجهة ويب عبر واي فاي للاستخدام اليومي، وبروتوكول تسلسلي عبر USB للاختبار ولـ ROS 2.
  • الحركة: حركة سلسة بلا تذبذب، مع سرعة قابلة للضبط من بطيئة جدًا إلى سريعة.
  • القابض: فكّان متوازيان يمسكان جسمًا وزنه 100 g دون أن ينزلق.

المتطلبات غير الوظيفية

  • التكلفة: نحو $48 إجمالًا، وأقل من $5 لكل سيرفو.
  • قابلية الطباعة: كل قطعة تناسب سرير طباعة مقاسه 220 × 220 mm وتُطبع بخيط PLA القياسي. أطبع على Bambu Lab X1 Carbon؛ وتكفي لوحتا طباعة لجميع القطع.
  • التجميع: لا حاجة إلى أدوات غير التي يملكها أي صانع (Maker) أصلًا؛ وكل جزء هيكلي مطبوع بالطباعة ثلاثية الأبعاد (3D).
  • الطاقة: مزود طاقة واحد بجهد 5 V، دون منظمات جهد إضافية.
  • وقت البناء: ما بين 8 و12 ساعة من التجميع والاختبار (دون احتساب الطباعة) لمن سبق له اللحام.

البنية الحركية

للذراع خمسة مفاصل دوّارة (مفاصل تدور حول محور واحد) إضافة إلى القابض:

  • المفصل 1 (القاعدة): يدير الذراع بالكامل حول المحور الرأسي، بمدى حركة 180 درجة. ولأن هذا المحور موازٍ لاتجاه الجاذبية، لا يولّد وزن الذراع أي عزم دوران حوله؛ فالسيرفو يتغلب على الاحتكاك والقصور الذاتي فقط.
  • المفصل 2 (الكتف): يرفع العضد (الجزء العلوي من الذراع) من الوضع الأفقي إلى الرأسي. تدور الذراع كلها حول هذه النقطة وتشدّ الجاذبية على طولها الكامل، لذا يحتاج هذا المفصل إلى أقوى سيرفو.
  • المفصل 3 (المرفق): يثني الساعد. ويتحمل حملًا أقل من الكتف لأن العضد يقع خلفه.
  • المفصل 4 (دوران الساعد): يدير المعصم والقابض حول محور الساعد. حمل خفيف.
  • المفصل 5 (إمالة المعصم): يُميل القابض إلى الأعلى والأسفل. حمل خفيف.
  • القابض: آلية بفكين متوازيين يحركها سيرفو صغير؛ ينغلق الإصبعان بشكل متماثل.

لماذا خمسة مفاصل للذراع وليس ستة؟ المفصل السادس سيضيف سيرفو آخر، ووزنًا أكبر في نهاية الذراع، وحملًا أكبر على الكتف. خمسة مفاصل تكفي لتحديد موضع القابض وضبط إمالته ودورانه، وهذا يغطي معظم مهام الالتقاط والوضع (Pick and Place). أما ما تتنازل عنه فهو حرية اختيار اتجاه القابض عند كل نقطة.

معاملات Denavit-Hartenberg

جديد في عالم الروبوتات؟ يمكنك تخطي هذا القسم. معاملات Denavit-Hartenberg (DH) هي أربعة أرقام لكل مفصل تصف موضع محور كل مفصل بالنسبة إلى المفصل السابق. لا يستخدمها الجزء 2: إذ يصف ملف URDF الهندسة نفسها بنقاط أصل المفاصل ومحاورها المصدَّرة من CAD.

يستخدم الجدول اصطلاح DH المعدَّل كما وصفه Craig. قيمه تقريبية لهندسة URDF؛ فالساعد مثلًا يبلغ طوله 98.2 mm بين محوري المفصلين في URDF.

المفصلإزاحة Theta (درجة)d (mm)a (mm)Alpha (درجة)ملاحظات
1050.8700ارتفاع القاعدة، محور رأسي
20023.14-90الكتف، ينثني إلى الأسفل
300113.110المرفق، يمتد إلى الأمام
49098.500-90دوران الساعد
5032.05090إمالة المعصم

في كل صف، a هو طول الوصلة، وalpha زاوية الالتواء من محور المفصل السابق إلى محور هذا المفصل. وتمتد الإزاحة d على طول محور هذا المفصل، أما إزاحة theta فهي موضع الصفر المعتمد في الأوامر.

تحليل عزم الدوران

عزم الدوران الاسمي للسيرفو لا يعني الكثير ما لم تقارنه بالحمل. رسمت لكل مفصل مخطط الجسم الحر (رسم تخطيطي لكل القوى المؤثرة في الجزء) وحسبت عزم الدوران الساكن بهذه المعادلة.

tau_required = m_load * g * L_from_pivot * cos(theta)

m_load هي كتلة كل ما يقع بعد المفصل، وL_from_pivot المسافة من نقطة الارتكاز إلى مركز تلك الكتلة، وg تسارع الجاذبية، وtheta زاوية المفصل. يبلغ عزم الدوران أقصاه عند theta = 0، أي عندما تكون الذراع أفقية.

المفصل 1 (دوران القاعدة): لا يوجد عزم ناتج عن الجاذبية، بل احتكاك وقصور ذاتي فقط. محرك MG996R أقوى مما يحتاجه هذا المفصل، لكنني أستخدمه رغم ذلك ليتوافق مع J2 وJ3. وهو يكلّف نحو $2 أكثر من SG90 ($4 مقابل $2 في BOM، أي قائمة المواد في القسم 4).

المفصل 2 (الكتف): كل ما يتحرك فوق القاعدة يزن 326 g وفق نموذج CAD. ولتقدير بسيط ومتحفظ، أحتسب ذلك كله مع الحمولة وأضع المجموع على مسافة متوسطة تبلغ نحو 180 mm من نقطة الارتكاز. ومع حمولة 150 g يصبح المجموع 476 g:

tau_required = 0.476 kg * 9.81 m/s^2 * 0.180 m * cos(0) = 0.84 N·m ≈ 8.6 kgf·cm

مع الحمولة الاسمية 100 g يحتاج الكتف إلى نحو 7.7 kgf·cm. أما الحساب مفصلًا بمفصل باستخدام كتل CAD، مع وضع الحمولة عند أطراف الفكين على بعد 303 mm من الكتف، فيعطي 8.0 kgf·cm عند 150 g. أي أن التقدير البسيط يبقى في جانب الأمان.

عزم MG996R الاسمي 11 kgf·cm عند 6 V. وعند 5 V، وهو الجهد الذي تعمل به الذراع، يعطي نحو 8.8 kgf·cm. وهذا يعني معامل أمان (عزم الدوران المتاح مقسومًا على عزم الدوران المطلوب) قدره 1.02 عند ذروة الحمل. ولزيادة الهامش أضفت عند الكتف نابض شدّ يسحب العضد إلى الأعلى (التفاصيل في القسم 3).

المفصل 3 (المرفق): يحمل الساعد والمعصم والقابض، وتزن معًا 132 g وفق نموذج CAD. ووزنها وحده يتطلب 0.13 N·m (أي 1.3 kgf·cm). وتتدلى الحمولة عند أطراف الفكين على بعد نحو 190 mm من المرفق. ومع حمولة 150 g:

tau_required = 0.13 N·m + 0.150 kg * 9.81 m/s^2 * 0.190 m = 0.41 N·m ≈ 4.2 kgf·cm

عند 5 V يوفر MG996R نحو 8.8 kgf·cm. معامل الأمان: 2.1.

المفصل 4 (دوران الساعد): يقع وزن القابض قريبًا من محور الدوران، لذا لا يكاد يوجد عزم ناتج عن الجاذبية. ويكفي هنا SG90.

المفصل 5 (إمالة المعصم): يحمل القابض، وكتلته 50 g على بعد 20 mm، إضافة إلى الحمولة عند أطراف الفكين على بعد نحو 60 mm من محور J5. ومع حمولة 150 g:

tau_required = 0.0098 N·m + 0.150 kg * 9.81 m/s^2 * 0.060 m = 0.098 N·m ≈ 1.0 kgf·cm

يوفر سيرفو SG90 الصغير (Micro Servo) عزمًا قدره 1.8 kgf·cm. معامل الأمان: 1.8، وهو كافٍ عند 100 g وضيّق عند 150 g.

الخلاصة:

المفصلالحمل (g)المسافة (mm)المطلوب (kgf·cm)Servoالمتاح (kgf·cm)معامل الأمانالقيد
147600MG996R8.8لا ينطبق (لا يوجد حمل جاذبية)لا يوجد
24761808.6MG996R8.81.02حرج
3282حتى 1904.2MG996R8.82.1آمن
4500~0SG901.8لا ينطبق (لا يوجد حمل جاذبية)آمن
5200حتى 601.0SG901.81.8ضيّق عند 150 g

المقصود بعزم الدوران المتاح قيمته عند 5 V، وهي 8.8 kgf·cm لمحرك MG996R، و1.8 kgf·cm لمحرك SG90 الذي تذكر ورقة بياناته هذه القيمة عند 4.8 V.

المفصل 2 هو ما يحدّ من قدرة الذراع كلها: فعند حمولة 150 g يحتاج إلى 8.6 من أصل 8.8 kgf·cm المتاحة عند 5 V. ويضيف النابض هامشًا قدره نحو 2.9 kgf·cm. ومن دونه يسخن سيرفو الكتف ويبدأ بالانحراف عن موضعه بعد دقيقة أو دقيقتين، لذا سأقصر الحمولة في هذه الحالة على نحو 75 g.

حد الحمولة. عزم دوران الكتف بدلالة الحمولة P (بوحدة kg):

tau_J2 = (0.326 + P) * 9.81 * 0.180 = 0.576 + P*1.766 N·m

بمساواة هذا المقدار بقيمة 8.8 kgf·cm المتاحة عند 5 V وحلّ المعادلة بالنسبة إلى P نحصل على نحو 0.16 kg. وهذا حد أعلى ساكن: فهو يتجاهل التسارع والاحتكاك والخلوص في التروس (Backlash، أي الحركة الحرة الصغيرة بين أسنان التروس) والحرارة، كما أن العزم المستمر لمحرك MG996R عند 5 V أقل من القيمة المذكورة في ورقة البيانات. لذلك اختبرت أوزانًا حقيقية: حمولة 100 g تتحرك بسلاسة دون أي صوت إجهاد مسموع، و150 g تُمسَك بثبات بمساعدة النابض، أما 200 g فتسبب توقف السيرفو (Stall، أي يتوقف عن الحركة رغم تغذيته بالطاقة) عند الزوايا القصوى. ومن هنا جاءت المواصفة: 100 g حمولةً اسمية (طوال اليوم) و150 g حدًّا أقصى (من حين لآخر، مع النابض). وما يتجاوز 150 g يجعل السيرفو يسخن وقد يحترق.

مساحة العمل ومدى الوصول

أطوال الوصلات من ملف URDF: العضد 113.1 mm، والساعد 98.2 mm، والمعصم 32.1 mm، والقابض نحو 60 mm حتى أطراف الفكين. وعند مدّ الذراع أفقيًا، وبالقياس من محور الكتف:

reach = 113.1 + 98.2 + 32.1 + 60 = 303.4 mm ≈ 303 mm

ارتفاع القاعدة البالغ 50.9 mm رأسي، ولا يضيف شيئًا إلى مدى الوصول الأفقي.

الحيز الذي تبلغه الذراع هو تقريبًا نصف كرة نصف قطرها 303 mm فوق الطاولة، ومركزها القاعدة.

ميزانية الطاقة

كل شيء يعمل من مزود طاقة واحد بجهد 5 V، لذا يجب أن يغطي هذا المزود مجموع التيارات كلها.

  • يسحب كل MG996R نحو 500 mA تحت الحمل المعتاد، وحتى 2500 mA عند التوقف (Stall).
  • يسحب كل SG90 نحو 200 mA في الوضع المعتاد، وحتى 650 mA عند التوقف.
  • يسحب ESP32 نحو 80 mA، وحتى 500 mA في ذروات الإرسال عبر واي فاي.
  • تسحب لوحة قيادة السيرفو PCA9685 (القسم 4) نحو 10 mA.

المجموع المعتاد: 500 + 500 + 500 + 200 + 200 + 200 + 80 + 10 = 2190 mA.

مجموع الذروة عند توقف السيرفوهات الستة كلها في آن واحد: 2500 + 2500 + 2500 + 650 + 650 + 650 + 500 + 10 = 9960 mA.

تبقى الذروة النظرية البالغة 9960 mA دون التيار الاسمي البالغ 10 A لمزود الطاقة 5 V 10 A. ولا تحدث إلا إذا توقفت السيرفوهات الستة كلها في آن واحد، وهو ما لا تسببه الحركة العادية. ولو حدث ذلك لانخفض الجهد، وقد يُعاد تشغيل ESP32 (Brown-out: إعادة تشغيل سببها انخفاض جهد التغذية). شغّلت هذه الذراع لساعات بأنماط حركة عادية دون أي مشكلة في التغذية. وبالقياس على ذراعي، تسحب نحو 1.5 A حين تثبت جميع السيرفوهات في مواضعها، ونحو 4 A في الذروات أثناء الحركات السريعة لعدة مفاصل معًا. مزود 5 V 10 A هو الحد الأدنى؛ أما وحدة 15 A فتكلف نحو $3 أكثر وتترك هامشًا أكبر. أوصل مزود الطاقة عبر مقبس أسطواني (Barrel Jack) مع كتلة أطراف لولبية (البند 33)، ومن هناك أمدّ سلكين إلى طرف التغذية في PCA9685. كثير من مقابس 5.5 × 2.1 mm مصنفة لتيار من 3 إلى 5 A فقط، فتحقق من تصنيف مقبسك؛ وللحصول على 10 A كاملة استخدم مزود طاقة بأطراف لولبية.

تنبيه أمان: غذِّ السيرفوهات دائمًا من مزود الطاقة المنفصل 5 V، ولا تغذِّها أبدًا من منفذ USB في ESP32. يوفر منفذ USB بضع مئات من المللي أمبير فقط؛ بينما يسحب محرك MG996R واحد متوقف 2500 mA.

3. التصميم الميكانيكي ونموذج CAD

صممت OmArm Zero في Autodesk Inventor. ومن أجل الجزء 2 استُورد النموذج إلى Fusion 360 وصُدِّر بصيغة URDF (Unified Robot Description Format)، وهي صيغة الملفات التي يصف بها ROS 2 وصلات الروبوت ومفاصله. برنامج Inventor بارامتري: أطوال الوصلات وأبعاد تجاويف السيرفو ومقاسات الثقوب كلها متغيرات، وكل بُعد مرتبط بها يتحدث تلقائيًا عند تغيير أحدها. وهذا يغني عن إعادة رسم القطع عندما يتغير طول الكتف مثلًا.

التصميم معياري (Modular): كل مفصل تجميعة مستقلة، مصممة للطباعة بتقنية FDM (النمذجة بالترسيب المنصهر، أي طابعة الخيوط المعتادة). سماكة معظم الجدران 1.2 mm: ثلاثة محيطات (Perimeters، أي حلقات الحواف الخارجية) تضعها فوهة 0.4 mm. وقطر ثقوب M3 هو 3.2 mm؛ إذ تشق براغي M3 أسنانها بنفسها في PLA (انظر «وسائل التثبيت» في القسم 4).

تجميعة القاعدة

تتكون القاعدة من جزأين: حلقة سفلية ثابتة وقطعة علوية تدور. يستقر MG996R في الجزء السفلي، مثبتًا على رف مقوّى بأربعة براغي No.3-28 برأس عريض مقبب (البند 25). وفي المركز محمل كروي 6806ZZ (30 × 42 × 7 mm). تركب حلقته الداخلية على بروز أسطواني قطره 30 mm (Spigot، أي وتد أسطواني قصير) في base_link. وتستقر حلقته الخارجية في تجويف قطره 42 mm على الجانب السفلي من base_link_upper، ويملأ عرضه البالغ 7 mm ذلك التجويف. يحمل المحمل القاعدة العلوية ويزيل الخلوص (الارتخاء) من دوران القاعدة. ومن دونه لن يحدد موضع القاعدة العلوية سوى ذراع السيرفو، مع خلوص نصف قطري قدره 6 mm من كل الجهات، لذا اعتبر المحمل مكوّنًا إلزاميًا. يُثبَّت ذراع السيرفو بالبراغي على الجزء العلوي، فيتبع الجزء العلوي حركة السيرفو.

قطر القاعدة 85 mm، وهو ما يكفي لوقوف ثابت. وتُطبع على جزأين لتجنب الجسور (Bridging، أي الطباعة في الهواء بين نقطتي ارتكاز) فوق تجويف المحمل.

مفصل الكتف

يتحمل الكتف الحمل الأكبر، لذا نال القدر الأكبر من الاهتمام. يحمل حامل الكتف (Bracket) محرك MG996R بحيث يكون عمود الخرج أفقيًا، فيتأرجح العضد إلى الأعلى والأسفل. ويُثبَّت العضد بالبراغي على ذراع السيرفو.

يسحب نابض شدّ مثبت بين العضد والحامل المفصلَ إلى الأعلى ويعادل نحو نصف وزن الذراع. وعندما يرفع السيرفو الذراع، فإنه يعمل فقط ضد الشد المسبق للنابض إضافة إلى الوزن المتبقي. والنابض هو ما يتيح لمحرك MG996R (8.8 kgf·cm عند 5 V) أن يحمل الذراع بثبات.

يستقر النابض في مجرى على الجانب الداخلي لحامل الكتف. يُعلَّق أحد طرفيه في فتحة على الحامل، والآخر في فتحة على arm_link_1 (العضد). وعندما يكون العضد أفقيًا، وهو أدنى موضع له، يكون النابض في أقصى تمدد له ويولّد نحو 2.9 kgf·cm؛ ومع ارتفاع الذراع يرتخي النابض.

مواصفات النابض: نابض شدّ (وليس نابض ضغط)، قطره الخارجي نحو 4 mm، وسمك سلكه 0.5 mm، وطوله الحر 15 mm، وثابت النابض نحو 0.8 N/mm (القوة اللازمة لكل مليمتر من التمدد). تجده في مجموعات النوابض المتنوعة لدى متاجر العُدد بنحو $1. الثابت الدقيق ليس حاسمًا؛ فأي قيمة بين 0.5 و1.2 N/mm تفي بالغرض. النابض الأكثر صلابة يعني أن السيرفو يبذل جهدًا أقل عند الرفع وأكثر عند الخفض. اختبرت ثلاثة نوابض واستقر اختياري على نابض 0.8 N/mm لأنه بدا متوازنًا في الاتجاهين.

مفصل المرفق

يستخدم المرفق حامل السيرفو نفسه المستخدم في الكتف، لكنه لا يحتاج إلى نابض لأنه يحمل الساعد والمعصم والقابض فقط.

تجميعة المعصم

يتكون المعصم من سيرفوين SG90 متعامدين. الأول (J4) مثبت في نهاية الساعد ويدير حامل المعصم حول محور الساعد. والثاني (J5) مثبت في حامل المعصم ويُميل القابض إلى الأعلى والأسفل. أبعاد كل SG90 هي 23 × 12.2 × 29 mm. ومع سيرفوهات بهذا الصغر، لا يبعد محور J5 عن J4 سوى 32 mm، ما يبقي المعصم قصيرًا وخفيفًا.

تجميع القابض

يحوّل القابض دوران السيرفو إلى حركة إغلاق لإصبعين متوازيين. يدور ترسان مطبوعان في اتجاهين متعاكسين؛ ويحرك كل منهما إصبعًا واحدًا عبر وصلة ميكانيكية. يُثبَّت الترس الأيسر بالبراغي على ذراع السيرفو، بينما يدور الترس الأيمن بحرية على محور ويديره الترس الأيسر. ولأن الإصبعين ينغلقان بشكل متماثل، يبقى الجسم في المنتصف. التقطت به أشياء تتراوح بين أقلام التحديد (Marker) ومحركات التيار المستمر (DC) الصغيرة.

الطباعة ثلاثية الأبعاد (3D): اتجاه الطباعة وقائمة القطع وإعدادات برنامج التقطيع

لكل قطعة اتجاه طباعة محدد يقلل مادة الدعامات (هياكل مؤقتة تُطبع أسفل الأجزاء البارزة) ويجعل خطوط الطبقات تمتد في اتجاه الحمل. تُطبع الذراع العلوية مستلقية بشكل مسطح وبالطول، فتمتد خطوط الطبقات على طول المسافة التي يقع عليها حمل الانحناء؛ ولو طُبعت قائمة لانفصلت الطبقات تحت الحمل. وتُطبع تروس القابض مسطحة أيضًا، لأن الطبقات العمودية تجعل الأسنان تنكسر بالقص.

سُمك الجدران 1.2 mm، إلا في مواضع تركّز الإجهاد حيث يبلغ 2.0 mm. ويحيط بجيب المحمل في base_link_upper جدار بسُمك 5 mm (تجويف قطره 42 mm داخل نتوء قطره 52 mm، وهو الحلقة البارزة حول التجويف) حتى لا يتشقق بفعل التركيب بالضغط. التفاوتات (مثل خلوص 0.2 mm حول كل جيب سيرفو) مدمجة في تصميم CAD، لذا اطبع ملفات STL بمقياس 100 % دون أي تعويض في XY.

تتكون الذراع من 16 قطعة مطبوعة من 12 ملف STL: يُطبع gripper_link أربع مرات، و gripper_sleeve مرتين، وكل ملف آخر مرة واحدة. أرقام البنود تطابق قائمة المواد (BOM) في القسم 4. الاسمان Gear_links و Gear_rechts اسما ملفين بالألمانية: links = يسار، rechts = يمين.

البندملف STLالكميةالوصفاتجاه الطباعة
1base_link.stl1قاعدة ثابتة مع جيب للسيرفو وبروز أسطواني بقطر 30 mm لتركيب المحملمسطّحة، والوجه السفلي للأسفل
2base_link_upper.stl1القاعدة العلوية الدوّارة مع جيب محمل بقطر 42 mm، وتُثبَّت على ذراع السيرفو (Horn)مسطّحة، والوجه السفلي للأسفل
3arm_link_1.stl1الذراع العلوية (من الكتف إلى المرفق)أفقية، مسطّحة بالطول
4arm_link_2.stl1الساعد (من المرفق إلى المعصم)أفقية، مسطّحة بالطول
5arm_link_3.stl1حامل المعصم (يحمل سيرفو J5)قائمة، ووجه القاعدة للأسفل
6gripper_base.stl1هيكل القابض، يُثبَّت على ذراع سيرفو J5مسطّحة، والوجه المفتوح للأعلى
7Gripper_2.stl1إصبع القابض الأيمنمسطّحة
8Gear_rechts.stl1الترس الأيمن، المُقاد، يدور بحرية على محورهمسطّحة، والأسنان للأعلى
9Gear_links.stl1الترس الأيسر، القائد، يُثبَّت بالبراغي على ذراع السيرفومسطّحة، والأسنان للأعلى
10Gripper.stl1إصبع القابض الأيسرمسطّحة
11gripper_link.stl4وصلة ربط القابضمسطّحة
12gripper_sleeve.stl2جلبة القابض (بطانة / فاصل)مسطّحة
الإجمالي16

تكفي لوحتا طباعة بمقاس 220 × 220 mm لكل القطع، لذا تصلح أيضًا الطابعات التي يكون سرير طباعتها أصغر من سرير X1 Carbon. تضم اللوحة 1 القطع الهيكلية (القاعدة ووصلات الذراع)، وتضم اللوحة 2 القابض والقطع الصغيرة.

إعدادات برنامج التقطيع (Bambu Studio):

المعاملالقيمة
المادةPLA (أي علامة تجارية)
الفوهة0.4 mm
ارتفاع الطبقة0.20 mm
ارتفاع الطبقة الأولى0.12 mm
نسبة الملء (Infill)15 % بنمط خطوط مستقيمة (Rectilinear)
عدد حلقات الجدار3 (= جدار بسُمك 1.2 mm)
الطبقات العلوية/السفلية4
نوع الدعاماتدعامات شجرية (تلقائي)
زاوية تفعيل الدعامات45 درجة
الالتصاق بلوحة الطباعةحافة Brim (إطار من طبقة واحدة حول القطعة) على base_link فقط

تستغرق الطباعة نحو 6 ساعات لكل لوحة، أي 12 ساعة إجمالًا، وتستهلك قرابة 200 g من خيط الطباعة (Filament). تصلح هذه الإعدادات لأي طابعة FDM بفوهة 0.4 mm. فعّل الدعامات الشجرية لوصلات الذراع، لأن في جيوب السيرفو أجزاء بارزة بزاوية تقارب 50 درجة.

بعد الطباعة، أزل مادة الدعامات بقاطعة مستوية (Flush Cutter) وافحص كل جيب محمل وجيب سيرفو بالقدمة (Caliper). يجب أن يبلغ قطر البروز الأسطواني للمحمل أعلى base_link مقدار 30.0 mm (بتفاوت -0.1 mm)، وقطر جيب المحمل في الجهة السفلية من base_link_upper مقدار 42.0 mm (+0.1 mm). ويجب أن يدخل جسم MG996R (40.7 × 19.7 mm) في جيوب السيرفو بخلوص يقارب 0.2 mm من كل جانب.

يفتح ملف مشروع Bambu Studio (omarm_zero.3mf) الموجود في حزمة الجزء 1 اللوحتين وعليهما كل القطع في مواضعها.

All 16 printed PLA parts laid out on the table before assembly
Printed parts in two colors: white arm links, orange base and gripper parts

4. قائمة المواد واختيار المكونات

يُظهر الرسم الهندسي كل قطعة مع رقم بندها؛ ويستخدم جدول قائمة المواد (BOM) الأرقام نفسها.

CAD technical drawing of OmArm Zero with all parts labeled and numbered

ملفات STL ونموذج STEP ودليل التجميع بصيغة PDF موجودة في حزمة OmArm Zero الجزء 1 (الرابط في النهاية). البرنامج الثابت (Firmware) متاح مجانًا على GitHub.

قائمة القطع الكاملة

البندالكميةالجزءالوصفالمصدرالسعر
11base_linkمنصة القاعدة مع جيب للسيرفو وبروز أسطواني بقطر 30 mm للمحملطباعة ثلاثية الأبعاد (3D)
21base_link_upperالقاعدة العلوية الدوّارة مع جيب محمل بقطر 42 mm، وتُثبَّت على ذراع السيرفو (Horn)طباعة ثلاثية الأبعاد (3D)
31arm_link_1الذراع العلوية (من الكتف إلى المرفق)طباعة ثلاثية الأبعاد (3D)
41arm_link_2الساعد (من المرفق إلى المعصم)طباعة ثلاثية الأبعاد (3D)
51arm_link_3حامل المعصم (يحمل سيرفو J5)طباعة ثلاثية الأبعاد (3D)
61gripper_baseهيكل القابض، يُثبَّت على ذراع سيرفو J5طباعة ثلاثية الأبعاد (3D)
71Gripper_2إصبع القابض الأيمنطباعة ثلاثية الأبعاد (3D)
81Gear_rechtsترس القابض الأيمن، المُقادطباعة ثلاثية الأبعاد (3D)
91Gear_linksترس القابض الأيسر، القائدطباعة ثلاثية الأبعاد (3D)
101القابضإصبع القابض الأيسرطباعة ثلاثية الأبعاد (3D)
114gripper_linkوصلة ربط القابضطباعة ثلاثية الأبعاد (3D)
122gripper_sleeveجلبة القابض / فاصلطباعة ثلاثية الأبعاد (3D)
133سيرفو MG996Rتروس معدنية، 11 kgf·cm عند 6 V، رقمي، J1، J2، J3شراء$12 (3 × $4)
143سيرفو مصغّر SG90تروس بلاستيكية، 1.8 kgf·cm، تناظري، J4، J5، القابضشراء$6 (3 × $2)
1514ANSI B18.6.4 No.2-32 3/8″برغي برأس عريض (Truss)، مقاس أمريكي #2، بـ 32 سنًّا لكل إنش، بطول 3/8 إنششراءضمن طقم البراغي
162ANSI B18.6.4 No.2-32 1/4″برغي برأس عريض No.2-32، بطول 1/4 إنششراءضمن طقم البراغي
173DIN 7985 M1.6×2-Zبرغي M1.6 × 2 برأس صليبيشراءضمن طقم البراغي
187ISO 4762 M3x16 (AS 1420)برغي M3 × 16 برأس سداسي داخليشراءضمن طقم البراغي
1912ISO 4032 M3 (AS 1112)صامولة سداسية M3شراءضمن طقم البراغي
202ANSI B18.6.4 No.2-32 1/2″برغي برأس عريض No.2-32، بطول 1/2 إنششراءضمن طقم البراغي
213ذراع سيرفو MG955 (Horn)ذراع سيرفو (Horn) لمحرك MG996R (الذراع المرفقة مع السيرفو)مرفق مع السيرفو
223DIN 7985 M3x6-Zبرغي M3 × 6 برأس صليبيشراءضمن طقم البراغي
233ذراع سيرفو SG90ذراع سيرفو لمحرك SG90مرفق مع السيرفو
242ANSI B18.6.4 No.2-32 3/8″برغي برأس عريض No.2-32، بطول 3/8 إنششراءضمن طقم البراغي
2512ANSI B18.6.4 No.3-28 1/2″برغي برأس عريض No.3-28، بطول 1/2 إنششراءضمن طقم البراغي
261محمل كروي 6806ZZ (30 × 42 × 7)محمل كروي ذو أخدود عميق لدوران القاعدة، قطر داخلي 30 mm، قطر خارجي 42 mm، عرض 7 mm (وليس 686ZZ)شراء$2
271ESP32 DevKitوحدة WROOM بـ 38 طرفًا، ذاكرة فلاش 4 MB، واي فاي (Wi-Fi) مدمجشراء$5
281لوحة قيادة السيرفو PCA9685لوحة قيادة سيرفو بـ 16 قناةشراء$2
291نابض شدقطر خارجي 4 mm، سلك 0.5 mm، طول حر 15 mm، نحو 0.8 N/mm، لمساعدة مفصل الكتف J2شراء$1
301مزود طاقة 5 V 10 Aتغذية السيرفوهات، مقبس أسطواني 5.5 × 2.1 mmشراء$8
311أسلاك توصيل Jumper (ذكر-ذكر)من ESP32 إلى PCA9685شراء$2
321أسلاك توصيل Jumper (ذكر-أنثى)التوصيلات العامةشراءمع البند 31
332موصل مقبس أسطواني (Barrel Jack)5.5 × 2.1 mm، لوحة توصيل للطاقة (Breakout)شراء$1
341قصدير اللحام، مادة الصهر (Flux)، أنابيب انكماش حراري60/40 أو خالٍ من الرصاص، أنواع متنوعةشراء$3
35200 gخيط طباعة PLAأي لونشراء$3
طقم البراغي (البنود 15-25)$2
الإجمالي (القطع المشتراة)~$47.50

البنود 1-12 قطع مطبوعة (16 قطعة من 12 ملف STL)، والبندان 13-14 هما السيرفوهات. البنود 15-25 وسائل تثبيت وأذرع سيرفو: يغطي طقم براغي متنوع واحد كل البراغي، وتأتي الأذرع مع السيرفوهات. البنود 26-35 إلكترونيات وقطع معدنية ومواد استهلاكية.

اختيار محركات السيرفو

MG996R هو السيرفو الرئيسي. تروسه المعدنية لا تنكسر أسنانها تحت الحمل. عزم دورانه المقنن 11 kgf·cm عند 6 V، وهذا بالكاد يكفي الكتف عند 5 V (القسم 2). ويُباع على أنه سيرفو رقمي: أي إن متحكمًا صغيرًا بداخله يقود المحرك بتردد أعلى من السيرفو التناظري، فيثبت على موضعه بقوة أكبر. ويستقبل النوعان إشارة التحكم نفسها بتردد 50 Hz. وبسعر يقارب $4 للقطعة، هو أرخص سيرفو بتروس معدنية وجدته. تتفاوت الجودة بين البائعين، لذا اختبر كل سيرفو باستخدام البرنامج الثابت ذي الأوامر التسلسلية قبل تركيبه.

لماذا لا نستخدم MG996R في كل المفاصل؟ بسبب الوزن والطاقة. يزن كل MG996R مقدار 55 g؛ أي إن الثلاثة تشكل 165 g من وزن الذراع البالغ 535 g. ولو وُضعت سيرفوهات MG996R في المعصم (J4، J5) لأضافت 110 g أخرى عند طرف الذراع. وعلى بعد 180 mm من محور دوران الكتف، يرفع ذلك عزم الدوران المطلوب من J2 بمقدار:

0.110 kg * 9.81 m/s^2 * 0.180 m = 0.194 N·m ≈ 2.0 kgf·cm of additional load

ولأن الكتف يعمل أصلًا عند حده الأقصى، فإن 2 kgf·cm إضافية ستجعله غير موثوق.

يزن سيرفو SG90 المصغّر 9 g؛ وثلاثة منها تزن 27 g بدلًا من 165 g. ويكفي عزمه البالغ 1.8 kgf·cm لإمالة المعصم (1.0 kgf·cm مع حمولة 150 g) ولتدوير الساعد (قريب من الصفر). لكن في المقابل، سيرفوهات SG90 تناظرية وقد ترتجف قليلًا حول الموضع المستهدف.

اختيار ESP32

ESP32 متحكم دقيق ثنائي النواة بمعمارية 32 بت وتردد ساعة 240 MHz، مع ذاكرة فلاش 4 MB وذاكرة RAM بسعة 520 KB، وواي فاي (Wi-Fi) وبلوتوث مدمجين، و16 قناة PWM. وPWM (تعديل عرض النبضة) هي إشارة النبضات التي تحدد زاوية السيرفو؛ ويولّدها ESP32 في عتاد LEDC (متحكم LED) الخاص به. سعره $5. وكانت البدائل لوحة Arduino Mega (بلا واي فاي، وتحتاج إلى وحدة لاسلكية خارجية)، ولوحة Arduino MKR WiFi 1010 (نحو $30)، ولوحة STM32 Blue Pill (بلا واي فاي).

يلبّي ESP32 كل ما أحتاجه: واي فاي دون وحدة لاسلكية منفصلة بسعر $15، وذاكرة RAM كافية لخادم ويب ولتحليل JSON (JavaScript Object Notation، وهي صيغة نصية للبيانات المهيكلة). كان بإمكان قنوات PWM الـ 16 فيه أن تقود السيرفوهات مباشرة، لكنني أستخدم بدلًا من ذلك لوحة قيادة السيرفو PCA9685 (القسم التالي).

لوحة قيادة السيرفو PCA9685

PCA9685 لوحة قيادة سيرفو. تتواصل مع ESP32 عبر I2C، وهو ناقل بسلكين (SDA ينقل البيانات و SCL ينقل إشارة الساعة)، وتولّد إشارات PWM بنفسها. وفي حالة السيرفو، تكون إشارة PWM نبضة كل 20 ms، وعرضها، وهو هنا من 0.6 إلى 2.4 ms، يحدد الزاوية.

أستخدمها لأنها تقود حتى 16 سيرفو عبر سلكَي I2C، وفيها أطراف توصيل للسيرفوهات مع خط تغذية V+ منفصل. وبذلك تبقى أطراف ESP32 حرة، ولا يمر تيار السيرفوهات عبر ESP32 أبدًا. سعر اللوحة $2، وتوفر Adafruit لها مكتبة مُختبرة تتولى صيانتها، ولا يحتاج ناقل I2C إلا إلى GPIO21 و GPIO22 على ESP32.

وسائل التثبيت والإضافات

يجمع التصميم بين وسائل تثبيت مترية وأخرى بالإنش. تثبّت براغي No.2-32 و No.3-28 ذات الرأس العريض (البنود 15، 16، 20، 24، 25) السيرفوهات في حواملها. وتشكّل براغي M3 × 16 ذات الرأس السداسي الداخلي (البند 18) مع صواميل M3 (البند 19) محاور وصلات القابض. وتثبّت براغي M3 × 6 ذات الرأس الصليبي (البند 22) أذرع السيرفو على الوصلات. وتثبّت براغي M1.6 × 2 (البند 17) سيرفوهات SG90. ويغطي طقم براغي متنوع واحد جميع المقاسات، مع قطع احتياطية.

لا حاجة إلى الحشوات الملولبة الحرارية (جلب نحاسية ملولبة تُصهر داخل البلاستيك): فبراغي M3 تُربط مباشرة في الثقوب المطبوعة، وتتشكل اللولبة في PLA بشكل نظيف دون ثقب مسبق. وإن كنت تنوي فك الذراع كثيرًا، فالحشوات تجعل اللولبة أكثر تحملًا.

اطلب المحمل الصحيح: البند 26 هو 6806ZZ (30 × 42 × 7 mm). أما 686ZZ (6 × 13 × 5 mm) فهو محمل مختلف لا يناسب أيًّا من القطعتين.

تصحيح (17 سبتمبر 2026): كانت النسخ السابقة من هذا المقال ودليل PDF وملف README على GitHub وفيديو الجزء 1 تذكر محملًا “686ZZ (6x13x5)” وجيبًا مقاسه “13.0mm”. كان ذلك خطأ نقل من جهتي: فتصميم CAD استخدم دائمًا المحمل 6806ZZ (30 × 42 × 7 mm). إن كنت قد طلبت 686ZZ من القائمة القديمة فلن يناسب، وأعتذر عن هذا الطلب الذي ذهب هدرًا. والشكر للقارئ الذي قاس قطعه المطبوعة واكتشف الخطأ.

5. دليل التجميع

قبل أن تبدأ: يجب أن تكون القطع الـ 16 كلها مطبوعة ومفحوصة بالقدمة، والسيرفوهات ووسائل التثبيت في متناول يدك. ترقيم المفاصل من J1 إلى J5 والقابض يتبع الترتيب الحركي في القسم 2؛ وفي الأوامر التسلسلية يحمل القابض رقم المفصل 6.

الخطوة 0: حرّك كل سيرفو إلى 0°، وهو موضعه الأساسي (نفّذ هذا أولًا)

تحتاج الخطوة 0 إلى الإلكترونيات من القسم 6 والبرنامج الثابت ذي الأوامر التسلسلية من القسم 7. وصّل السيرفوهات الستة بلوحة PCA9685 على طاولة العمل (دون الأذرع)، وحمّل OmArmZeroControl.ino على اللوحة، وافتح الشاشة التسلسلية (Serial Monitor) بسرعة 115200 baud (معدل الباود: سرعة النقل التسلسلي). عند بدء التشغيل يحرّك البرنامج الثابت كل سيرفو إلى 0°، وهو موضعه الأساسي؛ أرسل HOME لتكرار ذلك:

HOME

موضع 0° هذا هو المرجع لكل خطوات التجميع. لماذا قبل أي برغي؟ يعرف السيرفو زاوية عموده، لكنه لا يعرف كيف تجلس الذراع (Horn) على العمود المحزّز (عمود الخرج ذو الأسنان الدقيقة التي تمسك الذراع). إذا ثبّتَّ ذراعًا على وصلة وهي منحرفة بمقدار 45 درجة، فسيكون كل أمر زاوية بعد ذلك خاطئًا بمقدار 45 درجة.

اترك الأذرع منزوعة وانتظر حتى يتوقف كل سيرفو. لا تُدِر سيرفو موصولًا بالطاقة بيدك: فقد تنكسر أسنان تروسه. تُركَّب الأذرع أثناء التجميع: ثبّت كل ذراع على قطعتها أولًا، ثم ادفع القطعة مع الذراع على العمود بحيث تتجه القطعة في الاتجاه الذي تصفه الخطوة.

الخطوة 1: أدخل سيرفو القاعدة (J1) في base_link

ابدأ بـ base_link، وهي القطعة السفلية الثابتة. أدخل أول MG996R في جيب السيرفو المستطيل من الأعلى، وعمود الخرج متجه إلى الأعلى. يجب أن يدخل بإحكام. ثبّته بأربعة براغي No.3-28 بطول 1/2″ ذات رأس عريض (البند 25) عبر ألسنة تثبيت السيرفو. لا تبالغ في الشد: يكفي أن يكون محكمًا.

MG996R servo being inserted into the servo pocket of base_link

الخطوة 2: ركّب المحمل

اضغط المحمل 6806ZZ (البند 26) على البروز الأسطواني بقطر 30 mm في مركز base_link، بادئًا بالحلقة الداخلية. التركيب محكم عن قصد. إن لم يستقر في مكانه، فصنفِر البروز برفق بورق صنفرة بدرجة 120 ثم أعد المحاولة. يجب أن يدخل بضغط قوي من اليد وأن يستقر مستويًا على الدرجة عند قاعدة البروز.

Pressing the 6806ZZ bearing onto the spigot of base_link

الخطوة 3: ثبّت ذراع السيرفو على base_link_upper

خذ base_link_upper، وهي القطعة العلوية الدوّارة. ثبّت ذراع MG996R الصليبية الشكل (البند 21) في جهتها السفلية ببرغي M3 × 6 ذي رأس صليبي (البند 22)، في المنتصف تمامًا. تربط هذه الذراع القاعدة العلوية بعمود السيرفو.

Attaching the servo horn to the underside of base_link_upper

الخطوة 4: ركّب base_link_upper على base_link

أنزل base_link_upper فوق المحمل بحيث يستقر جيبها بقطر 42 mm فوق الحلقة الخارجية، وتتعشّق الذراع مع حزوز عمود السيرفو المتمركز. إن لم يدخل الجيب فوق الحلقة الخارجية، فصنفِر داخل الجيب برفق. ثبّت الذراع ببرغي المركز الخاص بالسيرفو (المرفق معه). أدر القاعدة العلوية بيدك: يجب أن تدور بسلاسة دون أن تعلق.

base_link_upper mounted on base_link over the bearing

الخطوة 5: ركّب سيرفو الكتف (J2)

أدخل MG996R الثاني في حامل الكتف على base_link_upper، وعمود الخرج أفقي ومتجه إلى الجانب. ثبّته بأربعة براغي No.3-28 بطول 1/2″ ذات رأس عريض (البند 25).

Shoulder servo mounted in the bracket on base_link_upper

الخطوة 6: ثبّت ذراع السيرفو على arm_link_1 (الذراع العلوية)

ثبّت ذراع MG996R (البند 21) على arm_link_1 ببرغي M3 × 6 (البند 22). اضبط اتجاه ذراع السيرفو بحيث تتجه الذراع العلوية إلى الأمام عندما يكون السيرفو عند 0° (الموضع الأساسي).

Servo horn attached to arm_link_1, the upper arm

الخطوة 7: ركّب زنبرك المساعدة

علّق زنبرك الشد (البند 29) بين base_link_upper و arm_link_1. في كلتا القطعتين شق صغير فيه نقطة تعليق على الجهة الداخلية. أدخل أحد طرفي الزنبرك في الشق الموجود على base_link_upper، ثم شُدّ الزنبرك قليلًا وأدخل الطرف الآخر في الشق الموجود على arm_link_1. عندما تكون الذراع العلوية أفقية يجب أن يكون الزنبرك مشدودًا وأن يسحب الذراع إلى الأعلى؛ فهو يخفف الحمل عن سيرفو الكتف بنحو 2.9 kgf·cm.

Extension spring hooked between base_link_upper and arm_link_1

الخطوة 8: ركّب سيرفو المرفق (J3)

أدخل MG996R الثالث في حامل المرفق عند نهاية arm_link_1، وعمود الخرج متجه إلى الجانب. ثبّته بأربعة براغي No.3-28 بطول 1/2″ ذات رأس عريض (البند 25).

Elbow servo mounted at the end of arm_link_1

الخطوة 9: ثبّت ذراع السيرفو على arm_link_2 (الساعد)

ثبّت ذراع MG996R الثالثة (البند 21) على arm_link_2 ببرغي M3 × 6 (البند 22). اضبط اتجاهها بحيث يمتد الساعد مستقيمًا عندما يكون السيرفو عند 0°.

Servo horn attached to arm_link_2, the forearm

الخطوة 10: صِل arm_link_2 بـ arm_link_1

أدخل arm_link_2 على عمود سيرفو المرفق بحيث تتعشّق الذراع مع الحزوز، وثبّتها ببرغي المركز الخاص بالسيرفو (المرفق معه). يجب أن يتأرجح الساعد بحرية على كامل مداه.

Forearm connected to the upper arm at the elbow joint

الخطوة 11: ركّب سيرفو تدوير الساعد (J4)

أدخل أول SG90 في الجيب الدائري عند نهاية arm_link_2، وعمود الخرج متجه على امتداد الساعد. يثبت في مكانه بالكبس؛ ثم أحكم تثبيته ببرغي M1.6 × 2 (البند 17).

Forearm rotation servo (J4) mounted in the end of arm_link_2

الخطوة 12: ثبّت ذراع السيرفو على arm_link_3 (حامل المعصم)

ثبّت ذراع SG90 (البند 23) على arm_link_3 ببرغيين No.2-32 بطول 3/8″ ذوي رأس عريض (البند 24). تربط هذه الذراع سيرفو J4 بحامل المعصم.

SG90 servo horn attached to arm_link_3, the wrist bracket

الخطوة 13: صِل arm_link_3 بـ arm_link_2

أدخل arm_link_3 على عمود سيرفو J4 وثبّتها ببرغي المركز الخاص بالسيرفو. يجب أن يدور حامل المعصم بحرية حول محور الساعد.

Wrist bracket arm_link_3 connected to the forearm

الخطوة 14: ركّب سيرفو إمالة المعصم (J5)

أدخل SG90 الثاني في الجيب الجانبي لـ arm_link_3، بزاوية قائمة على J4، وأحكم تثبيته ببرغي M1.6 × 2 (البند 17).

Wrist tilt servo (J5) mounted in the side pocket of arm_link_3

الخطوة 15: ثبّت ذراع السيرفو على gripper_base

ثبّت ذراع SG90 (البند 23) على gripper_base. تربط هذه الذراع سيرفو J5 بهيكل القابض.

SG90 servo horn attached to gripper_base

الخطوة 16: ثبّت gripper_base على arm_link_3

أدخل gripper_base على عمود سيرفو J5 وثبّتها ببرغي المركز الخاص بالسيرفو. يجب أن يميل القابض إلى الأعلى والأسفل بحرية.

Gripper base connected to the wrist bracket

الخطوة 17: ركّب سيرفو القابض

أدخل SG90 الثالث في هيكل القابض وأحكم تثبيته ببرغي M1.6 × 2 (البند 17). يفتح هذا السيرفو الفكين ويغلقهما (المفصل 6 في الأوامر التسلسلية).

Gripper servo mounted in gripper_base

الخطوة 18: ثبّت ذراع السيرفو على ترس القابض

ثبّت ذراع SG90 الثالثة (البند 23) على الترس الأيسر القائد (Gear_links). يدير السيرفو هذا الترس، ويحرّك الترس الفكين.

SG90 servo horn attached to the left gripper gear

الخطوة 19: ركّب تروس القابض في gripper_base

عشّق الترسين Gear_links وGear_rechts في قاعدة القابض. يجب أن تتلامس الأسنان بالكاد: إذا كان التعشيق محكمًا جدًا فلن يستطيع السيرفو الدوران، وإذا كان مرتخيًا جدًا ظهر خلوص (لعب) في القابض.

Gripper gears meshed in gripper_base

الخطوة 20: أكمل تجميع القابض

ثبّت إصبعي القابض (Gripper وGripper_2) على وصلات التروس وعلى قطع الربط gripper_link (البند 11) باستخدام براغي M3 × 16 (البند 18) وصواميل M3 (البند 19). والسيرفو غير موصول بالطاقة، أدر ذراع السيرفو (Horn) ببطء باليد: يجب أن ينغلق الإصبعان بشكل متماثل. افتح القابض بالكامل، ثم أغلقه بالكامل، ثم افتحه مرة أخرى، دون أن يعلق.

The complete gripper assembly with both fingers, gears and linkages

الخطوة 21: الفحص النهائي

مرّر أسلاك السيرفو الستة كلها عائدةً نحو القاعدة. اترك فيها ارتخاءً كافيًا حتى لا تنشدّ الأسلاك عند حركة الذراع، واجمعها بأربطة كابلات غير مشدودة.

The fully assembled OmArm Zero

ثبّت ESP32 وPCA9685 بالقرب من القاعدة. وصّل أسلاك السيرفو بقنوات PCA9685 (انظر الجدول في القسم 6)، ووصّل ESP32 بـ PCA9685 عبر I2C (SDA على GPIO21، وSCL على GPIO22)، ثم وصّل مزود الطاقة 5 V بمربط الطاقة في PCA9685. بعد ذلك وصّل كابل USB وحمّل البرنامج الثابت (Firmware) على اللوحة.

6. التوصيلات ودمج الإلكترونيات

يتكون النظام الكهربائي من ثلاثة أجزاء: مزود الطاقة، ولوحة قيادة السيرفو، والمتحكم الدقيق.

بنية النظام

System architecture: ESP32 connected to the PCA9685 over I2C, six servo channels, WiFi access point and USB serial

يتواصل ESP32 مع PCA9685 عبر I2C، ومع العالم الخارجي عبر واي فاي (WiFi) أو الاتصال التسلسلي عبر USB. تولّد لوحة PCA9685 إشارات السيرفو الست. تستمد جميع محركات السيرفو طاقتها من مزود الطاقة 5 V عبر لوحة PCA9685، وليس من ESP32.

مخطط التوصيل

  • الطاقة: القطب الموجب (الأحمر) لمزود الطاقة (5 V، 10 A) إلى الطرف V+ في PCA9685، والقطب السالب (الأسود) إلى الطرف GND في PCA9685. إذا لم يكن ESP32 يتغذى عبر USB، فمدّ سلكًا من خط 5 V نفسه إلى الطرف VIN في ESP32 (مدخل الطاقة 5 V في اللوحة).
  • ناقل I2C: الطرف GPIO21 (SDA) في ESP32 إلى SDA في PCA9685، والطرف GPIO22 (SCL) في ESP32 إلى SCL في PCA9685. وصّل جميع خطوط GND معًا (ESP32 وPCA9685 ومزود الطاقة).
  • محركات السيرفو: لكل سيرفو ثلاثة أسلاك (V+ وGND والإشارة)، ويُركَّب على أطراف توصيل قناة واحدة في PCA9685.
Servoالمفصلقناة PCA9685
MG996RJ1 القاعدةCH0
MG996RJ2 الكتفCH3
MG996RJ3 المرفقCH4
SG90J4 دوران الساعدCH7
SG90J5 إمالة المعصمCH8
SG90القابضCH11
Fritzing wiring diagram with ESP32, PCA9685, six servos and barrel jack
Pinout of the PCA9685 servo driver board

مقاس الأسلاك والتوصيلات

تكون أسلاك السيرفو عادةً بمقاس 22 AWG (المقياس الأمريكي للأسلاك؛ كلما زاد الرقم كان السلك أرفع). تولّد شريحة PCA9685 إشارة التحكم فقط، أما تيار السيرفو فيتدفق من مزود الطاقة 5 V عبر الطرف V+ وخط الطاقة في اللوحة. يسحب MG996R عند التوقف (Stall) تيارًا يصل إلى 2.5 A، لذا اجعل أسلاك تمديد السيرفو قصيرة.

وصّل مزود الطاقة 5 V بسلك مقاس 18 AWG أو 16 AWG مباشرةً إلى مربط البراغي V+ وGND في PCA9685، وليس عبر خطوط لوحة التجارب (فهي مصممة لتيار يقارب 1 A). السلك السميك يُبقي هبوط الجهد صغيرًا.

لا تحمل أسلاك I2C بين ESP32 وPCA9685 تيارًا يُذكر، لذا يمكن أن تكون رفيعة (22 AWG). استخدم أسلاكًا منفصلة وليس زوجًا مجدولًا، حتى تبقى السعة الكهربائية منخفضة.

أخطاء شائعة

  • غياب الأرضي المشترك: وصّل GND الخاص بـ ESP32 وPCA9685 ومزود الطاقة معًا. من دون أرضي مشترك لا يكون لإشارات I2C والسيرفو مرجع مشترك، فتتحرك محركات السيرفو بشكل عشوائي أو لا تتحرك إطلاقًا.
  • تغذية محركات السيرفو من لوحة ESP32: لا تفعل ذلك. الطرف 5V يمرّر طاقة USB فقط (نحو 500 mA من منفذ الحاسوب)، ومنظّم الجهد 3.3 V مصمَّم لتغذية ESP32 نفسه. يسحب MG996R واحد عند التوقف 2500 mA، فينهار جهد ESP32 (Brownout) ويُعاد تشغيله. استخدم دائمًا مزود الطاقة الخارجي 5 V.
  • مقاومات الرفع (Pull-up) لـ I2C: يحتاج I2C إلى مقاومات رفع (Pull-up) تُبقي الخطين SDA وSCL في المستوى المرتفع ما دام لا يوجد جهاز يسحبهما إلى المستوى المنخفض. لوحة PCA9685 الجاهزة (Breakout) مزوّدة بها أصلًا، وهي كافية للأسلاك القصيرة. ذراعي تعمل من دون مقاومات إضافية. إذا كان اتصال I2C غير مستقر، فأضف مقاومات رفع بقيمة 4.7 kΩ من SDA ومن SCL إلى 3.3 V.
  • عنوان I2C خاطئ: تستخدم PCA9685 عنوان I2C الافتراضي 0x40، ويفترض الكود القيمة 0x40. تغيّر وصلات اللحام A0-A5 على اللوحة هذا العنوان (A0 مغلقة = 0x41). إذا لم تكن أي وصلة مغلقة، فالعنوان هو 0x40.
  • هبوط الجهد أثناء التسلسلات الطويلة: إذا انخفض الجهد أثناء تسلسل حركة طويل، فهذا يعني أن مزود الطاقة وصل إلى حدّه. استخدم مزود طاقة أكبر، أو أبطئ الحركات باستخدام الأمر SPEED.

7. البرنامج الثابت (Firmware): التحكم التسلسلي في ESP32

يعمل البرنامج الثابت التسلسلي على ESP32 ويستقبل أوامر نصية عبر الاتصال التسلسلي بـ USB. وهو أسرع طريقة لاختبار زوايا المفاصل قبل إعداد واجهة الويب.

الإعداد

ستحتاج إلى:

  • Arduino IDE 2.x (أو PlatformIO).
  • حزمة لوحات ESP32: افتح القائمة File > Preferences، والصق الرابط https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json في الحقل “Additional Board Manager URLs”، ثم افتح Tools > Board > Boards Manager، وابحث عن “esp32” وثبّت الحزمة “esp32 by Espressif Systems”.
  • مكتبة Adafruit PWM Servo Driver: افتح القائمة Sketch > Include Library > Manage Libraries، وابحث عن “Adafruit PWM Servo Driver Library”، ثم ثبّتها.
  • كابل USB-C أو micro-USB (حسب لوحة DevKit لديك) لتوصيل ESP32 بحاسوبك.

اختر Tools > Board > ESP32 Dev Module (وليس “ESP32S2” أو أي إصدار آخر) ومنفذ COM الصحيح. افتح الشاشة التسلسلية Serial Monitor (Ctrl+Shift+M) واضبط معدل الباود (Baud Rate) على 115200.

كود البرنامج الثابت

يوضّح الكود أدناه منطق OmArmZeroControl.ino. ارفع إلى اللوحة برنامج Arduino (Sketch) الموجود في مجلد Firmware ضمن حزمة المشروع (وهو متوفر أيضًا على GitHub)، فهذه هي النسخة المختبرة. تختلف بعض الأسماء والتفاصيل عن هذا الكود، ومنها نطاق عرض النبضة.

#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>

// Create servo driver object. Address 0x40 is the default
Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(0x40);

// Servo configuration
// Standard servo: 1000 us = 0 degrees, 2000 us = 180 degrees
// But we're using a wider range for these servos:
// MG996R: 500-2500 us (but safer to limit to 600-2400 us)
// SG90: 500-2400 us

// Define which servo is on which channel
const int SERVO_BASE = 0;     // J1: MG996R
const int SERVO_SHOULDER = 3; // J2: MG996R
const int SERVO_ELBOW = 4;    // J3: MG996R
const int SERVO_WRIST_PITCH = 7;  // J4: SG90
const int SERVO_WRIST_ROLL = 8;   // J5: SG90
const int SERVO_GRIPPER = 11;     // Gripper: SG90

// Servo angle limits (degrees)
const int ANGLE_MIN = 0;
const int ANGLE_MAX = 180;

// Pulse width range for servos (in microseconds)
// MG996R on 5V is roughly 500-2500 us, but we'll be conservative
const int PULSE_MIN = 600;   // 0 degrees (microseconds)
const int PULSE_MAX = 2400;  // 180 degrees (microseconds)

// PCA9685 frequency is 50Hz for servos (20ms per cycle)
#define SERVO_FREQ 50

// Smooth movement parameters
int targetAngles[6] = {0, 0, 0, 0, 0, 0};  // Target angles for each servo
int currentAngles[6] = {0, 0, 0, 0, 0, 0}; // Current angles
int moveSpeed = 5;  // Degrees per 20ms cycle (5 = 5 deg/20ms = 250 deg/s max)
int moveInterval = 20;  // Milliseconds between updates

// Serial buffer
const int BUFFER_SIZE = 128;
char serialBuffer[BUFFER_SIZE];
int bufferIndex = 0;

// Timing for smooth motion
unsigned long lastMoveTime = 0;

void setup() {
  Serial.begin(115200);
  delay(1000);

  Serial.println("OmArm Zero - 6-DOF (5+1 Gripper) Robotic Arm");
  Serial.println("Initializing I2C and servo driver...");

  Wire.begin(21, 22);  // SDA=GPIO21, SCL=GPIO22
  pwm.begin();
  pwm.setOscillatorFrequency(27000000);
  pwm.setPWMFreq(SERVO_FREQ);

  Serial.println("PCA9685 initialized at 50Hz");
  Serial.println("Sending all servos to home position (0 degrees)...");

  // Move all servos to home
  for (int i = 0; i < 6; i++) {
    targetAngles[i] = 0;
    currentAngles[i] = 0;
    setServoAngle(i, 0);
  }

  delay(1000);
  Serial.println("Ready. Send commands:");
  Serial.println("  HOME                 - move all to 0 degrees");
  Serial.println("  STATUS               - print current positions");
  Serial.println("  MOVE:d1,d2,d3,d4,d5,d6  - move to angles");
  Serial.println("  1,90                 - move J1 to 90 degrees");
  Serial.println("  SPEED:speed,interval - set move speed");
  Serial.println();

  lastMoveTime = millis();
}

void loop() {
  // Handle serial input
  if (Serial.available()) {
    char c = Serial.read();

    // Basic line buffering
    if (c == '\n' || c == '\r') {
      if (bufferIndex > 0) {
        serialBuffer[bufferIndex] = '\0';
        processCommand(serialBuffer);
        bufferIndex = 0;
      }
    } else if (bufferIndex < BUFFER_SIZE - 1) {
      serialBuffer[bufferIndex++] = c;
    }
  }

  // Update servo positions smoothly
  updateServoMovement();
}

void processCommand(const char* cmd) {
  String input(cmd);
  input.trim();
  input.toUpperCase();

  if (input == "HOME") {
    // Move all servos to home position
    for (int i = 0; i < 6; i++) {
      targetAngles[i] = 0;
    }
    Serial.println("HOME command: moving all servos to 0 degrees");

  } else if (input.startsWith("STATUS")) {
    // Print current angles
    Serial.print("Current angles: ");
    for (int i = 0; i < 6; i++) {
      Serial.print("J");
      Serial.print(i + 1);
      Serial.print("=");
      Serial.print(currentAngles[i]);
      if (i < 5) Serial.print(", ");
    }
    Serial.println();

  } else if (input.startsWith("MOVE:")) {
    // Parse MOVE:d1,d2,d3,d4,d5,d6
    String params = input.substring(5);
    parseAngles(params);
    Serial.print("Moving to: ");
    for (int i = 0; i < 6; i++) {
      Serial.print(targetAngles[i]);
      if (i < 5) Serial.print(", ");
    }
    Serial.println();

  } else if (input.startsWith("SPEED:")) {
    // Parse SPEED:speed,interval
    String params = input.substring(6);
    int commaIndex = params.indexOf(',');
    if (commaIndex > 0) {
      moveSpeed = params.substring(0, commaIndex).toInt();
      moveInterval = params.substring(commaIndex + 1).toInt();
      Serial.print("Speed set to ");
      Serial.print(moveSpeed);
      Serial.print(" deg/");
      Serial.print(moveInterval);
      Serial.println(" ms");
    }

  } else if (input.indexOf(',') > 0) {
    // Single joint command: J,angle
    int commaIndex = input.indexOf(',');
    int jointNum = input.substring(0, commaIndex).toInt() - 1;  // Convert 1-based to 0-based
    int angle = input.substring(commaIndex + 1).toInt();

    if (jointNum >= 0 && jointNum < 6 && angle >= ANGLE_MIN && angle <= ANGLE_MAX) {
      targetAngles[jointNum] = angle;
      Serial.print("J");
      Serial.print(jointNum + 1);
      Serial.print(" -> ");
      Serial.println(angle);
    } else {
      Serial.println("Invalid joint or angle");
    }
  } else {
    Serial.println("Unknown command");
  }
}

void parseAngles(const String& params) {
  // Parse comma-separated angles
  int angles[6];
  int angleCount = 0;
  int lastComma = -1;

  for (int i = 0; i <= params.length(); i++) {
    if (params[i] == ',' || i == params.length()) {
      if (i > lastComma + 1 && angleCount < 6) {
        String numStr = params.substring(lastComma + 1, i);
        angles[angleCount] = numStr.toInt();
        angleCount++;
      }
      lastComma = i;
    }
  }

  // Apply parsed angles
  for (int i = 0; i < angleCount && i < 6; i++) {
    if (angles[i] >= ANGLE_MIN && angles[i] <= ANGLE_MAX) {
      targetAngles[i] = angles[i];
    }
  }
}

void updateServoMovement() {
  unsigned long now = millis();

  // Check if it's time to update servo positions
  if (now - lastMoveTime >= moveInterval) {
    lastMoveTime = now;

    // Move each servo toward its target
    for (int i = 0; i < 6; i++) {
      if (currentAngles[i] != targetAngles[i]) {
        int diff = targetAngles[i] - currentAngles[i];

        // Move by up to moveSpeed degrees per interval
        if (abs(diff) <= moveSpeed) {
          currentAngles[i] = targetAngles[i];
        } else if (diff > 0) {
          currentAngles[i] += moveSpeed;
        } else {
          currentAngles[i] -= moveSpeed;
        }

        // Send new angle to servo
        setServoAngle(i, currentAngles[i]);
      }
    }
  }
}

void setServoAngle(int servoIndex, int angle) {
  // Clamp angle to valid range
  if (angle < ANGLE_MIN) angle = ANGLE_MIN;
  if (angle > ANGLE_MAX) angle = ANGLE_MAX;

  // Convert angle (0-180) to pulse width (PULSE_MIN to PULSE_MAX)
  int pulseWidth = map(angle, 0, 180, PULSE_MIN, PULSE_MAX);

  // Convert pulse width (microseconds) to PCA9685 tick count.
  // The PCA9685 divides one 20ms cycle into 4096 ticks.
  // So we map from the full 20000us period to the 0-4096 range.
  // Example: 1500us (90°) → map(1500, 0, 20000, 0, 4096) = 307 ticks.
  int pwmValue = map(pulseWidth, 0, 20000, 0, 4096);

  // Get the channel for this servo
  int channel = getChannelForServo(servoIndex);

  // Set the PWM pulse
  pwm.setPWM(channel, 0, pwmValue);
}

int getChannelForServo(int servoIndex) {
  // Map servo index to channel
  const int channels[6] = {
    SERVO_BASE,         // Index 0 -> J1
    SERVO_SHOULDER,     // Index 1 -> J2
    SERVO_ELBOW,        // Index 2 -> J3
    SERVO_WRIST_PITCH,  // Index 3 -> J4
    SERVO_WRIST_ROLL,   // Index 4 -> J5
    SERVO_GRIPPER       // Index 5 -> Gripper
  };
  return channels[servoIndex];
}

ملاحظة حول التسمية: يسمّي البرنامج الثابت سيرفوي المعصم SERVO_WRIST_PITCH (J4) وSERVO_WRIST_ROLL (J5)، وتعرضهما واجهة الويب باسمَي “J4 Wrist Pitch” و“J5 Wrist Roll”. هذه الأسماء موروثة من مرحلة سابقة؛ فالمفصل J4 يدير الساعد والمفصل J5 يُميل القابض. المهم هو أرقام القنوات.

شرح الكود

  • المكتبات وإعداد I2C: تتولى مكتبة Wire اتصال I2C، وتتولى Adafruit_PWMServoDriver التحكم في PCA9685. تبدأ الدالة setup() تشغيل Wire على GPIO21 (SDA) وGPIO22 (SCL)، وتضبط تردد وحدة PWM على 50 Hz.
  • قنوات السيرفو: تربط الثوابت SERVO_BASE وSERVO_SHOULDER وغيرها المفاصل (من J1 إلى J5 والقابض) بقنوات PCA9685 التالية: CH0 وCH3 وCH4 وCH7 وCH8 وCH11، بدلًا من 0 إلى 5. توزيع القنوات بفواصل بينها يمنع تكدّس الموصلات الستة ثلاثية الأطراف على جانب واحد من اللوحة. إذا كانت توصيلاتك مختلفة، فغيّر الأرقام في getChannelForServo()، فهذا الربط موجود في هذا المكان وحده.
  • تحويل عرض النبضة: عرض النبضة هو ما يحدد الزاوية. القيمة 1500 µs تمثل الوضع الأوسط (90 درجة)؛ ويستخدم البرنامج الثابت 600 µs للزاوية 0 درجة و2400 µs للزاوية 180 درجة. تعدّ PCA9685 بوحداتها الخاصة (من 0 إلى 4095 وحدة عدّ في كل دورة مدتها 20 ms)، لذلك تحوّل الدالة setServoAngle() القيم من الميكروثانية إلى وحدات العدّ (Ticks).
  • الحركة السلسة: يحدد الأمر هدفًا جديدًا فقط؛ وكل moveInterval ms (القيمة الافتراضية 20) تحرّك الحلقة الرئيسية كل مفصل بمقدار moveSpeed درجة (القيمة الافتراضية 5) نحو الهدف. أي 250 درجة في الثانية عند سرعة ثابتة.
  • البروتوكول التسلسلي: تحلّل الدالة processCommand() الأوامر. الأمر HOME يحرّك جميع محركات السيرفو إلى 0 درجة. الأمر MOVE:a1,a2,a3,a4,a5,a6 يضبط الأهداف الستة كلها. أمر المفصل الواحد مثل “1,45” يحرّك J1 إلى 45 درجة. الأمر SPEED:speed,interval يغيّر سرعة الحركة.
  • السلامة: تُرفض الأوامر التي تحتوي على رقم مفصل خارج النطاق 1-6 أو زاوية خارج النطاق 0-180، وتظهر الرسالة “Invalid joint or angle”؛ كما تحصر الدالة setServoAngle() كل قيمة ضمن 0-180 كإجراء حماية ثانٍ.
Firmware control flow: the main loop reads serial commands and updates the servo positions every cycle

تؤدي الحلقة الرئيسية مهمتين في كل دورة: قراءة المدخلات التسلسلية، ثم تحديث مواضع محركات السيرفو. وتواصل المفاصل حركتها نحو أهدافها سواء وصل أمر جديد أم لا.

اختبار البرنامج الثابت التسلسلي

قبل رفع كود السيرفو، افحص توصيلات I2C باستخدام برنامج مسح (Scanner Sketch):

#include <Wire.h>
void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  Serial.println("Scanning I2C bus...");
  for (byte addr = 1; addr < 127; addr++) {
    Wire.beginTransmission(addr);
    if (Wire.endTransmission() == 0) {
      Serial.print("Found device at 0x");
      Serial.println(addr, HEX);
    }
  }
  Serial.println("Done.");
}
void loop() {}

يجب أن تظهر الرسالة “Found device at 0x40”. إذا لم يظهر شيء، فافحص توصيلات SDA/SCL. وإذا ظهر عنوان مختلف، مثل 0x41، فهذا يعني أن إحدى وصلات العنوان في لوحة PCA9685 لديك مغلقة؛ فغيّر العنوان في كود السيرفو.

بعد أن يعمل I2C، ارفع برنامج OmArmZeroControl وافتح الشاشة التسلسلية Serial Monitor (Ctrl+Shift+M) على سرعة 115200 باود. يجب أن تظهر رسالة بدء التشغيل. بعد ذلك جرّب الأوامر التالية:

HOME

تتحرك جميع محركات السيرفو إلى 0°، أي وضع البداية (Home). إذا لم يتحرك أحدها، فافحص توصيلاته.

1,180

يدور J1 (القاعدة) بالكامل في اتجاه عقارب الساعة.

1,0

يعود J1 بالدوران بالكامل عكس اتجاه عقارب الساعة إلى 0°.

MOVE:90,45,135,90,90,90

يتحرك J2 إلى 45 درجة (ترتفع الذراع)، وJ3 إلى 135 درجة (ينثني المرفق)، وتنتقل بقية المفاصل إلى 90.

STATUS

يطبع الزاوية الحالية لكل مفصل.

SPEED:2,50

يضبط السرعة على 2 درجة لكل تحديث مدته 50 ms، وهي سرعة أبطأ.

إذا لم يستجب أحد محركات السيرفو، فاستعن بجدول استكشاف الأخطاء وإصلاحها في نهاية القسم 9. وبعد أن يعمل التحكم التسلسلي بشكل موثوق، انتقل إلى واجهة الويب.

8. واجهة الويب

الأوامر النصية مناسبة للاختبار، لكنها بطيئة في الاستخدام اليومي. تمنحك واجهة الويب شريط تمرير (Slider) لكل مفصل في متصفح هاتفك أو حاسوبك المحمول.

يشغّل البرنامج الثابت OmArmZero_LittleFS لوحة ESP32 كنقطة وصول واي فاي (Access Point)، أي أن ESP32 ينشئ شبكة واي فاي خاصة به. ويقدّم واجهة الويب من ذاكرة الفلاش عبر LittleFS، وهو نظام ملفات صغير لذاكرة الفلاش. شغّل الذراع، واتصل بشبكة الواي فاي الخاصة بها، وافتح 10.10.10.1 في متصفحك.

البنية

يشغّل ESP32 ثلاث خدمات في الوقت نفسه:

  • نقطة وصول واي فاي باسم “OmArmZero”،
  • خادم ويب HTTP على المنفذ 80،
  • منطق التحكم في السيرفو نفسه المستخدم في البرنامج الثابت التسلسلي.

واجهة الويب تطبيق أحادي الصفحة (SPA): صفحة HTML واحدة تتواصل شيفرة JavaScript فيها مع ESP32 دون إعادة تحميل الصفحة. ملفات HTML وCSS وJavaScript مخزنة في LittleFS. عند فتح 10.10.10.1 يرسل الخادم الملف index.html، وترسل JavaScript الأوامر بطلبات POST إلى واجهة API في ESP32 (العنوان /api/command).

Sequence diagram of the web interface: the browser connects to the ESP32 access point, loads the page and sends JSON commands

عند سحب شريط التمرير، يرسل المتصفح طلب HTTP POST يحمل رسالة JSON صغيرة. يحلّل البرنامج الثابت الرسالة ويضبط زاوية الهدف؛ ثم تحرّك حلقة الحركة السلسة نفسها المستخدمة في البرنامج الثابت التسلسلي المفصلَ نحو الهدف خطوة بخطوة. ويطلب المتصفح /api/status كل ثانية ليعرض ما إذا كان الاتصال بالذراع لا يزال قائمًا.

أخزّن الصفحة في LittleFS بدلًا من وضعها كسلسلة نصية داخل البرنامج الثابت، لأن ملفات الويب تبقى بذلك منفصلة عن كود التحكم ويمكن أن تتضمن صورًا.

إعداد واجهة الويب

تحتاج إلى إضافة رفع LittleFS لبيئة Arduino IDE 2.x:

  • نزّل ملف .vsix من صفحة الإصدار.
  • انسخ ملف .vsix إلى المجلد .arduinoIDE/plugins في مجلد المستخدم لديك (على Windows C:\Users\YourName\.arduinoIDE\plugins). أنشئ المجلد إذا لم يكن موجودًا، ثم أعد تشغيل IDE.
  • اضغط Ctrl+Shift+P ونفّذ الأمر “Upload LittleFS to Pico/ESP8266/ESP32”.

قبل الرفع، أنشئ مجلدًا باسم “data” بجوار ملف .ino. ضع فيه index.html وstyle.css وapp.js وأي ملفات صور اختيارية. يحزم أمر الرفع مجلد data في صورة نظام ملفات LittleFS ويحمّلها على ESP32.

استكشاف أخطاء رفع LittleFS وإصلاحها.

  • يفشل الرفع برسالة “Wrong board” أو يتوقف عند “Connecting…”: اختر اللوحة “ESP32 Dev Module”، لا لوحة ESP32 عامة، وتحقق من منفذ COM، وأغلق الشاشة التسلسلية (فهي تحجز المنفذ).
  • ينجح الرفع لكن الصفحة تعرض “index.html not found”: مخطط التقسيم (Partition Scheme) خاطئ. افتح Tools > Partition Scheme واختر “Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)”. رغم الاسم، يحجز هذا المخطط 1.5 MB من ذاكرة الفلاش يستخدمها LittleFS لملفات الويب؛ فقد حلّ LittleFS محل SPIFFS كنظام الملفات الموصى به، لكن اسم القسم لم يُغيَّر قط. أعد الرفع بعد تغيير هذا الإعداد.
  • لا يُعثر على مجلد data: يجب أن يكون اسمه “data” بالضبط (بأحرف صغيرة)، وأن يوجد في المجلد نفسه الذي فيه ملف .ino، لا في المستوى الأعلى ولا في مجلد فرعي.

ملفات واجهة الويب

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>OmArm Zero Control</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <header>
      <h1>OmArm Zero</h1>
      <div id="status" class="status disconnected">Disconnected</div>
    </header>

    <main>
      <section class="control-panel">
        <h2>Joint Control</h2>
        <div class="joint-group">
          <label for="j1">J1 Base (0-180°)</label>
          <input type="range" id="j1" min="0" max="180" value="0" class="slider">
          <span id="j1-value">0</span>°
        </div>

        <div class="joint-group">
          <label for="j2">J2 Shoulder (0-180°)</label>
          <input type="range" id="j2" min="0" max="180" value="0" class="slider">
          <span id="j2-value">0</span>°
        </div>

        <div class="joint-group">
          <label for="j3">J3 Elbow (0-180°)</label>
          <input type="range" id="j3" min="0" max="180" value="0" class="slider">
          <span id="j3-value">0</span>°
        </div>

        <div class="joint-group">
          <label for="j4">J4 Wrist Pitch (0-180°)</label>
          <input type="range" id="j4" min="0" max="180" value="0" class="slider">
          <span id="j4-value">0</span>°
        </div>

        <div class="joint-group">
          <label for="j5">J5 Wrist Roll (0-180°)</label>
          <input type="range" id="j5" min="0" max="180" value="0" class="slider">
          <span id="j5-value">0</span>°
        </div>

        <div class="joint-group">
          <label for="gripper">Gripper (0-180°)</label>
          <input type="range" id="gripper" min="0" max="180" value="0" class="slider">
          <span id="gripper-value">0</span>°
        </div>
      </section>

      <section class="settings">
        <h2>Settings</h2>
        <div class="setting-group">
          <label for="speed">Movement Speed (deg/update)</label>
          <input type="number" id="speed" min="1" max="20" value="5">
        </div>

        <button id="homeButton" class="button primary">Home Position</button>
      </section>

      <section class="sequences">
        <h2>Sequences</h2>
        <button id="recordButton" class="button">Record Sequence</button>
        <button id="playButton" class="button" disabled>Play Sequence</button>
        <button id="clearButton" class="button">Clear Sequence</button>
        <textarea id="sequenceJSON" placeholder="Recorded sequence will appear here"></textarea>
        <input type="file" id="loadSequence" accept=".json" style="display:none">
        <button id="loadButton" class="button">Load Sequence</button>
      </section>
    </main>

    <footer>
      <p>OmArm Zero v1.0 - 6-DOF (5+1 Gripper) Robotic Arm</p>
    </footer>
  </div>

  <script src="app.js"></script>
</body>
</html>

style.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
  padding: 10px;
}

.container {
  max-width: 600px;
  margin: 0 auto;
  background: white;
  border-radius: 12px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

header {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  padding: 20px;
  text-align: center;
}

header h1 {
  font-size: 28px;
  margin-bottom: 10px;
}

.status {
  display: inline-block;
  padding: 6px 12px;
  border-radius: 20px;
  font-size: 12px;
  font-weight: bold;
}

.status.connected {
  background: #4caf50;
  color: white;
}

.status.disconnected {
  background: #f44336;
  color: white;
}

main {
  flex: 1;
  padding: 20px;
  overflow-y: auto;
}

section {
  margin-bottom: 30px;
  padding-bottom: 20px;
  border-bottom: 1px solid #eee;
}

section:last-child {
  border-bottom: none;
}

section h2 {
  font-size: 18px;
  margin-bottom: 15px;
  color: #333;
}

.joint-group {
  display: flex;
  align-items: center;
  margin-bottom: 15px;
  gap: 10px;
}

.joint-group label {
  flex: 0 0 150px;
  font-size: 14px;
  color: #555;
}

.joint-group input[type="range"] {
  flex: 1;
  height: 6px;
  border-radius: 3px;
  background: #ddd;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}

.joint-group input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #667eea;
  cursor: pointer;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.joint-group input[type="range"]::-moz-range-thumb {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #667eea;
  cursor: pointer;
  border: none;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.joint-group span {
  flex: 0 0 40px;
  text-align: right;
  font-weight: bold;
  color: #333;
}

.setting-group {
  margin-bottom: 15px;
}

.setting-group label {
  display: block;
  font-size: 14px;
  margin-bottom: 5px;
  color: #555;
}

.setting-group input[type="number"] {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

.button {
  display: inline-block;
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  font-size: 14px;
  cursor: pointer;
  transition: all 0.3s ease;
  background: #f0f0f0;
  color: #333;
  margin-right: 10px;
  margin-bottom: 10px;
}

.button:hover {
  background: #e0e0e0;
}

.button.primary {
  background: #667eea;
  color: white;
}

.button.primary:hover {
  background: #5568d3;
}

.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

#sequenceJSON {
  width: 100%;
  height: 120px;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-family: monospace;
  font-size: 12px;
  resize: vertical;
  margin: 10px 0;
}

footer {
  background: #f5f5f5;
  color: #999;
  text-align: center;
  padding: 15px;
  font-size: 12px;
  border-top: 1px solid #eee;
}

@media (max-width: 600px) {
  header h1 {
    font-size: 22px;
  }

  main {
    padding: 15px;
  }

  .joint-group label {
    flex: 0 0 120px;
  }
}

app.js:

// OmArm Zero Control Web App

const API_BASE = "/api";
const STATUS_CHECK_INTERVAL = 1000;

const sliders = {
  j1: document.getElementById("j1"),
  j2: document.getElementById("j2"),
  j3: document.getElementById("j3"),
  j4: document.getElementById("j4"),
  j5: document.getElementById("j5"),
  gripper: document.getElementById("gripper")
};

const sliderValues = {
  j1: document.getElementById("j1-value"),
  j2: document.getElementById("j2-value"),
  j3: document.getElementById("j3-value"),
  j4: document.getElementById("j4-value"),
  j5: document.getElementById("j5-value"),
  gripper: document.getElementById("gripper-value")
};

const statusDisplay = document.getElementById("status");
const speedInput = document.getElementById("speed");
const homeButton = document.getElementById("homeButton");
const recordButton = document.getElementById("recordButton");
const playButton = document.getElementById("playButton");
const clearButton = document.getElementById("clearButton");
const loadButton = document.getElementById("loadButton");
const sequenceJSON = document.getElementById("sequenceJSON");
const loadSequenceInput = document.getElementById("loadSequence");

let isConnected = false;
let isRecording = false;
let recordedSequence = [];

// Initialize
document.addEventListener("DOMContentLoaded", () => {
  // Slider listeners
  for (const [id, slider] of Object.entries(sliders)) {
    slider.addEventListener("input", (e) => {
      const angle = e.target.value;
      sliderValues[id].textContent = angle;
      sendCommand(id, angle);
    });
  }

  // Button listeners
  homeButton.addEventListener("click", () => {
    sendCommand("HOME", null);
    // Reset sliders to home position
    for (const slider of Object.values(sliders)) {
      slider.value = 0;
    }
    for (const value of Object.values(sliderValues)) {
      value.textContent = 0;
    }
  });

  recordButton.addEventListener("click", () => {
    if (!isRecording) {
      recordedSequence = [];
      isRecording = true;
      recordButton.textContent = "Stop Recording";
      recordButton.classList.add("recording");
    } else {
      isRecording = false;
      recordButton.textContent = "Record Sequence";
      recordButton.classList.remove("recording");
      saveSequenceToJSON();
    }
  });

  playButton.addEventListener("click", () => {
    playRecordedSequence();
  });

  clearButton.addEventListener("click", () => {
    recordedSequence = [];
    sequenceJSON.value = "";
    playButton.disabled = true;
  });

  loadButton.addEventListener("click", () => {
    loadSequenceInput.click();
  });

  loadSequenceInput.addEventListener("change", (e) => {
    const file = e.target.files[0];
    if (file) {
      const reader = new FileReader();
      reader.onload = (event) => {
        try {
          recordedSequence = JSON.parse(event.target.result);
          saveSequenceToJSON();
          playButton.disabled = false;
          alert("Sequence loaded successfully");
        } catch (err) {
          alert("Failed to parse sequence file: " + err.message);
        }
      };
      reader.readAsText(file);
    }
  });

  speedInput.addEventListener("change", () => {
    sendCommand("SPEED", speedInput.value);
  });

  // Check connection status periodically
  checkConnection();
  setInterval(checkConnection, STATUS_CHECK_INTERVAL);
});

function sendCommand(cmd, value) {
  let fullCmd = cmd;

  if (cmd === "HOME") {
    fullCmd = "HOME";
  } else if (cmd === "SPEED") {
    fullCmd = "SPEED:" + value + ",20";
  } else if (["j1", "j2", "j3", "j4", "j5", "gripper"].includes(cmd)) {
    // Single joint command
    const jointNum = cmd === "gripper" ? 6 : parseInt(cmd.slice(1));
    fullCmd = jointNum + "," + value;

    // Record if recording
    if (isRecording) {
      recordedSequence.push({
        j1: sliders.j1.value,
        j2: sliders.j2.value,
        j3: sliders.j3.value,
        j4: sliders.j4.value,
        j5: sliders.j5.value,
        gripper: sliders.gripper.value,
        timestamp: Date.now()
      });
    }
  }

  fetch(API_BASE + "/command", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ cmd: fullCmd })
  }).catch(err => console.log("Command send error:", err));
}

function checkConnection() {
  fetch(API_BASE + "/status")
    .then(response => response.json())
    .then(data => {
      isConnected = true;
      statusDisplay.textContent = "Connected";
      statusDisplay.classList.remove("disconnected");
      statusDisplay.classList.add("connected");
    })
    .catch(err => {
      isConnected = false;
      statusDisplay.textContent = "Disconnected";
      statusDisplay.classList.remove("connected");
      statusDisplay.classList.add("disconnected");
    });
}

function saveSequenceToJSON() {
  sequenceJSON.value = JSON.stringify(recordedSequence, null, 2);
  playButton.disabled = recordedSequence.length === 0;
}

function playRecordedSequence() {
  if (recordedSequence.length === 0) return;

  playButton.disabled = true;
  let index = 0;

  const playStep = () => {
    if (index >= recordedSequence.length) {
      playButton.disabled = false;
      return;
    }

    const step = recordedSequence[index];
    const delay = index > 0 ? recordedSequence[index].timestamp - recordedSequence[index - 1].timestamp : 0;

    setTimeout(() => {
      for (const joint of ["j1", "j2", "j3", "j4", "j5", "gripper"]) {
        sliders[joint].value = step[joint];
        sliderValues[joint].textContent = step[joint];
        sendCommand(joint, step[joint]);
      }
      index++;
      playStep();
    }, Math.max(delay, 100));
  };

  playStep();
}

البرنامج الثابت للتحكم عبر الويب

يشبه OmArmZero_LittleFS.ino البرنامج الثابت التسلسلي، لكنه يضيف واي فاي وخادم HTTP. الكود المعروض مختصر؛ ارفع إلى اللوحة البرنامج الكامل من مجلد Firmware في حزمة المشروع:

#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
#include <WiFi.h>
#include <WebServer.h>
#include <LittleFS.h>
#include <ArduinoJson.h>

Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(0x40);
WebServer server(80);

// WiFi AP credentials
const char* SSID = "OmArmZero";
const char* PASSWORD = "omarmcontrol";

// Same servo configuration as serial version
const int SERVO_BASE = 0;
const int SERVO_SHOULDER = 3;
const int SERVO_ELBOW = 4;
const int SERVO_WRIST_PITCH = 7;
const int SERVO_WRIST_ROLL = 8;
const int SERVO_GRIPPER = 11;

const int ANGLE_MIN = 0;
const int ANGLE_MAX = 180;
const int PULSE_MIN = 600;
const int PULSE_MAX = 2400;
#define SERVO_FREQ 50

int targetAngles[6] = {0, 0, 0, 0, 0, 0};
int currentAngles[6] = {0, 0, 0, 0, 0, 0};
int moveSpeed = 5;
int moveInterval = 20;

unsigned long lastMoveTime = 0;

void setup() {
  Serial.begin(115200);
  delay(1000);

  Serial.println("OmArm Zero - Web Control Interface");

  // Initialize LittleFS
  if (!LittleFS.begin()) {
    Serial.println("LittleFS mount failed");
    return;
  }
  Serial.println("LittleFS mounted");

  // Initialize I2C and servo driver
  Wire.begin(21, 22);
  pwm.begin();
  pwm.setOscillatorFrequency(27000000);
  pwm.setPWMFreq(SERVO_FREQ);

  // Move all to home
  for (int i = 0; i < 6; i++) {
    setServoAngle(i, 0);
  }

  // Start WiFi AP
  WiFi.mode(WIFI_AP);
  WiFi.softAP(SSID, PASSWORD);
  delay(500);  // Wait for AP interface to fully initialize

  // Set static IP (must be AFTER softAP on ESP32)
  IPAddress local_IP(10, 10, 10, 1);
  IPAddress gateway(10, 10, 10, 1);
  IPAddress subnet(255, 255, 255, 0);
  WiFi.softAPConfig(local_IP, gateway, subnet);
  IPAddress IP = WiFi.softAPIP();
  Serial.print("AP IP address: ");
  Serial.println(IP);

  // Set up web server routes
  server.on("/", handleRoot);
  server.on("/api/command", HTTP_POST, handleCommand);
  server.on("/api/status", HTTP_GET, handleStatus);
  server.onNotFound(handleNotFound);

  server.begin();
  Serial.println("Web server started");

  lastMoveTime = millis();
}

void loop() {
  server.handleClient();
  updateServoMovement();
}

void handleRoot() {
  if (LittleFS.exists("/index.html")) {
    File file = LittleFS.open("/index.html", "r");
    server.streamFile(file, "text/html");
    file.close();
  } else {
    server.send(404, "text/plain", "index.html not found");
  }
}

void handleCommand() {
  if (server.hasArg("plain")) {
    String body = server.arg("plain");

    StaticJsonDocument<256> doc;
    DeserializationError error = deserializeJson(doc, body);

    if (!error) {
      String cmd = doc["cmd"];
      processCommand(cmd.c_str());
      server.send(200, "application/json", "{\"status\":\"ok\"}");
    } else {
      server.send(400, "application/json", "{\"error\":\"JSON parse error\"}");
    }
  } else {
    server.send(400, "application/json", "{\"error\":\"No command\"}");
  }
}

void handleStatus() {
  StaticJsonDocument<256> doc;
  doc["connected"] = true;
  doc["angles"][0] = currentAngles[0];
  doc["angles"][1] = currentAngles[1];
  doc["angles"][2] = currentAngles[2];
  doc["angles"][3] = currentAngles[3];
  doc["angles"][4] = currentAngles[4];
  doc["angles"][5] = currentAngles[5];

  String response;
  serializeJson(doc, response);
  server.send(200, "application/json", response);
}

void handleNotFound() {
  // Try to serve the requested file from LittleFS
  String path = server.uri();
  if (path.endsWith("/")) {
    path += "index.html";
  }

  if (LittleFS.exists(path)) {
    File file = LittleFS.open(path, "r");
    String contentType = "application/octet-stream";
    if (path.endsWith(".html")) contentType = "text/html";
    else if (path.endsWith(".css")) contentType = "text/css";
    else if (path.endsWith(".js")) contentType = "application/javascript";
    else if (path.endsWith(".png")) contentType = "image/png";
    else if (path.endsWith(".jpg")) contentType = "image/jpeg";

    server.streamFile(file, contentType);
    file.close();
  } else {
    server.send(404, "text/plain", "File not found");
  }
}

void processCommand(const char* cmd) {
  String input(cmd);
  input.trim();
  input.toUpperCase();

  if (input == "HOME") {
    for (int i = 0; i < 6; i++) {
      targetAngles[i] = 0;
    }
  } else if (input.startsWith("MOVE:")) {
    String params = input.substring(5);
    parseAngles(params);
  } else if (input.startsWith("SPEED:")) {
    String params = input.substring(6);
    int commaIndex = params.indexOf(',');
    if (commaIndex > 0) {
      moveSpeed = params.substring(0, commaIndex).toInt();
      moveInterval = params.substring(commaIndex + 1).toInt();
    }
  } else if (input.indexOf(',') > 0) {
    int commaIndex = input.indexOf(',');
    int jointNum = input.substring(0, commaIndex).toInt() - 1;
    int angle = input.substring(commaIndex + 1).toInt();

    if (jointNum >= 0 && jointNum < 6 && angle >= ANGLE_MIN && angle <= ANGLE_MAX) {
      targetAngles[jointNum] = angle;
    }
  }
}

void parseAngles(const String& params) {
  int angles[6];
  int angleCount = 0;
  int lastComma = -1;

  for (int i = 0; i <= params.length(); i++) {
    if (params[i] == ',' || i == params.length()) {
      if (i > lastComma + 1 && angleCount < 6) {
        String numStr = params.substring(lastComma + 1, i);
        angles[angleCount] = numStr.toInt();
        angleCount++;
      }
      lastComma = i;
    }
  }

  for (int i = 0; i < angleCount && i < 6; i++) {
    if (angles[i] >= ANGLE_MIN && angles[i] <= ANGLE_MAX) {
      targetAngles[i] = angles[i];
    }
  }
}

void updateServoMovement() {
  unsigned long now = millis();

  if (now - lastMoveTime >= moveInterval) {
    lastMoveTime = now;

    for (int i = 0; i < 6; i++) {
      if (currentAngles[i] != targetAngles[i]) {
        int diff = targetAngles[i] - currentAngles[i];

        if (abs(diff) <= moveSpeed) {
          currentAngles[i] = targetAngles[i];
        } else if (diff > 0) {
          currentAngles[i] += moveSpeed;
        } else {
          currentAngles[i] -= moveSpeed;
        }

        setServoAngle(i, currentAngles[i]);
      }
    }
  }
}

void setServoAngle(int servoIndex, int angle) {
  if (angle < ANGLE_MIN) angle = ANGLE_MIN;
  if (angle > ANGLE_MAX) angle = ANGLE_MAX;

  int pulseWidth = map(angle, 0, 180, PULSE_MIN, PULSE_MAX);
  int pwmValue = map(pulseWidth, 0, 20000, 0, 4096);
  int channel = getChannelForServo(servoIndex);

  pwm.setPWM(channel, 0, pwmValue);
}

int getChannelForServo(int servoIndex) {
  const int channels[6] = {
    SERVO_BASE,
    SERVO_SHOULDER,
    SERVO_ELBOW,
    SERVO_WRIST_PITCH,
    SERVO_WRIST_ROLL,
    SERVO_GRIPPER
  };
  return channels[servoIndex];
}

استخدام واجهة الويب

بعد رفع البرنامج الثابت وملفات LittleFS:

  • شغّل الذراع.
  • على هاتفك أو حاسوبك المحمول، افتح قائمة شبكات الواي فاي واتصل بالشبكة “OmArmZero”. كلمة المرور هي “omarmcontrol” (وكلاهما محدد في سطري البرنامج الثابت const char* SSID وconst char* PASSWORD).
  • افتح 10.10.10.1 في المتصفح. تعرض الصفحة ستة أشرطة تمرير، وحقلًا للسرعة، وأزرارًا لوضع البداية والتسجيل والتشغيل.
  • اسحب أحد أشرطة التمرير: تتحرك الذراع فورًا.
  • اضغط Home: تتحرك جميع المفاصل إلى 0°، أي وضع البداية.
  • اضغط Record، وحرّك الذراع عبر تسلسل من الحركات، ثم اضغط Stop Recording. يظهر التسلسل بصيغة JSON في مربع النص أدناه.
  • اضغط Play لتكرار التسلسل.
  • لتصدير التسلسل، انسخ نص JSON من مربع النص إلى ملف .json. ويقرأ الزر Load Sequence هذا الملف لاحقًا.

في اختباراتي تستجيب واجهة الويب دون تأخير ملحوظ. أستخدم الهاتف في الغالب: أمسكه بيد واحدة وأقف بجوار الذراع مباشرةً أثناء حركتها.

تُظهر لقطات الشاشة واجهة الويب الموجودة في حزمة المشروع (والمتوفرة أيضًا على GitHub). تبدو مختلفة عن الكود المختصر أعلاه، لكنها ترسل الأوامر نفسها. على الحاسوب المكتبي تتكون من ثلاثة أعمدة: صورة الذراع على اليسار، وأشرطة التمرير وزر HOME في الوسط، ومحرر التسلسلات على اليمين. في محرر التسلسلات تحفظ الوضعيات بالزر “+ Add Current Pose” ثم تعيد تشغيلها؛ وتعمل أيقونتا السهمين على تصدير التسلسل واستيراده كملف JSON.

The web interface in a desktop browser: picture of the arm, six joint sliders with a speed slider, and the sequence editor

على الهاتف يتكيف التصميم مع حجم الشاشة: تترتب أشرطة التمرير عموديًا، وينتقل محرر التسلسلات إلى أسفل عناصر التحكم. لقطة شاشة الهاتف مأخوذة من إصدار سابق من البرنامج الثابت كان يستخدم العنوان الافتراضي لـ ESP32 وهو 192.168.4.1؛ أما البرنامج الثابت الحالي فيستخدم 10.10.10.1.

The web interface on a phone with the sliders stacked vertically

9. الاختبار والتحقق

اختبار نطاق حركة السيرفو

لكل سيرفو نطاق اسمي يبلغ 0-180 درجة، لكن الحدود الميكانيكية وتفاوتات الطباعة قد تقلّصه. أرسل كل مفصل إلى 0 ثم إلى 180، وتحقق من أنه يصل إلى الطرفين دون أن يعلق:

Serial commands:
1,0    (J1 fully counterclockwise)
1,180  (J1 fully clockwise)
2,0    (J2 lowered)
2,180  (J2 raised)
3,0    (J3 elbow down)
3,180  (J3 elbow up)
4,0    (J4 forearm rotation, one end stop)
4,180  (J4 forearm rotation, other end stop)
5,0    (J5 wrist tilt, one end stop)
5,180  (J5 wrist tilt, other end stop)
6,0    (Gripper open)
6,180  (Gripper closed)

بعد كل أمر، أنصت لأي صوت طحن، وتحسّس المفصل بيدك للتأكد من أنه لا يعلق. إذا لم يصل أحد المفاصل إلى حدّه بسلاسة، فأرخِ عناصر التثبيت وحاول مرة أخرى، وتأكد من أن أذرع السيرفو مركّبة بالكامل على المحاور.

اختبار الدقة

أرسل أمرًا بزاوية معينة، ثم قِس الزاوية الفعلية بمنقلة. يجب أن يستقر المفصل ضمن هامش 5 درجات. إذا كان الانحراف ثابتًا بالمقدار نفسه دائمًا، فهذا يعني أن ذراع السيرفو (Horn) مركّب بزاوية خاطئة: كرّر الخطوة 0 لهذا السيرفو.

اختبار السرعة

أرسل حركة مسح من 0 إلى 180 وقِس زمنها:

1,0    (note the time)
1,180  (note the new time)

صوّر حركة المسح بهاتفك (بالتصوير البطيء إن توفر) وعُدّ الإطارات من البداية حتى التوقف. عند السرعة الافتراضية (5 درجات كل 20 ms = 250 درجة في الثانية) تستغرق حركة مسح بمقدار 180 درجة نحو 720 ms؛ وقد قست 750 ms. إذا اختلفت نتيجتك كثيرًا، فاضبط moveSpeed باستخدام الأمر SPEED.

اختبار الحمولة

يزن القابض نفسه 50 g. ضع أوزان اختبار في القابض وتحقق من أن الذراع تحافظ على وضعها.

  • أرسل الأمر HOME. عند 0° (وضع البداية) يتجه العضد إلى الأمام ويكون الساعد مستقيمًا (الخطوتان 6 و9): أي امتداد أفقي كامل، حيث يبلغ عزم الدوران على الكتف أعلى قيمة له.
  • ثبّت وزنًا مقداره 50 g في القابض وراقب أي انحراف تدريجي. يجب أن تحافظ الذراع على وضعها دون اهتزاز.
  • كرّر الاختبار بأوزان 100 g و150 g (مع تركيب نابض المساعدة) و200 g، وقارن النتائج بجدول النتائج أدناه. لا تشغّل الذراع بحمولة 200 g: فهي تتجاوز المواصفات.

اختبار مدى الواي فاي

اختبر واجهة الويب من مسافات مختلفة وعبر الجدران:

  • 2 m، مع خط رؤية مباشر: لا يوجد تأخير محسوس.
  • 5 m، عبر جدار: لا يزال الأداء جيدًا.
  • 10 m: التأخير ملحوظ، لكن الذراع لا تزال تستجيب.
  • في مكان مفتوح: نحو 15 m مع خط رؤية مباشر (حسب قياسي).
  • لا يعمل الاتصال عبر المنزل بأكمله. يشغّل ESP32 نقطة وصول خاصة به ولا يستخدم جهاز التوجيه (الراوتر) لديك.

اختبار قابلية التكرار

أرسل الذراع إلى وضع معين (مثلًا 45, 90, 90, 90, 90, 90)، ثم أعدها إلى وضع البداية، ثم أرجعها إلى الوضع نفسه. قِس الخطأ بمنقلة أو بالعين. كرّر ذلك 10 مرات. إذا تزايد الخطأ، فهذا يشير إلى مشكلة في السيرفو أو إلى عنصر تثبيت مرتخٍ.

اختبار تشغيل التسلسل

سجّل تسلسل التقاط ووضع بسيطًا: أنزل الذراع، وأغلق القابض، وارفع الذراع، وحرّكها إلى الجانب، وأنزلها، وافتح القابض، ثم أعِدها إلى وضعها الأول. شغّل التسلسل 10 مرات. يشير تخطّي بعض الخطوات أو الارتجاف إلى مشكلة في البرنامج الثابت (غالبًا تجاوز سعة المخزن المؤقت إذا كان التسلسل طويلًا جدًا).

ملخص نتائج الاختبارات

القياسات على ذراع OmArm Zero العاملة لدي:

الاختبارالنتيجةملاحظات
نطاق السيرفوحركة كاملة 0-180°حركة سلسة دون تعليق
الدقة (السكونية)±3 درجاتضمن المواصفات
السرعة (مسح 0-180)750 msيطابق القيمة النظرية 5 درجات/20 ms
حمولة 50 gتحافظ على وضعهالا يوجد انثناء مرئي
حمولة 100 gتحافظ على وضعهاانثناء طفيف عند أقصى مدى للوصول
حمولة 150 gتحافظ على وضعها مع النابضاهتزاز طفيف، مقبول
حمولة 200 gالسيرفو يعمل تحت إجهادغير موصى به
مدى الواي فاي15 m مع خط رؤية مباشرمعتاد في وضع نقطة الوصول (AP)
قابلية التكرار±2 درجةثابتة على مدى 10 دورات
تشغيل التسلسل10/10 مرات تشغيل متطابقةدون تخطّي أي خطوة

إذا فشل أحد الاختبارات، فاستعن بهذا الجدول:

العَرَضالفحصالحل
لا يتحرك أي سيرفوقِس وجود 5 V على الطرف V+ في PCA9685؛ وشغّل برنامج مسح I2Cأصلح توصيلات مزود الطاقة؛ وأصلح SDA/SCL أو عنوان I2C
سيرفو واحد لا يتحرك، والبقية تتحركوصّل سيرفو آخر بالقناة نفسها؛ وتحقق من رقم القناة في getChannelForServo()إذا تحرك السيرفو الثاني، فالأول تالف: استبدله. وإذا لم يتحرك هو أيضًا، فصحّح رقم القناة؛ وإذا كان الرقم صحيحًا، فهذه القناة لا تُخرج إشارة PWM
السيرفو يتحرك لكنه ضعيفقِس جهد مزود الطاقة بجهاز ملتيميتر أثناء الحركةإذا هبط الجهد نحو 4 V، فمزود الطاقة أصغر من اللازم: استخدم مزودًا أكبر
السيرفو يرتجفالمكثف على V+ وGND؛ سرعة الحركةالحم مكثفًا كهروليتيًا بسعة 470-1000 µF بين V+ وGND على PCA9685 (في اللوحة مكان مخصص له)، وخفّض السرعة (SPEED:2,20)
الذراع تتحرك إلى زوايا خاطئةموضع ذراع السيرفو (Horn) على محور السيرفوكرّر الخطوة 0 لهذا السيرفو
الواي فاي بطيءأجسام معدنية قريبة من الذراع أو من هوائي ESP32أبعد الهوائي (إذا كان خارجيًا) أو الذراع عن الأجسام المعدنية؛ فالمعدن يحجب إشارة الواي فاي على 2.4 GHz

10. ما القادم

الجزء 2 يصدّر نموذج CAD إلى URDF ويعرض الذراع في RViz (عارض 3D الخاص بـ ROS 2) وفي محاكي الروبوتات Gazebo. الجزء 3 يضيف تخطيط الحركة باستخدام MoveIt 2. تضبط أشرطة التمرير زوايا المفاصل؛ أما في المهام الحقيقية فتحدد موضعًا بدلًا من ذلك، مثل (200, 100, 50) mm، ويحسب MoveIt زوايا المفاصل (الحركيات العكسية). الجزء 4 يضيف كاميرا وعلامات ArUco (مربعات مطبوعة بالأبيض والأسود يمكن للكاميرا تحديد موقعها) لمهام الالتقاط والوضع.

تنزيل ملفات المشروع

تحتوي حزمة OmArm Zero الجزء 1 (14.90 €) على ملفات STL وSTEP و3MF (ملف 3MF هو مشروع Bambu Studio)، والبرنامج الثابت، ومخطط التوصيل، ودليل التجميع بصيغة PDF. أما البرنامج الثابت وحده فمتاح مجانًا على GitHub.

البرنامج الثابت مرخّص بموجب رخصة MIT. ملفات CAD ودليل PDF مخصصة للاستخدام الشخصي: اطبع الذراع وجمّعها وعدّلها لنفسك، لكن يُرجى عدم مشاركة الملفات أو بيعها.

ملاحظات ختامية

إذا واجهتك مشكلة، فراجع أولًا قسم Issues على GitHub؛ وإذا لم تجد سؤالك هناك، فافتح Issue جديدًا. وصلتني مئات الأسئلة حول الذراع الأصلية بست درجات حرية (6-DOF)، وأحاول الإجابة عنها جميعًا. إذا بنيت نسختك الخاصة أو عدّلت التصميم، فاترك تعليقًا أدناه أو أشِر إليّ (Tag) في منشورك.

مقارنة بين كل أذرعي الروبوتية: كيف تبني ذراعًا روبوتية بست محاور في المنزل يشرح ماذا يعني ست محاور، ويضع التصاميم الأربعة في جدول واحد، ويخبرك أيها تبني أولًا.

رأي واحد حول “OmArm Zero: Build a 6-DOF (5+1 Gripper) Robotic Arm with ESP32 Web Control”

أضف تعليق