في مقال برمجة الطائرات المسيرة كتبنا برنامج Python صغيراً بمكتبة MAVSDK يعطي الطائرة مهمة ويراقبها، وشغلناه على PX4 في محاكاة SITL. وهذا يكفي لمهمة تمر بنقاط، ولكن المشاريع الحقيقية تحتاج أكثر من ذلك: كود يعمل على حاسوب مرافق Companion Computer فوق الطائرة نفسها، يقرأ كاميرا أو LiDAR ويقرر أين تذهب الطائرة عشرات المرات في الثانية، ثم طريقة لتختبر هذا الكود بأمان قبل أن يطير، وطريقة لتعرف ماذا حدث إذا لم يطر كما توقعت. وفي هذا المقال سوف نبني هذه السلسلة كلها على PX4 وROS 2، ثم ننظر كيف يبدو التطوير نفسه في منصة DJI المغلقة.
وسوف نناقش في هذا المقال ما يلي:
- مسار التطوير من المحاكاة SITL إلى HITL إلى الحقل، وما الذي يكشفه كل منها.
- الحاسوب المرافق، ولماذا ROS 2، وكيف يتصل ب PX4 عبر uXRCE-DDS.
- وضع Offboard الذي يتحكم فيه كودك في الطائرة، وما يشترطه PX4 حتى يقبله.
- تجربة عملية: عقدة ROS 2 Node تطير مربعاً في SITL، ثم نوقفها فجأة كأن الحاسوب المرافق تعطل.
- سجل الطيران ULog: كيف تقرؤه بأدوات pyulog وبكود Python صغير.
- اختبار المحاكاة في CI، وشكل التطوير بمكتبة DJI Mobile SDK دون أن نفتح حساباً.
من المحاكاة إلى الحقل
الحل البسيط أن تكتب الكود وتحمله على الطائرة وتجربه في الحديقة، والمشكلة أن كل خطأ في هذه الطريقة ثمنه طائرة، وأحياناً ما هو أغلى من الطائرة. لذلك يمر كل تعديل بثلاث مراحل، والصورة التالية تبين ما هو حقيقي وما هو محاكاة في كل منها:

- SITL: كود PX4 الحقيقي يعمل على جهازك مع محاكي فيزياء، وقد شرحناه في مقال البرمجة. وهنا تكتشف أخطاء المنطق: أمر في غير وقته، أو حالة لم تتوقعها، أو رد فعل الحماية.
- HITL: واختصارها Hardware In The Loop، ويقول توثيق PX4 إن البرنامج العادي ل PX4 يعمل فيها على المتحكم الحقيقي بوضع HITL، بينما تأتي قراءات الحساسات من محاكي على الحاسوب، وإن المحاكيين المدعومين لها في الإصدار 1.17 هما Gazebo Classic للطائرات متعددة المراوح وVTOL وjMAVSim للرباعية فقط، وإن هذه الميزة يدعمها المجتمع ويصونها. وفائدتها أنها تكشف ما لا يكشفه SITL: معالج المتحكم الأبطأ، وإعداد المنافذ الحقيقي، والتوقيت.
- الحقل: طيران حقيقي على ارتفاع منخفض في مكان مفتوح، وطيار يمسك جهاز التحكم ويستطيع أن يأخذ التحكم في أي لحظة، ثم قراءة السجل بعد كل طيران.
الحاسوب المرافق وإطار ROS 2 للروبوتات
الحاسوب المرافق Companion Computer حاسوب صغير يركب على الطائرة بجانب المتحكم، مثل Raspberry Pi أو NVIDIA Jetson، ويعمل عليه Linux وكودك، ويتصل بالمتحكم بكابل تسلسلي أو Ethernet. والسبب أنك لا تضع منطق الرؤية والتخطيط داخل الطيار الآلي نفسه، فالمتحكم معالج صغير مهمته الأولى أن يبقي الطائرة في الهواء، وكل تعقيد إضافي فيه خطر على هذه المهمة. لذلك يبقى الطيار الآلي مسؤولاً عن الاستقرار والحماية، ويرسل له الحاسوب المرافق «إلى أين» فقط.
وأما ROS 2 فهو إطار Framework مفتوح المصدر لبرمجة الروبوتات، والكود فيه عقد Nodes منفصلة تتبادل الرسائل على مواضيع Topics، فعقدة تقرأ الكاميرا وأخرى تكتشف العوائق وثالثة تقرر المسار، وكلها تتحدث بطريقة واحدة. ويوصي دليل ROS 2 في توثيق PX4 بالإصدار Humble على Ubuntu 22.04.
كيف تصل رسائل ROS 2 إلى الطيار الآلي
في مقال البرمجة تحدث برنامجنا مع PX4 بلغة MAVLink، أما ROS 2 فيتحدث مع PX4 بطريقة أخرى اسمها uXRCE-DDS، ويقول توثيقها إنها تجعل رسائل PX4 الداخلية، واسمها uORB، تظهر كمواضيع ROS 2 مباشرة، وإنها من جزأين: عميل Client يعمل داخل PX4، ووكيل Agent يعمل على الحاسوب المرافق، وبينهما رابط تسلسلي أو UDP، والمنفذ الافتراضي في المحاكاة 8888. والصورة التالية تبين ذلك:

وأهم ما في هذا الترتيب لك كمطور:
- المواضيع التي يرسلها PX4 تبدأ بـ
/fmu/out/والتي يستقبلها تبدأ بـ/fmu/in/، والملفdds_topics.yamlفي كود PX4 يحدد أيها يظهر. - تعريفات الرسائل في حزمة
px4_msgs، ويجب أن يطابق فرعها إصدار PX4 الذي تعمل عليه، ولهذا نبني في التجربة الفرعrelease/1.17. ويذكر دليل ROS 2 في PX4 أن الإصدار 1.16 أضاف ترقيم إصدارات الرسائل Message Versioning، وأن عقدة الترجمة Message Translation Node تلزمك إذا اختلفت إصدارات الرسائل بين كودك والطيار الآلي. - هذه الطريقة حلت في PX4 v1.14 محل الجسر القديم Fast-RTPS Bridge الذي كان يحتاج نسخة خاصة من البرنامج، فإذا وجدت مثالاً يذكر
microRTPSفهو قديم.
وضع Offboard الذي يقود فيه كودك الطائرة
في وضع Offboard يتبع PX4 الأهداف Setpoints التي يرسلها كودك: موقعاً أو سرعة أو ميلاً، ويبقى هو مسؤولاً عن تثبيت الطائرة والحماية. ولكن له شرطان تذكرهما صفحة الوضع: أن يصله دليل حياة Proof of Life بشكل مستمر بمعدل أعلى من 2 هرتز، أي رسالة OffboardControlMode أو أهداف متتابعة، وإلا خرج من الوضع، وأن يبدأ هذا البث قبل أن تطلب التحويل إلى Offboard أو التشغيل فيه. وإذا انقطع البث ينتظر PX4 المدة COM_OF_LOSS_T ثم ينفذ ما في المعامل COM_OBL_RC_ACT، وقيمه تبدأ من وضع تثبيت الموقع Position إلى العودة Return والهبوط Land.
وقد يتساءل البعض: لماذا هذا الشرط؟ والإجابة أن الحاسوب المرافق قد يتجمد أو يعيد التشغيل أو ينقطع كابله، والطائرة في الهواء لا تستطيع أن تنتظر، فإذا لم يسمع الطيار الآلي من كودك أخذ القرار بنفسه، وهذا ما سوف نكسره في التجربة.
التجربة العملية: عقدة ROS 2 تطير مربعاً
المختبر ثلاثة أجزاء على شبكة Docker واحدة: PX4 v1.17.0 في SITL كما في مقال البرمجة، وContainer فيه ROS 2 Humble والوكيل وتعريفات الرسائل وعقدتنا، ثم أدوات قراءة السجل. والوكيل والعقدة يعملان في شبكة Container ال PX4 نفسها، والسبب أن العميل داخل PX4 في المحاكاة يتصل بالعنوان 127.0.0.1 على المنفذ 8888 كما يظهر في سجله.
وملف Dockerfile يبني الوكيل من مصدره بالإصدار v2.4.3 الذي يذكره دليل PX4، ثم يبني px4_msgs من الفرع المطابق لإصدار PX4، ثم يثبت pyulog:
# ROS 2 Humble + Micro XRCE-DDS Agent + px4_msgs (release/1.17) + our offboard node and log tools
FROM ros:humble-ros-base-jammy
SHELL ["/bin/bash", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends git cmake g++ python3-pip python3-colcon-common-extensions \
&& rm -rf /var/lib/apt/lists/*
# the agent PX4 talks to (version from the PX4 ROS 2 user guide)
RUN git clone --depth 1 -b v2.4.3 https://github.com/eProsima/Micro-XRCE-DDS-Agent.git /opt/agent \
&& cmake -S /opt/agent -B /opt/agent/build && cmake --build /opt/agent/build -j6 \
&& cmake --install /opt/agent/build && ldconfig /usr/local/lib/
# message definitions that match the PX4 release we fly
WORKDIR /ws
RUN git clone --depth 1 -b release/1.17 https://github.com/PX4/px4_msgs.git src/px4_msgs \
&& source /opt/ros/humble/setup.bash && colcon build --packages-select px4_msgs --parallel-workers 6
RUN pip3 install --no-cache-dir pyulog==1.2.4
COPY offboard_square.py analyze_log.py /lab/
COPY entry.sh /entry.sh
ENTRYPOINT ["/entry.sh"]وبناء هذه Docker Image أخذ على جهازنا أكثر من ساعة، أغلبها في بناء مكتبة Fast DDS التي يبنيها الوكيل معه، فإذا كنت ستعيد التجربة كثيراً فاحتفظ بها. أما ملف entry.sh فيحمل بيئة ROS 2 وبيئة الرسائل قبل أي أمر:
#!/bin/bash
source /opt/ros/humble/setup.bash
source /ws/install/setup.bash
exec "$@"والعقدة offboard_square.py قرابة 120 سطراً، تجدها كاملة في البطاقة التالية:
offboard_square.py كاملاً
"""ROS 2 offboard node for PX4 (uXRCE-DDS): arm, switch to Offboard, fly a 10 m square at 5 m, land.
python3 offboard_square.py normal run
python3 offboard_square.py --crash 25 stop every publisher after 25 s in Offboard (a companion computer that hangs)
Topic names come from the PX4 release (px4_msgs release/1.17); versioned messages carry a _vN suffix.
"""
import sys
import time
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy, DurabilityPolicy
from px4_msgs.msg import OffboardControlMode, TrajectorySetpoint, VehicleCommand, VehicleLocalPosition, VehicleStatus
NAV = {0: "MANUAL", 2: "POSCTL", 3: "AUTO_MISSION", 4: "AUTO_LOITER", 5: "AUTO_RTL", 14: "OFFBOARD",
17: "AUTO_TAKEOFF", 18: "AUTO_LAND"}
SQUARE = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)] # north, east in metres
ALT = -5.0 # NED: up is negative
class OffboardSquare(Node):
def __init__(self, crash_after):
super().__init__("offboard_square")
qos = QoSProfile(reliability=ReliabilityPolicy.BEST_EFFORT, durability=DurabilityPolicy.TRANSIENT_LOCAL,
history=HistoryPolicy.KEEP_LAST, depth=1)
self.mode_pub = self.create_publisher(OffboardControlMode, "/fmu/in/offboard_control_mode", qos)
self.sp_pub = self.create_publisher(TrajectorySetpoint, "/fmu/in/trajectory_setpoint", qos)
self.cmd_pub = self.create_publisher(VehicleCommand, "/fmu/in/vehicle_command", qos)
self.create_subscription(VehicleLocalPosition, "/fmu/out/vehicle_local_position_v1", self.on_pos, qos)
self.create_subscription(VehicleStatus, "/fmu/out/vehicle_status_v1", self.on_status, qos)
self.pos, self.status, self.nav = None, None, None
self.ticks, self.wp, self.offboard_since, self.crash_after = 0, 0, None, crash_after
self.t0 = time.monotonic()
self.timer = self.create_timer(0.1, self.tick) # 10 Hz: PX4 wants the heartbeat above 2 Hz
def log(self, msg):
print(f"[{time.monotonic() - self.t0:6.1f}s] {msg}", flush=True)
def now_us(self):
return int(self.get_clock().now().nanoseconds / 1000)
def on_pos(self, m):
self.pos = m
def on_status(self, m):
if self.nav != m.nav_state:
self.log(f"nav_state {NAV.get(m.nav_state, m.nav_state)} armed={m.arming_state == 2} failsafe={m.failsafe}")
self.nav = m.nav_state
self.status = m
def command(self, cmd, p1=0.0, p2=0.0):
m = VehicleCommand()
m.command, m.param1, m.param2 = cmd, float(p1), float(p2)
m.target_system, m.target_component, m.source_system, m.source_component = 1, 1, 1, 1
m.from_external, m.timestamp = True, self.now_us()
self.cmd_pub.publish(m)
def tick(self):
if self.crash_after is not None and self.offboard_since and time.monotonic() - self.offboard_since > self.crash_after:
if self.timer is not None:
self.log("simulated hang: no more offboard_control_mode or setpoints")
self.timer.cancel()
self.timer = None
self.create_timer(1.0, self.watch_only)
return
hb = OffboardControlMode()
hb.position, hb.timestamp = True, self.now_us()
self.mode_pub.publish(hb)
n, e = SQUARE[self.wp]
sp = TrajectorySetpoint()
sp.position = [n, e, ALT]
sp.velocity = [float("nan")] * 3
sp.acceleration = [float("nan")] * 3
sp.yaw, sp.timestamp = 0.0, self.now_us()
self.sp_pub.publish(sp)
self.ticks += 1
if self.ticks == 10: # 1 s of setpoints first, or PX4 refuses Offboard
self.command(VehicleCommand.VEHICLE_CMD_DO_SET_MODE, 1, 6) # custom mode, PX4 main mode 6 = Offboard
self.command(VehicleCommand.VEHICLE_CMD_COMPONENT_ARM_DISARM, 1)
self.log("requested Offboard + arm")
if self.nav == 14 and self.offboard_since is None:
self.offboard_since = time.monotonic()
if self.pos is None or self.nav != 14:
return
err = ((self.pos.x - n) ** 2 + (self.pos.y - e) ** 2 + (self.pos.z - ALT) ** 2) ** 0.5
if self.ticks % 20 == 0:
self.log(f"wp {self.wp} target ({n:4.1f},{e:4.1f},{-ALT:3.1f}) at ({self.pos.x:5.1f},{self.pos.y:5.1f},{-self.pos.z:4.1f}) err {err:4.1f} m")
if err < 0.5:
if self.wp == len(SQUARE) - 1:
self.log("square done, landing")
self.command(VehicleCommand.VEHICLE_CMD_NAV_LAND)
self.timer.cancel()
self.create_timer(1.0, self.watch_only)
else:
self.wp += 1
self.log(f"reached, next waypoint {self.wp}")
def watch_only(self):
if self.status is not None and self.status.arming_state != 2 and self.pos is not None and self.pos.z > -0.3:
self.log(f"disarmed at ({self.pos.x:5.1f},{self.pos.y:5.1f}), done")
raise SystemExit(0)
def main():
crash = float(sys.argv[sys.argv.index("--crash") + 1]) if "--crash" in sys.argv else None
rclpy.init()
node = OffboardSquare(crash)
try:
rclpy.spin(node)
except SystemExit:
pass
node.destroy_node()
rclpy.try_shutdown()
if __name__ == "__main__":
main()
في الكود أعلاه لاحظ التالي:
- ملف QoS بموثوقية
BEST_EFFORTلكل الاشتراكات والنشر، لأن PX4 ينشر مواضيعه بهذا الإعداد، والاشتراك بإعداد Reliable لا يستقبل منها شيئاً. - المؤقت يعمل 10 مرات في الثانية، وفي كل مرة ينشر
OffboardControlModeمعposition=Trueثم الهدفTrajectorySetpoint، وهذا هو دليل الحياة الذي يشترطه PX4. - طلب وضع Offboard والتشغيل لا يرسل إلا بعد 10 دورات، أي بعد ثانية من البث، والسبب شرط PX4 أن يصله البث قبل التحويل.
- الأوامر من نوع
VehicleCommand، والرقم 6 فيDO_SET_MODEهو الوضع الرئيسي Offboard في PX4. - المحاور NED: الشمال ثم الشرق ثم الأسفل، لذلك الارتفاع 5 أمتار هو
-5.0. - الخيار
--crashيوقف المؤقت كله بعد عدد من الثواني في Offboard، فلا يصل PX4 أي دليل حياة، كأن الحاسوب المرافق تجمد، ويبقى الاشتراك فقط لنرى ماذا يحدث.
تشغيل العقدة
الأوامر التالية تشغل PX4، ثم تشغل الوكيل في شبكة Container ال PX4، ثم تعرض المواضيع التي ظهرت في ROS 2:
docker build -t lab-drone2-ros:1 .
docker network create lab-drone2-net
docker run -dit --name lab-drone2-px4 --network lab-drone2-net jonasvautherin/px4-gazebo-headless:1.17.0
docker exec lab-drone2-px4 bash -c 'cd /root/px4/build; ./bin/px4-param set NAV_DLL_ACT 0'
docker run -d --name lab-drone2-ros --network container:lab-drone2-px4 lab-drone2-ros:1 MicroXRCEAgent udp4 -p 8888
docker exec lab-drone2-ros /entry.sh bash -c 'ros2 topic list | sort'ولاحظ أننا ألغينا فعل فقدان محطة التحكم NAV_DLL_ACT للسبب نفسه الذي شرحناه في مقال التجميع: لا توجد محطة تحكم في هذه التجربة، وPX4 يرفض التشغيل دونها. والمخرج مختصراً على بعض المواضيع:
/fmu/in/offboard_control_mode
/fmu/in/trajectory_setpoint
/fmu/in/vehicle_attitude_setpoint_v1
/fmu/in/vehicle_command
/fmu/in/vehicle_visual_odometry
/fmu/out/battery_status_v1
/fmu/out/failsafe_flags
/fmu/out/vehicle_attitude
/fmu/out/vehicle_local_position_v1
/fmu/out/vehicle_status_v1
/fmu/out/windظهر 65 موضوعاً تحت /fmu، ولاحظ اللاحقة _v1 في بعضها، وهي ترقيم إصدار الرسالة الذي ذكرناه، ولهذا يشترك الكود في vehicle_status_v1 وليس vehicle_status، فإذا اشتركت بالاسم القديم من مثال قديم فلن يصلك شيء ولن ترى خطأ. الآن نشغل العقدة:
docker exec lab-drone2-ros /entry.sh python3 -u /lab/offboard_square.py[ 0.1s] nav_state AUTO_LOITER armed=False failsafe=False
[ 1.0s] requested Offboard + arm
[ 1.2s] nav_state OFFBOARD armed=True failsafe=False
[ 2.0s] wp 0 target ( 0.0, 0.0,5.0) at ( -0.0, -0.0,-0.5) err 5.5 m
[ 4.0s] wp 0 target ( 0.0, 0.0,5.0) at ( -0.0, -0.0,-0.6) err 5.6 m
[ 6.0s] wp 0 target ( 0.0, 0.0,5.0) at ( -0.0, 0.0,-0.6) err 5.6 m
[ 8.0s] wp 0 target ( 0.0, 0.0,5.0) at ( 0.1, 0.0, 1.4) err 3.6 m
[ 9.9s] reached, next waypoint 1
[ 10.0s] wp 1 target (10.0, 0.0,5.0) at ( -0.1, 0.1, 4.6) err 10.1 m
[ 12.0s] wp 1 target (10.0, 0.0,5.0) at ( 6.5, -1.1, 5.2) err 3.7 m
[ 13.4s] reached, next waypoint 2
[ 14.0s] wp 2 target (10.0,10.0,5.0) at ( 10.1, 0.5, 5.4) err 9.5 m
[ 15.9s] reached, next waypoint 3
[ 16.0s] wp 3 target ( 0.0,10.0,5.0) at ( 10.1, 10.0, 5.1) err 10.1 m
[ 18.0s] wp 3 target ( 0.0,10.0,5.0) at ( 3.3, 10.6, 5.0) err 3.4 m
[ 19.0s] reached, next waypoint 4
[ 20.0s] wp 4 target ( 0.0, 0.0,5.0) at ( -0.3, 8.9, 5.3) err 8.9 m
[ 22.0s] wp 4 target ( 0.0, 0.0,5.0) at ( -0.2, 1.1, 5.1) err 1.1 m
[ 22.4s] square done, landing
[ 22.4s] nav_state AUTO_LAND armed=True failsafe=False
[ 38.6s] nav_state OFFBOARD armed=False failsafe=False
[ 39.4s] disarmed at ( 0.1, -2.8), doneفي المخرج أعلاه لاحظ التالي:
- بعد ثانية من البث قبل PX4 وضع Offboard والتشغيل معاً، وبدأت الطائرة تصعد إلى 5 أمتار ثم طارت المربع: 10 أمتار شمالاً ثم شرقاً ثم جنوباً ثم غرباً.
- الخطأ يقفز إلى 10 أمتار تقريباً عند كل نقطة جديدة لأن الهدف يتغير دفعة واحدة، فالعقدة ترسل النقطة التالية مباشرة، وPX4 هو الذي يحسب الطريق إليها ويحد السرعة والتسارع.
- بعد آخر نقطة أرسلت العقدة أمر الهبوط، فتحول الوضع إلى
AUTO_LANDثم أطفأ PX4 المحركات بعد الهبوط. ولاحظ أن موضع الهبوط بعيد قرابة 2.8 متر غرباً عن نقطة البداية، فأمر الهبوط يهبط حيث الطائرة، ولا يعود إلى نقطة محددة، والطائرة كانت ما زالت تتحرك حين صدر.
نكسر الإعداد: الحاسوب المرافق يتجمد
قبل الكسر نقرأ ما سوف يفعله PX4 إذا انقطع دليل الحياة، من المعاملات في المحاكاة نفسها:
docker exec lab-drone2-px4 bash -c 'cd /root/px4/build; for p in COM_OF_LOSS_T COM_OBL_RC_ACT COM_RC_IN_MODE; do
./bin/px4-param show $p | grep -E " $p "; done'x COM_OF_LOSS_T [285,509] : 1.0000
x COM_OBL_RC_ACT [284,508] : 0
x COM_RC_IN_MODE [296,520] : 1أي أن PX4 ينتظر ثانية واحدة، ثم ينتقل إلى الوضع 0 وهو تثبيت الموقع Position، وأن التحكم اليدوي من MAVLink فقط لأنه لا يوجد جهاز تحكم في المحاكاة. الآن نعيد التجربة على Container جديد من PX4، ونطلب من العقدة أن تتجمد بعد 12 ثانية في Offboard:
docker rm -f lab-drone2-ros lab-drone2-px4
docker run -dit --name lab-drone2-px4 --network lab-drone2-net jonasvautherin/px4-gazebo-headless:1.17.0
docker exec lab-drone2-px4 bash -c 'cd /root/px4/build; ./bin/px4-param set NAV_DLL_ACT 0'
docker run -d --name lab-drone2-ros --network container:lab-drone2-px4 lab-drone2-ros:1 MicroXRCEAgent udp4 -p 8888
docker exec lab-drone2-ros /entry.sh python3 -u /lab/offboard_square.py --crash 12
docker logs -t lab-drone2-px4 | grep -E "commander|failsafe|navigator"[ 0.1s] nav_state AUTO_LOITER armed=False failsafe=False
[ 1.0s] requested Offboard + arm
[ 1.0s] nav_state OFFBOARD armed=True failsafe=False
[ 2.0s] wp 0 target ( 0.0, 0.0,5.0) at ( -0.0, 0.0, 0.0) err 5.0 m
[ 4.0s] wp 0 target ( 0.0, 0.0,5.0) at ( -0.0, -0.0, 0.0) err 5.0 m
[ 6.0s] wp 0 target ( 0.0, 0.0,5.0) at ( 0.1, 0.0, 1.8) err 3.2 m
[ 8.0s] wp 0 target ( 0.0, 0.0,5.0) at ( -0.1, 0.1, 4.3) err 0.7 m
[ 8.9s] reached, next waypoint 1
[ 10.0s] wp 1 target (10.0, 0.0,5.0) at ( 1.7, -0.5, 4.9) err 8.3 m
[ 11.8s] reached, next waypoint 2
[ 12.0s] wp 2 target (10.0,10.0,5.0) at ( 10.0, -0.2, 4.8) err 10.2 m
[ 13.2s] simulated hang: no more offboard_control_mode or setpoints
[ 14.5s] nav_state AUTO_RTL armed=True failsafe=True
[ 75.7s] nav_state OFFBOARD armed=False failsafe=False
[ 76.2s] disarmed at ( 0.0, -0.0), done
2026-10-12T12:03:33.591011185Z INFO [commander] Takeoff detected
2026-10-12T12:03:45.039755557Z WARN [failsafe] Failsafe activated
2026-10-12T12:03:45.111547085Z INFO [navigator] RTL: start return at 31 m (30 m above destination)
2026-10-12T12:04:08.210004632Z INFO [navigator] RTL: land at destination
2026-10-12T12:04:42.413621236Z INFO [commander] Landing detected
2026-10-12T12:04:46.385621037Z INFO [commander] Disarmed by landingفي المخرج أعلاه لاحظ التالي:
- بين توقف العقدة وإعلان الحماية 1.3 ثانية، أي مهلة
COM_OF_LOSS_Tوهي ثانية واحدة، مع تأخر بسيط. - الإعداد يقول وضع تثبيت الموقع، ولكن PX4 اختار العودة RTL، والسبب أن وضع Position وضع يدوي يحتاج من يقود الطائرة، ولا يوجد جهاز تحكم، فاختار PX4 فعلاً يستطيع أن ينفذه وحده. ولهذا لا تفترض أن القيمة التي في المعامل هي ما سيحدث، واختبرها في SITL بالظروف نفسها التي ستطير بها.
- العودة بدأت بالصعود إلى 30 متراً فوق نقطة الإقلاع، كما رأينا في مقال البرمجة، ثم عادت الطائرة وهبطت في نقطة البداية، وهذا أفضل بكثير من أن تبقى في الهواء تنتظر كوداً لن يعود.
قراءة سجل الطيران ULog بالكود
كل ما رأيناه في الكسر عرفناه من رسائل PX4 ومن مخرج عقدتنا، ولكن في طائرة حقيقية لا تكون أمامك شاشة وقت المشكلة، وإنما سجل الطيران. وPX4 يكتب سجلاً بصيغة ULog، وفي هذه المحاكاة يبدأ التسجيل من التشغيل حتى الإطفاء لأن المعامل SDLOG_MODE قيمته 1، والملف في المجلد build/rootfs/log داخل Container. ويذكر توثيق تحليل السجلات ثلاث أدوات رئيسية: Flight Review على logs.px4.io وهو موقع ترفع إليه السجل فيرسم لك تقريراً، وPlotJuggler برنامج سطح مكتب يرسم أي موضوع، وpyulog مكتبة Python مع أوامر جاهزة. ولاحظ أن رفع السجل إلى موقع عام يعني أن بيانات طيرانك ومواقعه خرجت من جهازك، فإذا كان الطيران لجهة عملك فاستخدم الأدوات المحلية.
الأوامر التالية تنسخ سجل الطيرة الأولى من Container ال PX4، ثم تقرأ ملخصه ورسائله بأوامر pyulog:
docker cp lab-drone2-px4:/root/px4/build/rootfs/log/2026-10-12/11_59_40.ulg normal.ulg
docker cp normal.ulg lab-drone2-ros:/tmp/normal.ulg
# the second run, after the break, is 12_03_11.ulg in the new PX4 container: copy it the same way as hang.ulg
docker exec lab-drone2-ros /entry.sh bash -c 'cd /tmp; ulog_info normal.ulg | head -6; ulog_messages normal.ulg | head -8'Logging start time: 0:00:12, duration: 0:01:04
No Dropouts
SW Version: v1.17.0
Info Messages:
boot_time_utc_us: 1791806368083657
metadata_events_sha256: 3e07d215b9591c01d9014acff13d780e310b058438b0d7f0423b23bc9fc1f59d
0:00:12.600 INFO: [logger] logger started (mode=all)
0:00:12.604 INFO: logging: opening log file 2026-10-12/11_59_40.ulg
0:00:12.604 INFO: [logger] Start file log (type: full)
0:00:12.604 INFO: [logger] Opened full log file: ./log/2026-10-12/11_59_40.ulg
0:00:12.616 INFO: [mavlink] MAVLink only on localhost (set param MAV_{i}_BROADCAST = 1 to enable network)
0:00:12.616 INFO: [mavlink] MAVLink only on localhost (set param MAV_{i}_BROADCAST = 1 to enable network)
0:00:12.640 INFO: [px4] Startup script returned successfully
0:00:12.668 WARNING: [health_and_arming_checks] Preflight Fail: No connection to the GCSوالأوامر الجاهزة مفيدة للنظرة الأولى، ولكن الفائدة الحقيقية أن السجل بيانات تقرؤها بكود، فتسأله السؤال الذي يهمك. والسكربت analyze_log.py يطبع تغيرات الوضع، والتحذيرات، وأي علامات حماية Failsafe Flags ظهرت بعد التشغيل وبأي ترتيب، وأعلى ارتفاع وأبعد مسافة:
"""Reads a PX4 ULog file and prints what happened: mode changes, warnings, which failsafe flags fired, and how
high and far the vehicle went. python3 analyze_log.py <file.ulg>"""
import math
import sys
from pyulog import ULog
NAV = {0: "MANUAL", 2: "POSCTL", 3: "AUTO_MISSION", 4: "AUTO_LOITER", 5: "AUTO_RTL", 14: "OFFBOARD",
17: "AUTO_TAKEOFF", 18: "AUTO_LAND"}
ulog = ULog(sys.argv[1])
t0 = int(ulog.start_timestamp)
sec = lambda t: (int(t) - t0) / 1e6
print(f"log length {(int(ulog.last_timestamp) - t0) / 1e6:.1f} s, {len(ulog.data_list)} topics, "
f"{ulog.get_version_info_str()}, dropouts {len(ulog.dropouts)}")
def topic(name):
for d in ulog.data_list:
if d.name == name and d.multi_id == 0:
return d.data
raise KeyError(name)
st = topic("vehicle_status")
last = None
print("\nmode changes:")
for i, t in enumerate(st["timestamp"]):
nav, armed, fs = int(st["nav_state"][i]), int(st["arming_state"][i]) == 2, bool(st["failsafe"][i])
if (nav, armed, fs) != last:
print(f" {sec(t):6.1f}s {NAV.get(nav, nav):12s} armed={armed} failsafe={fs}")
last = (nav, armed, fs)
print("\nwarnings and worse written by PX4:")
for m in ulog.logged_messages:
if m.log_level_str() in ("WARNING", "ERROR", "CRITICAL", "ALERT", "EMERGENCY"):
print(f" {sec(m.timestamp):6.1f}s {m.log_level_str():9s} {m.message}")
ff = topic("failsafe_flags")
armed_from = min(sec(t) for i, t in enumerate(st["timestamp"]) if int(st["arming_state"][i]) == 2)
print(f"\nfailsafe flags that turned on after arming ({armed_from:.1f}s):")
fired = []
for field in ff:
if field == "timestamp" or not field.endswith(("_lost", "_invalid", "_failure", "_low", "_breached")):
continue
for i, t in enumerate(ff["timestamp"]):
if sec(t) > armed_from and ff[field][i]:
fired.append((sec(t), field))
break
for t, field in sorted(fired):
print(f" {t:6.1f}s {field}")
pos = topic("vehicle_local_position")
far = max(math.hypot(x, y) for x, y in zip(pos["x"], pos["y"]))
print(f"\nhighest point {-min(pos['z']):.1f} m, farthest point {far:.1f} m from the start")docker exec lab-drone2-ros /entry.sh bash -c 'cd /tmp; python3 /lab/analyze_log.py normal.ulg; python3 /lab/analyze_log.py hang.ulg'== normal.ulg
log length 64.3 s, 89 topics, v1.17.0, dropouts 0
mode changes:
-0.1s AUTO_LOITER armed=False failsafe=False
35.2s OFFBOARD armed=True failsafe=False
50.6s AUTO_LAND armed=True failsafe=False
63.4s OFFBOARD armed=False failsafe=False
warnings and worse written by PX4:
0.1s WARNING [health_and_arming_checks] Preflight Fail: No connection to the GCS
2.7s ERROR [sensors] Accel #0 fail: TIMEOUT!
25.1s ERROR [sensors] Accel #0 fail: TIMEOUT!
failsafe flags that turned on after arming (35.2s):
35.7s gcs_connection_lost
35.7s manual_control_signal_lost
51.7s offboard_control_signal_lost
highest point 5.4 m, farthest point 14.5 m from the start
== hang.ulg
log length 78.5 s, 90 topics, v1.17.0, dropouts 0
mode changes:
-0.2s AUTO_LOITER armed=False failsafe=False
19.5s OFFBOARD armed=True failsafe=False
31.2s AUTO_RTL armed=True failsafe=True
77.5s OFFBOARD armed=False failsafe=False
warnings and worse written by PX4:
0.6s WARNING [health_and_arming_checks] Preflight Fail: No connection to the GCS
31.2s WARNING [failsafe] Failsafe activated
78.1s WARNING [health_and_arming_checks] Preflight Fail: heading estimate not stable
failsafe flags that turned on after arming (19.5s):
19.7s gcs_connection_lost
19.7s manual_control_signal_lost
31.2s offboard_control_signal_lost
78.1s local_position_invalid
highest point 29.8 m, farthest point 15.5 m from the startفي المخرج أعلاه لاحظ التالي:
- في السجل الثاني تظهر القصة كاملة دون أي شاشة: تشغيل في Offboard عند 19.5 ثانية، ثم علامة
offboard_control_signal_lostوالعودة RTL مع الحماية عند 31.2 ثانية، ثم الإطفاء، وأعلى ارتفاع قرابة 30 متراً هو ارتفاع العودة. - علامتا
gcs_connection_lostوmanual_control_signal_lostظهرتا في الطيرتين بعد التشغيل بلحظات، لأنه لا توجد محطة ولا جهاز تحكم في المحاكاة، وفي طائرة حقيقية ظهور أي منهما يستحق أن تتوقف عنده. - في السجل الأول ظهرت
offboard_control_signal_lostأيضاً، ولكن بعد أن تحولت الطائرة إلى الهبوط، لأن العقدة توقفت عن البث بعد أمر الهبوط، فهي ليست مشكلة، وهذا مثال على أن العلامة وحدها لا تكفي وأن ترتيبها مع الوضع هو الذي يحكي. - رسالة
Accel #0 fail: TIMEOUTشرحناها في مقال الأساسيات، وهي من بطء المحاكاة على جهاز مشغول.
والصورة التالية رسمناها من السجلين نفسيهما بعد تحويل موضوعين منهما إلى CSV بالأمر ulog2csv:

تنظيف المختبر
docker rm -f lab-drone2-ros lab-drone2-px4
docker network rm lab-drone2-net
docker image rm lab-drone2-ros:1اختبار آلي بالمحاكاة مع كل تعديل
ما فعلناه في التجربة يمكن أن يتحول إلى اختبار آلي يعمل مع كل تعديل في الكود، فالمحاكاة تعمل بلا شاشة داخل Docker كما رأيت، والسجل يقرأ بكود. وPX4 نفسه يفعل ذلك، فتقول صفحة اختبارات التكامل إن اختباراته المبنية على MAVSDK مطورة أساساً لتعمل على SITL، وإنها تعمل في التكامل المستمر CI، ويشغلها الأمر التالي بسرعة محاكاة أعلى من الزمن الحقيقي:
test/mavsdk_tests/mavsdk_test_runner.py test/mavsdk_tests/configs/sitl.json --speed-factor 10وفي مشروعك تستطيع أن تبني الشيء نفسه بالقطع التي في هذا المقال: Container من PX4 في SITL، وContainer فيه عقدتك والوكيل، ثم سكربت يقرأ السجل ويفشل الاختبار إذا ظهرت حماية Failsafe أو تجاوز الخطأ بين الهدف والموقع حداً تختاره. وقد شرحنا تشغيل خط CI على سيرفرك في دليل Gitea Actions.
التطوير لطائرات DJI بمكتبتها الرسمية
في مقال المنصات عرفنا مكتبات DJI الثلاث، وهنا ننظر إلى شكل الكود نفسه في Mobile SDK v5. ولم ننشئ حساب مطور ولم نشغل الكود، فما يلي من المستودع الرسمي كما في آخر تعديل عليه في 3 يونيو 2026، والأمثلة فيه برخصة MIT. والخطوات كما يلي:
- تسجل مطوراً في DJI، وتنشئ مفتاح تطبيق App Key مرتبطاً باسم حزمة تطبيقك.
- تضع المفتاح في ملف
AndroidManifest.xmlبالاسمcom.dji.sdk.API_KEY، وفي المثال يأتي من المتغيرAIRCRAFT_API_KEYفيgradle.properties. - تهيئ المكتبة ثم تسجل التطبيق، والتسجيل يحتاج اتصالاً بالإنترنت، ولهذا يعيد المثال المحاولة عندما تعود الشبكة.
- تتعامل مع الطائرة من خلال «مفاتيح» Keys تقرؤها أو تستمع إليها أو تنفذها.
والمقطع التالي من الكود التجريبي في المستودع، ملف MSDKManagerVM.kt، مختصراً على التهيئة والتسجيل:
SDKManager.getInstance().init(appContext, object : SDKManagerCallback {
override fun onRegisterSuccess() {
lvRegisterState.postValue(Pair(true, null))
}
override fun onRegisterFailure(error: IDJIError) {
lvRegisterState.postValue(Pair(false, error))
}
override fun onInitProcess(event: DJISDKInitEvent, totalProcess: Int) {
lvInitProcess.postValue(Pair(event, totalProcess))
// Don't forget to call the registerApp()
if (event == DJISDKInitEvent.INITIALIZE_COMPLETE) {
isInit = true
SDKManager.getInstance().registerApp()
}
}
// onProductConnect, onProductDisconnect, onProductChanged, onDatabaseDownloadProgress ...
})وأمر الإقلاع في الملف BasicAircraftControlVM.kt مفتاح ينفذ:
FlightControllerKey.KeyStartTakeoff.create().action({
callback.onSuccess(it)
}, { e: IDJIError ->
callback.onFailure(e)
})في الكود أعلاه لاحظ التالي:
- لا توجد هنا رسائل ولا بروتوكول تراه، فكل شيء خلف المفاتيح، وهذا هو الفرق العملي مع MAVLink وuXRCE-DDS: أنت لا ترى ما يحدث تحت المكتبة، ولا تستطيع أن تضيف ما ليس فيها.
- كل شيء يحتاج طائرة حقيقية مدعومة متصلة بالجوال أو جهاز التحكم، ولا يوجد ما يقابل SITL الذي يشغل كود الطيار الآلي على جهازك. والمثال فيه شاشة للمحاكي من خلال
SimulatorManager.getInstance().enableSimulator(...)، ولكن هذا المحاكي يعمل والطائرة الحقيقية متصلة، فالبداية تحتاج عتاداً وحساباً. - التطبيق Android فقط، فإذا كان فريقك يكتب Python أو ROS 2 على Linux فهذه المكتبة لا تناسبه، والأقرب له Payload SDK على Linux أو Cloud API على سيرفر.
أخطاء شائعة
- فرع
px4_msgsلا يطابق إصدار PX4: المواضيع لا تظهر أو تظهر بأسماء مختلفة، فابن الفرع الذي يطابق الطيار الآلي. - طلب Offboard قبل بث الأهداف: PX4 يرفض التحويل، فابدأ البث ثانية على الأقل قبل الطلب.
- إرسال الأهداف من مؤقت بطيء: أقل من 2 هرتز يخرج الطائرة من Offboard، فاجعل البث في مؤقت مستقل عن منطقك الثقيل.
- QoS لا يطابق PX4: مواضيع
/fmu/outتنشر بموثوقية Best Effort، والاشتراك بإعداد Reliable لا يستقبل شيئاً. - ترك
COM_OBL_RC_ACTدون مراجعة: اختر بنفسك ما تفعله الطائرة إذا تعطل الحاسوب المرافق، ثم اختبره في SITL كما في التجربة. - الطيران دون قراءة السجل: كل طيران تجريبي ينتهي بقراءة ULog، حتى لو بدا كل شيء طبيعياً.
تدريب عملي
- غير
COM_OBL_RC_ACTإلى 4 (الهبوط) وأعد الكسر، وقارن ما يطبعه سكربت السجل بالتشغيل السابق. - اجعل العقدة ترسل السرعة بدل الموقع، فتضع
velocity=TrueفيOffboardControlMode، وطر المربع بسرعة ثابتة.
الخلاصة
- كل تعديل يمر ب SITL ثم HITL ثم الحقل، وكل مرحلة تجعل شيئاً إضافياً حقيقياً.
- الحاسوب المرافق يقرر «إلى أين»، والطيار الآلي يبقى مسؤولاً عن الاستقرار والحماية.
- ROS 2 يتحدث مع PX4 عبر uXRCE-DDS: وكيل على الحاسوب المرافق وعميل داخل PX4، ومواضيع تحت
/fmu/inو/fmu/out. - وضع Offboard يحتاج بثاً مستمراً أعلى من 2 هرتز، وانقطاعه يشغل حماية تختارها أنت.
- سجل ULog يقرأ بأدوات pyulog أو بكود، ويصلح أساساً لاختبار آلي في CI.
- التطوير مع DJI يكون فوق مفاتيح مكتبة مغلقة، ويحتاج حساباً وطائرة حقيقية من البداية.
دليلبرمجة الطائرات المسيرة مع PX4 وMAVSDKكيف تكتب برنامجاً يقلع بالطائرة المسيرة ويطير مهمة ويعود، وتختبره في محاكاة، وترى كيف يحميها الطيار الآلي عند الأعطال.
دليلPX4 وArduPilot أم DJI: أي منصة تناسب طائرتك؟ما الفرق بين منصات الطائرات المسيرة المفتوحة ومنصة DJI المغلقة ومكتباتها، وما القيود الأمريكية الرسمية التي تدخل في القرار اليوم.
دليلتجميع طائرة مسيرة رباعية: من القطع إلى أول إقلاعكيف تختار قطع طائرة رباعية وتحسب دفعها وزمن طيرانها من جداول المحركات، ثم توصلها وتضبطها بأمان قبل أول إقلاع.
دليلرسم الخرائط بالطائرات المسيرة مع OpenDroneMapكيف تتحول عشرات الصور الجوية إلى خريطة مصححة ونموذج ارتفاعات على سيرفرك، وما الذي يحدد دقتها قبل أن تقلع الطائرة.سجل التحديثات
- أكتوبر 2026: كتابة المقال، والتجربة على PX4 v1.17.0 في محاكاة SITL مع Gazebo (jonasvautherin/px4-gazebo-headless:1.17.0)، وROS 2 Humble (rclpy 3.3.22) على Ubuntu 22.04.5 داخل ros:humble-ros-base-jammy، وMicro XRCE-DDS Agent v2.4.3 مع Fast DDS 2.14.7، وpx4_msgs من الفرع release/1.17، وpyulog 1.2.4 على Python 3.10.12 في Docker Engine 29.8.0، ومكتبة DJI Mobile SDK من مستودعها الرسمي دون تشغيل، والمعلومات من توثيق PX4 كما قرأناه في 12 أكتوبر 2026.