في مقال المعمارية رسمنا منصة قيادة لأسطول من مصنعين مختلفين: بوابة عند الطرف تترجم لغة كل مصنع إلى مخطط واحد، ووسيط يحفظ القراءات، وخريطة حية، وسجلات ترفع بعد الهبوط، وفيديو له طريقه الخاص. والكلام على الورق سهل، لذلك في هذا المقال نبني هذه المنصة كلها على جهاز واحد بطائرات في محاكاة، ثم نكسرها عن قصد: نقطع الرابط، ونغير وحدات المصنع، ونترك ساعة بلا منطقة زمنية، ونرى ماذا يحدث في كل مرة وكيف نصلحه.
وهذا المقال الثاني في سلسلة منصات عمليات الطائرات المسيرة:
- كيف تبني منصة قيادة وتحكم لأسطول طائرات مسيرة: المعمارية والقرارات.
- أسطول طائرات من مصنعين مختلفين على لوحة مراقبة واحدة (الموضوع الحالي): التجربة العملية.
وسوف نناقش في هذا المقال ما يلي:
- المختبر: طائرة PX4 وطائرة ArduPilot في محاكاة SITL، و«محطة مصنع X» ترسل JSON عبر MQTT، وبوابة ووسيط NATS وقاعدة TimescaleDB وخريطة حية وGrafana وMediaMTX.
- ما وجدناه عند تشغيل المحولات: هوية الطائرة، والرسائل التي يجب أن تطلبها، وساعة المحاكاة.
- الكسر الأول: قطع رابط البوابة بالسحابة، وكيف كاد التخزين ثم الإرسال أن يضيع البيانات مرتين قبل أن يعمل.
- الكسر الثاني: المصنع يغير وحدة الارتفاع والساعة في تحديث، فتتجمد طائرته على الخريطة.
- رفع سجلات ULog و
.binبعد الهبوط وقراءتها، والفرق بينها وبين القياس عن بعد. - الفيديو: قياس التأخير من الكاميرا إلى الشاشة لكل بروتوكول، ثم على رابط يفقد حزماً ورابط أضيق من الفيديو.
المختبر
الصورة التالية تبين المختبر:

ولاحظ ما يلي في الصورة:
- شبكة الراديو
lab-dops-air: فيها الطياران الآليان، وكل منهما يرسل MAVLink على رابطين: رابط للبوابة كأنها محطة التحكم، ورابط لبرنامج الطيارpilot.pyالذي يطير بهما دوائر حتى يكون على الخريطة ما يتحرك. - شبكة غرفة العمليات
lab-dops-cloud: فيها الوسيط وقاعدة البيانات والخدمات والفيديو، و«محطة مصنع X» التي تتصل بالوسيط مباشرة عبر MQTT كما تفعل سحابات بعض المصنعين. - البوابة
lab-dops-gw: على الشبكتين معاً، وبرنامج الطيار يشاركها نفس العنوان، لأن PX4 في هذه المحاكاة يرسل الرابطين إلى عنوان واحد.
وكان في الملف طائرة PX4 ثانية، ولكن عندما شغلنا طائرتين PX4 مع Gazebo على هذا الجهاز، وهو جهاز بثمانية أنوية يعمل عليه في الوقت نفسه غيرنا، امتلأت سجلات الاثنتين بالرسالة Accel #0 fail: TIMEOUT! ورفضتا التسليح، فجعلنا الثانية اختيارية بالخيار --profile second-px4، والدرس هنا أن محاكاة الأسطول تحتاج جهازاً بحجم الأسطول، أو محاكاة أخف مثل SIH في PX4.
والملفات في مجلد واحد كما يلي:
| الملف | ماذا يفعل |
|---|---|
compose.yaml | كل الخدمات، والشبكتان، وكل المنافذ على 127.0.0.1 فقط |
schema.py | المخطط الموحد ووحداته |
gateway.py | البوابة: MAVLink إلى المخطط الموحد، ثم NATS JetStream، والتخزين ثم الإرسال |
pilot.py | يقلع بالطائرتين ويطير بهما دوائر |
vendorx.py | محطة «مصنع X»، وهي ليست منتجاً حقيقياً |
core.py | محول مصنع X، والكتابة في TimescaleDB، والحالة الحية والتنبيهات عبر WebSocket، ورفع السجلات |
logparse.py | يقرأ ULog و.bin إلى ملخص واحد |
camera.py | كاميرا اختبار تطبع الوقت على كل إطار |
measure-latency.mjs وweb/latency.html | قياس التأخير من الكاميرا إلى الشاشة |
web/index.html | الخريطة الحية |
nats.conf وmediamtx.yml وgrafana/ | إعدادات الوسيط وسيرفر الوسائط وGrafana |
والملف compose.yaml كاملاً في البطاقة التالية:
compose.yaml كاملاً
# Drone operations lab: 2 x PX4 + 1 x ArduPilot in SITL, a vendor X dock over MQTT, an edge gateway,
# NATS JetStream, TimescaleDB, a core service with a live map, Grafana and MediaMTX.
# docker compose -p lab-dops up -d --build
name: lab-dops
x-drones: &drones '{"10.251.31.11": "px4-a", "10.251.31.12": "px4-b", "10.251.31.13": "ap-c"}'
networks:
air: # the radio side: autopilots and the gateway
name: lab-dops-air
ipam: { config: [{ subnet: 10.251.31.0/24 }] }
cloud: # the command-center side
name: lab-dops-cloud
ipam: { config: [{ subnet: 10.251.30.0/24 }] }
volumes:
lab-dops-nats: { name: lab-dops-nats }
lab-dops-db: { name: lab-dops-db }
lab-dops-buf: { name: lab-dops-buf }
lab-dops-data: { name: lab-dops-data }
lab-dops-rec: { name: lab-dops-rec }
services:
px4a:
image: jonasvautherin/px4-gazebo-headless:1.17.0
container_name: lab-dops-px4a
init: true # an init process, so the container stops cleanly (PX4 leaves zombies otherwise)
command: ["10.251.31.20", "10.251.31.20"] # API link (UDP 14540) and GCS link (UDP 14550)
stdin_open: true
tty: true
networks: { air: { ipv4_address: 10.251.31.11 } }
px4b:
image: jonasvautherin/px4-gazebo-headless:1.17.0
container_name: lab-dops-px4b
init: true
profiles: [second-px4] # a second Gazebo starved this 8-core machine; start it with --profile second-px4
command: ["10.251.31.20", "10.251.31.20"] # API link (UDP 14540) and GCS link (UDP 14550)
stdin_open: true
tty: true
networks: { air: { ipv4_address: 10.251.31.12 } }
ap:
build: { context: ., dockerfile: Dockerfile.ardupilot }
image: lab-dops-ardupilot:1
container_name: lab-dops-ap
command: ["-I0", "--home", "47.3972,8.5432,488,0",
"--serial0", "udpclient:10.251.31.20:14560", "--serial1", "udpclient:10.251.31.20:14561"]
networks: { air: { ipv4_address: 10.251.31.13 } }
gw:
build: { context: ., dockerfile: Dockerfile.py }
image: lab-dops-py:1
container_name: lab-dops-gw
command: ["python", "-u", "gateway.py"]
environment: { DRONES: *drones, NATS_URL: "nats://lab-dops-nats:4222" }
cap_add: [NET_ADMIN] # only for the tc netem break step
volumes: [lab-dops-buf:/buf]
networks:
air: { ipv4_address: 10.251.31.20 }
cloud: { ipv4_address: 10.251.30.20 }
depends_on: [nats]
pilot:
image: lab-dops-py:1
container_name: lab-dops-pilot
command: ["python", "-u", "pilot.py"]
environment: { DRONES: *drones }
network_mode: "service:gw" # same address as the gateway: PX4 sends its API link there on UDP 14540
depends_on: [gw]
vendorx:
image: lab-dops-py:1
container_name: lab-dops-vendorx
command: ["python", "-u", "vendorx.py"]
environment: { FIRMWARE: "${FIRMWARE:-1}" }
networks: [cloud]
depends_on: [nats]
nats:
image: nats:2.15.0-alpine
container_name: lab-dops-nats
command: ["-c", "/etc/nats/nats.conf"]
volumes: ["./nats.conf:/etc/nats/nats.conf:ro", "lab-dops-nats:/data"]
ports: ["127.0.0.1:38222:8222"]
networks: { cloud: { ipv4_address: 10.251.30.10 } }
db:
image: timescale/timescaledb:2.30.2-pg18
container_name: lab-dops-db
environment: { POSTGRES_PASSWORD: lab, POSTGRES_DB: fleet }
volumes: [lab-dops-db:/var/lib/postgresql]
ports: ["127.0.0.1:35432:5432"]
networks: [cloud]
core:
image: lab-dops-py:1
container_name: lab-dops-core
command: ["python", "-u", "core.py"]
environment: { VX_ADAPTER: "${VX_ADAPTER:-1}" }
volumes: [lab-dops-data:/data]
ports: ["127.0.0.1:38080:8080"]
networks: [cloud]
depends_on: [nats, db]
grafana:
image: grafana/grafana:13.2.3
container_name: lab-dops-grafana
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
GF_SECURITY_ADMIN_PASSWORD: lab-only-password
volumes: ["./grafana/provisioning:/etc/grafana/provisioning:ro", "./grafana/dashboards:/var/lib/grafana/dashboards:ro"]
ports: ["127.0.0.1:33000:3000"]
networks: [cloud]
mtx:
image: bluenviron/mediamtx:1.21.2
container_name: lab-dops-mtx
volumes: ["./mediamtx.yml:/mediamtx.yml:ro", "lab-dops-rec:/recordings"]
ports: ["127.0.0.1:38889:8889", "127.0.0.1:38189:38189/udp"]
networks: { cloud: { ipv4_address: 10.251.30.30 } }
في الإعداد أعلاه لاحظ التالي:
- الخيار
init: trueفي PX4 ضروري، والسبب أننا عندما أوقفنا Container ال PX4 بدونه بقيت عملياته Zombie، ورفض Docker إيقافه أو حذفه. - PX4 يأخذ عنوانين، الأول للرابط على المنفذ 14540 والثاني لرابط محطة التحكم على 14550، وعندما أعطيناه عنواناً واحداً في البداية لم يصل البوابة شيء على المنفذ 14550، فقرأنا ملف
entrypoint.shداخل Docker Image ووجدنا السبب. - ArduPilot يعمل من الملف الجاهز الذي ينشره المشروع على
firmware.ardupilot.orgللإصدار Copter-4.7.1، دون بناء من الكود، ويرسل المنفذ التسلسليserial0إلى البوابة وserial1إلى برنامج الطيار. وقد بدأنا بـserial1وserial2، فبقيت المحاكاة على السطرWaiting for connection ....، لأنserial0افتراضياً منفذ TCP تنتظر المحاكاة أن يتصل به أحد قبل أن تبدأ. - المتغيران
FIRMWAREوVX_ADAPTERهما ما نغيره في الكسر الثاني.
والتشغيل كما يلي:
docker compose -p lab-dops up -d --build
docker logs -f lab-dops-gwالبوابة: من MAVLink إلى المخطط الموحد
البوابة كاملة في البطاقة التالية، وفيها التعديلات التي اضطررنا إليها أثناء التجربة، ونشرح كل تعديل في مكانه:
gateway.py كاملاً
"""Edge gateway: MAVLink in (PX4 and ArduPilot), normalized telemetry out to NATS JetStream.
- one MAVLink parser per source address, because two PX4 vehicles can both say "I am system 1"
- event time from the autopilot clock (SYSTEM_TIME), not the time the packet arrived
- 2 messages per second per drone to the cloud (the full rate stays in the flight log)
- store-and-forward: when the cloud link is down, messages wait in a SQLite outbox on disk
and are sent later in order, each with a Nats-Msg-Id so a resend is not stored twice
"""
import asyncio
import json
import os
import socket
import sqlite3
import time
import nats
from pymavlink.dialects.v20 import ardupilotmega as mavlink2
from schema import iso, make
NATS_URL = os.environ.get("NATS_URL", "nats://lab-dops-nats:4222")
# who is who: the radio link (here a UDP port and a source address) decides the identity, not sysid
DRONES = json.loads(os.environ.get("DRONES", "{}")) # {"10.251.31.11": "px4-a", ...}
RATE_HZ = 2.0
PX4_MODES = {(1, 0): "MANUAL", (2, 0): "ALTCTL", (3, 0): "POSCTL", (4, 2): "TAKEOFF", (4, 3): "HOLD",
(4, 4): "MISSION", (4, 5): "RTL", (4, 6): "LAND", (6, 0): "OFFBOARD"}
AP_MODES = {0: "STABILIZE", 2: "ALT_HOLD", 3: "AUTO", 4: "GUIDED", 5: "LOITER", 6: "RTL", 9: "LAND", 16: "POSHOLD"}
def log(msg):
print(f"{time.strftime('%H:%M:%S')} gateway {msg}", flush=True)
class Drone:
def __init__(self, drone_id, addr, sock):
self.id, self.addr, self.sock = drone_id, addr, sock
self.mav = mavlink2.MAVLink(None, srcSystem=255, srcComponent=190)
self.st = {}
self.boot_offset = None # unix seconds at boot = time_unix - time_boot
self.vendor = None
self.seq = 0
self.last_pub = 0.0
self.requested = False
self.types = {}
self.last_unix = None
def send(self, m):
self.sock.sendto(m.pack(self.mav), self.addr)
class Gateway:
def __init__(self):
self.drones = {}
self.out = asyncio.Queue()
self.db = sqlite3.connect("/buf/outbox.db")
self.db.execute("CREATE TABLE IF NOT EXISTS outbox (id INTEGER PRIMARY KEY, subject TEXT, msg_id TEXT, body BLOB)")
self.db.execute("PRAGMA journal_mode=WAL")
self.db.commit()
self.pending = [] # rows for the outbox, written to disk together twice a second
self.down_until = 0.0 # simple circuit breaker after a failed publish
self.stats = {"sent": 0, "buffered": 0, "flushed": 0}
# ---------- MAVLink side ----------
def on_mavlink(self, sock, data, addr):
d = self.drones.get(addr)
if d is None:
if addr[0] not in DRONES:
return
d = self.drones[addr] = Drone(DRONES[addr[0]], addr, sock)
log(f"new link {addr[0]}:{addr[1]} -> {d.id}")
try:
msgs = d.mav.parse_buffer(data) or []
except mavlink2.MAVError:
return
for m in msgs:
self.on_msg(d, m)
def on_msg(self, d, m):
t = m.get_type()
d.types[t] = d.types.get(t, 0) + 1
s = d.st
if t == "HEARTBEAT" and m.type != mavlink2.MAV_TYPE_GCS:
d.vendor = "px4" if m.autopilot == mavlink2.MAV_AUTOPILOT_PX4 else "ardupilot"
s["armed"] = bool(m.base_mode & mavlink2.MAV_MODE_FLAG_SAFETY_ARMED)
if not d.requested:
self.request_streams(d)
if d.vendor == "px4":
main, sub = (m.custom_mode >> 16) & 0xFF, (m.custom_mode >> 24) & 0xFF
s["mode"] = PX4_MODES.get((main, sub), f"{main}.{sub}")
else:
s["mode"] = AP_MODES.get(m.custom_mode, str(m.custom_mode))
elif t == "SYSTEM_TIME":
d.last_unix = m.time_unix_usec
if m.time_unix_usec < 1.6e15: # 0 (or 1970) until the autopilot has a real date from GNSS
return
d.boot_offset = m.time_unix_usec / 1e6 - m.time_boot_ms / 1e3
elif t == "SYS_STATUS":
s["battery_pct"] = m.battery_remaining if m.battery_remaining >= 0 else None
elif t == "GPS_RAW_INT":
s.update(gnss_fix=m.fix_type, gnss_sats=m.satellites_visible,
gnss_eph_m=(m.h_acc / 1000) if getattr(m, "h_acc", 0) else None)
elif t == "VFR_HUD":
s["gs_ms"] = m.groundspeed
elif t == "GLOBAL_POSITION_INT":
s.update(lat=m.lat / 1e7, lon=m.lon / 1e7, alt_amsl_m=m.alt / 1000, alt_rel_m=m.relative_alt / 1000,
vs_ms=-m.vz / 100, heading_deg=None if m.hdg == 65535 else m.hdg / 100)
now = time.time()
if d.boot_offset is None or now - d.last_pub < 1 / RATE_HZ:
return
d.last_pub = now
d.seq += 1
msg = make(drone_id=d.id, vendor=d.vendor, seq=d.seq, ts=iso(d.boot_offset + m.time_boot_ms / 1e3),
rx_ts=iso(now), **s)
self.out.put_nowait((f"tlm.{d.id}", f"{d.id}-{d.seq}", json.dumps(msg).encode()))
def request_streams(self, d):
# ArduPilot sends little on a new link until asked: ask for the messages we normalize at 5 Hz.
# PX4 streams them by default, but SYSTEM_TIME (id 2) has to be asked for, here once a second.
wanted = [(1, 200000), (2, 1000000), (24, 200000), (33, 200000), (74, 200000)]
if d.vendor == "px4":
wanted = [(2, 1000000)]
for msg_id, interval_us in wanted:
d.send(mavlink2.MAVLink_command_long_message(1, 1 if d.vendor == "px4" else 0,
mavlink2.MAV_CMD_SET_MESSAGE_INTERVAL, 0,
msg_id, interval_us, 0, 0, 0, 0, 0))
d.requested = True
async def heartbeats(self):
while True:
for d in list(self.drones.values()):
d.send(mavlink2.MAVLink_heartbeat_message(mavlink2.MAV_TYPE_GCS, mavlink2.MAV_AUTOPILOT_INVALID,
0, 0, 0, 3))
await asyncio.sleep(1)
# ---------- cloud side ----------
async def publisher(self, js, nc):
while True:
subject, msg_id, body = await self.out.get()
backlog = len(self.pending) or self.db.execute("SELECT COUNT(*) FROM outbox").fetchone()[0]
if backlog == 0 and nc.is_connected and time.time() > self.down_until:
try:
await js.publish(subject, body, headers={"Nats-Msg-Id": msg_id}, timeout=3)
self.stats["sent"] += 1
continue
except Exception as e:
self.down_until = time.time() + 3
log(f"publish failed ({type(e).__name__}), buffering on disk")
# one disk write per message blocked this loop for up to seconds on a busy disk, and the MAVLink
# packets that arrived meanwhile were dropped by the kernel: so collect here, write in batches
self.pending.append((subject, msg_id, body))
self.stats["buffered"] += 1
async def flusher(self, js, nc):
# the backlog must drain faster than new data arrives, so send a batch at once and wait for all the acks
# (the first version sent one message, waited for its ack, then the next: under load it never caught up)
while True:
await asyncio.sleep(0.5)
if self.pending:
rows, self.pending = self.pending, []
await asyncio.to_thread(self.persist, rows)
if not nc.is_connected or time.time() < self.down_until:
continue
rows = self.db.execute("SELECT id, subject, msg_id, body FROM outbox ORDER BY id LIMIT 100").fetchall()
if not rows:
continue
results = await asyncio.gather(*[js.publish(subj, body, headers={"Nats-Msg-Id": mid}, timeout=5)
for _, subj, mid, body in rows], return_exceptions=True)
done = [(r[0],) for r, res in zip(rows, results) if not isinstance(res, Exception)]
self.db.executemany("DELETE FROM outbox WHERE id = ?", done)
self.db.commit()
self.stats["flushed"] += len(done)
if len(done) < len(rows):
self.down_until = time.time() + 3
def persist(self, rows):
db = sqlite3.connect("/buf/outbox.db")
db.executemany("INSERT INTO outbox (subject, msg_id, body) VALUES (?, ?, ?)", rows)
db.commit()
db.close()
async def report(self):
while True:
await asyncio.sleep(5)
backlog = self.db.execute("SELECT COUNT(*) FROM outbox").fetchone()[0]
for d in self.drones.values():
if d.boot_offset is None:
log(f"{d.id}: no SYSTEM_TIME with a real date yet, holding its messages "
f"(last time_unix_usec {d.last_unix})")
log(f"sent {self.stats['sent']}, buffered {self.stats['buffered']}, flushed {self.stats['flushed']}, "
f"waiting on disk {backlog}")
async def run(self):
loop = asyncio.get_running_loop()
for port in (14550, 14560): # 14550: PX4 GCS link, 14560: ArduPilot serial0
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", port))
sock.setblocking(False)
loop.add_reader(sock, lambda s=sock: self.on_mavlink(s, *s.recvfrom(65535)))
async def down():
log("NATS disconnected")
async def up():
log("NATS reconnected")
async def err(e):
pass # reconnect attempts fail while the link is down; down() already said so
nc = await nats.connect(NATS_URL, ping_interval=2, max_outstanding_pings=2, max_reconnect_attempts=-1,
reconnect_time_wait=1, connect_timeout=2, disconnected_cb=down, reconnected_cb=up,
error_cb=err)
js = nc.jetstream()
log(f"connected to {NATS_URL}, waiting for {len(DRONES)} drones")
await asyncio.gather(self.heartbeats(), self.publisher(js, nc), self.flusher(js, nc), self.report())
if __name__ == "__main__":
asyncio.run(Gateway().run())
في الكود أعلاه لاحظ التالي:
- الهوية من الرابط: لكل عنوان مصدر محلل MAVLink خاص به، والقاموس
DRONESيربط العنوان باسم الطائرة، ولا نستخدم رقم النظام System ID، لأن طائرتي PX4 كانتا تحملان الرقم 1 نفسه. - الطلب قبل الاستقبال: ArduPilot لم يرسل الموقع على الرابط الجديد حتى طلبناه بالأمر
MAV_CMD_SET_MESSAGE_INTERVAL، وPX4 أرسل الموقع والبطارية دون طلب، ولكنه لم يرسلSYSTEM_TIME، فبقيت قراءاته محجوزة في البوابة، وطبعت البوابة كل خمس ثوان سطراً مثل التالي حتى أضفنا الطلب:
12:24:22 gateway px4-a: no SYSTEM_TIME with a real date yet, holding its messages (last time_unix_usec None)- الوقت من الطائرة:
boot_offsetهو التاريخ لحظة تشغيل الطيار الآلي، منSYSTEM_TIME، ووقت كل قراءة هو هذا الفرق معtime_boot_msفي الرسالة نفسها، وليس وقت وصولها. - قراءتان في الثانية: البوابة لا ترسل إلى السحابة كل ما يصلها، وإنما قراءتين في الثانية لكل طائرة، والباقي في سجل الطيران كما سوف نرى.
- التخزين ثم الإرسال: إذا فشل الإرسال، أو كان في المخزن شيء لم يرسل بعد، تذهب القراءة إلى المخزن، و
flusherيرسل المخزن بالترتيب عندما يعود الرابط، ومع كل رسالةNats-Msg-Idفلا تحفظ مرتين إذا أعيد إرسالها.
والقراءة الموحدة التي تصل إلى الوسيط من هذه البوابة مثل التالية، وهي من ArduPilot كما أعادها السيرفر، وقد وزعنا حقولها على أسطر:
{
"v": 1, "drone_id": "ap-c", "vendor": "ardupilot", "seq": 3514,
"ts": "2026-10-13T16:59:28.003Z", "rx_ts": "2026-10-13T17:07:11.777Z",
"lat": 47.3968753, "lon": 8.5420001, "alt_amsl_m": 538.07, "alt_rel_m": 49.997,
"gs_ms": 9.90256404876709, "vs_ms": 0.01, "heading_deg": 219.54,
"battery_pct": 0, "mode": "GUIDED", "armed": true,
"gnss_fix": 6, "gnss_sats": 10, "gnss_eph_m": 0.3
}وقد يتساءل البعض: لماذا وقت القياس قبل وقت الاستلام بثماني دقائق تقريباً، والطائرة والبوابة على نفس الجهاز؟ والإجابة أن ساعة محاكاة ArduPilot تحسب الوقت من المحاكاة نفسها، والمحاكاة على جهاز مزدحم تجري أبطأ من الزمن الحقيقي، فتأخرت ساعتها 133 ثانية في التشغيل الأول، و480 ثانية في تشغيل آخر، و1700 ثانية في آخر التجربة. وهذا بالضبط ما يحدث مع طائرة حقيقية ساعتها خاطئة، والمنصة لم تلاحظه لأننا أخبرناها بل لأن تنبيه الفرق بين الوقتين ظهر من تلقاء نفسه:
{'drone': 'ap-c', 'type': 'clock', 'text': 'clock skew +480 s'}ولاحظ أيضاً أن battery_pct صفر والطائرة ما زالت تطير في وضع GUIDED، لأن البطارية المحاكاة نفدت بعد ساعات ولم يكن في الإعداد أي فعل عند انخفاضها، والمنصة أطلقت تنبيه البطارية وهذا كل ما تستطيع فعله، فالفعل نفسه إعداد في الطيار الآلي.
الخدمات: الكتابة والحالة الحية والتنبيهات
الخدمة الأساسية في البطاقة التالية:
core.py كاملاً
"""Command-center core: vendor X adapter, TimescaleDB writer, live state and alerts over WebSocket,
and the post-flight log upload API.
GET / the live map page GET /ws live snapshots twice a second
GET /api/state the same snapshot as JSON GET /api/time server clock (for latency tests)
POST /api/logs multipart: drone_id, file (.ulg or .bin) -> parsed summary, stored in flight_logs
"""
import asyncio
import json
import os
import time
from datetime import datetime, timezone
import asyncpg
import nats
from aiohttp import web, WSMsgType
from nats.js.api import StreamConfig
from schema import iso, make, parse_iso
NATS_URL = os.environ.get("NATS_URL", "nats://lab-dops-nats:4222")
PG = os.environ.get("PG", "postgresql://postgres:lab@lab-dops-db:5432/fleet")
VX_ADAPTER = int(os.environ.get("VX_ADAPTER", "1")) # 1 = trusts the vendor document, 2 = checks units and clock
LINK_LOST_S, LOW_BATT, CEILING_M, SKEW_S = 5, 30, 120, 5
DDL = """
CREATE TABLE IF NOT EXISTS telemetry (
ts timestamptz NOT NULL, drone_id text NOT NULL, seq bigint, vendor text, rx_ts timestamptz,
lat double precision, lon double precision, alt_amsl_m real, alt_rel_m real, gs_ms real, vs_ms real,
heading_deg real, battery_pct real, mode text, armed boolean,
gnss_fix smallint, gnss_sats smallint, gnss_eph_m real, stored_at timestamptz DEFAULT now(),
PRIMARY KEY (drone_id, ts));
SELECT create_hypertable('telemetry', by_range('ts', INTERVAL '1 day'), if_not_exists => TRUE);
CREATE TABLE IF NOT EXISTS flight_logs (
id bigserial PRIMARY KEY, drone_id text NOT NULL, kind text NOT NULL, file_name text, size_bytes bigint,
started_at timestamptz, duration_s real, summary jsonb, uploaded_at timestamptz DEFAULT now());
"""
COLS = ("ts", "drone_id", "seq", "vendor", "rx_ts", "lat", "lon", "alt_amsl_m", "alt_rel_m", "gs_ms", "vs_ms",
"heading_deg", "battery_pct", "mode", "armed", "gnss_fix", "gnss_sats", "gnss_eph_m")
TYPES = ("timestamptz", "text", "int8", "text", "timestamptz", "float8", "float8", "float4", "float4", "float4",
"float4", "float4", "float4", "text", "bool", "int2", "int2", "float4")
# one statement per batch; RETURNING tells how many rows were new (a replayed duplicate returns nothing)
INSERT = (f"INSERT INTO telemetry ({', '.join(COLS)}) SELECT * FROM unnest("
+ ", ".join(f"${i + 1}::{t}[]" for i, t in enumerate(TYPES))
+ ") ON CONFLICT (drone_id, ts) DO NOTHING RETURNING 1")
def log(msg):
print(f"{time.strftime('%H:%M:%S')} core {msg}", flush=True)
class Core:
def __init__(self):
self.latest = {} # drone_id -> last accepted message (by event time)
self.last_rx = {} # drone_id -> local time we last heard anything
self.counters = {"late": 0, "future": 0, "written": 0, "duplicates": 0, "quarantined": 0,
"clock_replaced": 0, "rejected": 0}
self.clients = set()
# ---------- vendor X adapter ----------
async def vendor_x(self, nc, js):
async def on(m):
raw = json.loads(m.data)
rx = time.time()
vts = datetime.strptime(raw["time"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc).timestamp()
alt = raw["alt"]
if VX_ADAPTER >= 2:
unit = raw.get("alt_unit", "m")
if unit not in ("m", "ft"):
self.counters["quarantined"] += 1
await nc.publish("quarantine.vendorx", m.data)
return
alt = alt * 0.3048 if unit == "ft" else alt
if abs(vts - rx) > 30: # a zone-less or drifting vendor clock: keep our receive time
self.counters["clock_replaced"] += 1
vts = rx
msg = make(drone_id=raw["sn"].lower(), vendor="vendor-x", seq=None, ts=iso(vts), rx_ts=iso(rx),
lat=raw["pos"]["lat"], lon=raw["pos"]["lng"], alt_rel_m=alt, gs_ms=raw["spd_kmh"] / 3.6,
heading_deg=raw["hdg"], battery_pct=raw["batt"] * 100, mode=raw["state"], armed=True)
try:
await js.publish(f"tlm.{msg['drone_id']}", json.dumps(msg).encode(),
headers={"Nats-Msg-Id": f"{msg['drone_id']}-{raw['time']}"})
except Exception as e:
log(f"vendor X publish failed: {type(e).__name__}")
await nc.subscribe("vendorx.*.telemetry", cb=on)
# ---------- storage ----------
async def writer(self, js, pool):
sub = await js.pull_subscribe("tlm.>", durable="db-writer", stream="TLM")
while True:
try:
batch = await sub.fetch(200, timeout=1)
except (nats.errors.TimeoutError, asyncio.TimeoutError):
continue
rows = []
for m in batch:
try:
d = json.loads(m.data)
assert d.get("v") == 1 and d.get("ts") and d.get("drone_id")
except Exception:
self.counters["rejected"] += 1 # not schema v1: keep it out of the table
continue
rows.append(tuple(datetime.fromisoformat(d[c].replace("Z", "+00:00")) if c in ("ts", "rx_ts")
else d.get(c) for c in COLS))
if not rows:
for m in batch:
await m.ack()
continue
async with pool.acquire() as con:
new = len(await con.fetch(INSERT, *[list(col) for col in zip(*rows)]))
self.counters["written"] += new
self.counters["duplicates"] += len(rows) - new
for m in batch:
await m.ack()
# ---------- live state ----------
async def live(self, nc):
async def on(m):
try:
d = json.loads(m.data)
did = d["drone_id"]
ts = parse_iso(d["ts"])
except Exception:
return
now = time.time()
self.last_rx[did] = now
if ts > now + SKEW_S:
self.counters["future"] += 1
if VX_ADAPTER >= 2: # never let a future clock decide what "latest" is
return
cur = self.latest.get(did)
if cur and parse_iso(cur["ts"]) >= ts:
self.counters["late"] += 1 # an old message (replayed after an outage): store it, don't draw it
return
self.latest[did] = d
await nc.subscribe("tlm.>", cb=on)
def snapshot(self):
now = time.time()
alerts = []
for did, d in sorted(self.latest.items()):
age = now - self.last_rx.get(did, 0)
skew = parse_iso(d["rx_ts"]) - parse_iso(d["ts"])
if age > LINK_LOST_S:
alerts.append({"drone": did, "type": "link", "text": f"no data for {age:.0f} s"})
if d.get("battery_pct") is not None and d["battery_pct"] < LOW_BATT:
alerts.append({"drone": did, "type": "battery", "text": f"battery {d['battery_pct']:.0f}%"})
if d.get("alt_rel_m") and d["alt_rel_m"] > CEILING_M:
alerts.append({"drone": did, "type": "ceiling", "text": f"altitude {d['alt_rel_m']:.0f} m"})
if abs(skew) > SKEW_S:
alerts.append({"drone": did, "type": "clock", "text": f"clock skew {skew:+.0f} s"})
return {"server_time": iso(now), "drones": list(self.latest.values()), "alerts": alerts,
"counters": self.counters}
async def push(self):
while True:
await asyncio.sleep(0.5)
if self.clients:
data = json.dumps(self.snapshot())
for ws in list(self.clients):
try:
await ws.send_str(data)
except Exception:
self.clients.discard(ws)
# ---------- HTTP ----------
async def ws(self, request):
ws = web.WebSocketResponse(heartbeat=10)
await ws.prepare(request)
self.clients.add(ws)
async for msg in ws:
if msg.type == WSMsgType.ERROR:
break
self.clients.discard(ws)
return ws
async def upload(self, request):
from logparse import parse
form = await request.post()
f = form["file"]
data = f.file.read()
path = f"/data/logs/{int(time.time())}-{f.filename}"
os.makedirs("/data/logs", exist_ok=True)
with open(path, "wb") as out:
out.write(data)
t0 = time.time()
summary = await asyncio.to_thread(parse, path)
summary["parse_seconds"] = round(time.time() - t0, 2)
started = datetime.fromtimestamp(summary["start_unix"], timezone.utc) if summary.get("start_unix") else None
async with request.app["pool"].acquire() as con:
row_id = await con.fetchval(
"INSERT INTO flight_logs (drone_id, kind, file_name, size_bytes, started_at, duration_s, summary) "
"VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id", form["drone_id"], summary["kind"], f.filename,
len(data), started, summary.get("duration_s"), json.dumps(summary))
return web.json_response({"id": row_id, **summary})
async def run(self):
while True: # the database may take a minute on first start
try:
pool = await asyncpg.create_pool(PG, min_size=1, max_size=4)
break
except Exception as e:
log(f"database not ready: {e}")
await asyncio.sleep(2)
async with pool.acquire() as con:
await con.execute(DDL)
nc = await nats.connect(NATS_URL, max_reconnect_attempts=-1)
js = nc.jetstream()
await js.add_stream(StreamConfig(name="TLM", subjects=["tlm.>"], max_age=7 * 86400, duplicate_window=120))
await self.vendor_x(nc, js)
await self.live(nc)
app = web.Application(client_max_size=512 * 1024 * 1024)
app["pool"] = pool
app.add_routes([web.get("/ws", self.ws),
web.get("/api/state", lambda r: web.json_response(self.snapshot())),
web.get("/api/time", lambda r: web.json_response({"t": time.time() * 1000})),
web.post("/api/logs", self.upload),
web.get("/", lambda r: web.FileResponse("web/index.html")),
web.static("/web", "web")])
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, "0.0.0.0", 8080).start()
log(f"listening on 8080, vendor X adapter v{VX_ADAPTER}")
await asyncio.gather(self.writer(js, pool), self.push())
if __name__ == "__main__":
asyncio.run(Core().run())
في الكود أعلاه لاحظ التالي:
- الكتابة دفعة واحدة:
writerيقرأ من مستهلك دائم Durable Consumer اسمهdb-writerحتى 200 رسالة، ويكتبها بأمر واحد معunnest، وRETURNING 1يخبرنا كم صفاً كان جديداً. وكانت النسخة الأولى تحسبcount(*)قبل الكتابة وبعدها لتعرف المكرر، فوصل استهلاك Container قاعدة البيانات إلى 179% من المعالج، ونزل بعد التعديل إلى قرابة 22%. - الحالة الحية:
liveيشترك في المواضيع نفسها مباشرة دون قاعدة البيانات، ويحتفظ بآخر قراءة لكل طائرة بحسب وقت القياس، فالقراءة الأقدم تحسب في العدادlateوتحفظ في قاعدة البيانات ولا ترسم. - القراءات التي لا تطابق المخطط ترفض: عندما أرسلنا بأداة
natsرسالة اختبار{}إلىtlm.testتوقفت الخدمة كلها بالخطأKeyError: 'ts'، والسبب أن الوسيط حفظ الرسالة ولم يصله تأكيد قراءتها، فإذا أعيد تشغيل الخدمة قرأتها من جديد، فأضفنا فحص المخطط والعدادrejected. وهذا خطأ يقع فيه كثيرون: رسالة واحدة سيئة في سجل محفوظ توقف المستهلك إلى الأبد إذا لم يتجاوزها.
والصورة التالية الخريطة الحية في المتصفح على http://127.0.0.1:38080:
ولاحظ في الصورة أن تنبيه الساعة لطائرة ArduPilot وصل إلى 2332 ثانية، وأن طائرة PX4 كانت في تلك اللحظة قرب مستوى الأرض بارتفاع نسبي -1.9 متر كما يقدره طيارها الآلي، وهذا الرقم السالب نفسه علامة يجب أن تنتبه لها المنصة.

والخريطة بلا خلفية عن قصد، فلا يخرج من المختبر طلب إلى مزود خرائط، وفي الإنتاج تستخدم خرائطك على سيرفرك كما في مقال الخرائط. ويجدر الإشارة هنا إلى أن مكتبة MapLibre GL JS في الإصدار 6 أصبحت تنشر بصيغة ES Modules فقط، فالصفحة التي كتبناها بالأسلوب القديم لم ترسم شيئاً، فاستخدمنا الإصدار 5.24.0 الذي ما زال ينشر الملف maplibre-gl.js، ونحمله أثناء بناء Docker Image حتى تعمل الصفحة دون CDN.
الكسر الأول: قطع رابط البوابة بالسحابة
أردنا أن نقطع الرابط بين البوابة والوسيط بأداة tc netem داخل Container البوابة، فكانت النتيجة كما يلي:
docker exec lab-dops-gw tc qdisc add dev eth0 root netem loss 100%Error: Specified qdisc kind is unknown.والسبب أن نواة Docker Desktop على هذا الجهاز لا تحتوي وحدة netem، بل لم تقبل أي نوع من qdisc، لذلك أضفنا iptables إلى Docker Image وأسقطنا الحزم الخارجة من البوابة إلى منفذ الوسيط:
docker exec lab-dops-gw iptables -A OUTPUT -d 10.251.30.10 -p tcp --dport 4222 -j DROP
# after about a minute
docker exec lab-dops-gw iptables -D OUTPUT -d 10.251.30.10 -p tcp --dport 4222 -j DROPالمحاولة الأولى: المخزن لا يفرغ
في النسخة الأولى كان flusher يرسل رسالة واحدة وينتظر تأكيدها ثم التالية، ومهلة كل رسالة ثانية واحدة، وعلى هذا الجهاز المزدحم كان تأكيد JetStream يتأخر أحياناً أكثر من ثانية، فكان المخزن يفرغ برسالة أو رسالتين في الثانية بينما يدخله قرابة أربع، فلا يفرغ أبداً. والسطور التالية من سجل البوابة في تلك المرحلة:
11:57:22 gateway sent 0, buffered 548, flushed 55, waiting on disk 776
11:57:28 gateway sent 0, buffered 550, flushed 56, waiting on disk 777
11:57:33 gateway sent 0, buffered 556, flushed 56, waiting on disk 783والحل أن ترسل دفعة من 100 رسالة معاً وتنتظر تأكيداتها معاً بـ asyncio.gather، فالرسائل تخرج بالترتيب على اتصال واحد، والانتظار واحد للدفعة كلها، فنزل المخزن من 500 رسالة إلى صفر في قرابة عشر ثوان:
12:58:05 gateway sent 0, buffered 13, flushed 300, waiting on disk 500
12:58:10 gateway sent 0, buffered 26, flushed 800, waiting on disk 13
12:58:15 gateway sent 16, buffered 28, flushed 815, waiting on disk 0والقاعدة هنا: سرعة إفراغ المخزن يجب أن تكون أكبر بكثير من سرعة القراءات الجديدة، وإلا فالانقطاع القصير يصبح تأخيراً دائماً.
المحاولة الثانية: البيانات تضيع أثناء الانقطاع
بعد ذلك قطعنا الرابط قرابة ثلاث دقائق، ثم سألنا قاعدة البيانات كم قراءة وصلت من كل طائرة في كل 30 ثانية بحسب وقت استلام البوابة:
SELECT drone_id, time_bucket('30 seconds', rx_ts) AS rx_bucket, count(*),
round(max(extract(epoch from stored_at - rx_ts))::numeric, 1) AS max_wait_s
FROM telemetry
WHERE rx_ts BETWEEN '2026-10-13 13:15:00+00' AND '2026-10-13 13:20:00+00'
GROUP BY 1, 2 ORDER BY 1, 2;والمخرج سوف يكون كما يلي، مختصراً على طائرة ArduPilot:
drone_id | rx_bucket | count | max_wait_s
----------+------------------------+-------+------------
ap-c | 2026-10-13 13:15:00+00 | 50 | 1.2
ap-c | 2026-10-13 13:15:30+00 | 47 | 1.8
ap-c | 2026-10-13 13:16:00+00 | 29 | 166.5
ap-c | 2026-10-13 13:16:30+00 | 7 | 132.9
ap-c | 2026-10-13 13:17:00+00 | 7 | 120.0
ap-c | 2026-10-13 13:17:30+00 | 14 | 87.0
ap-c | 2026-10-13 13:18:00+00 | 17 | 56.6
ap-c | 2026-10-13 13:18:30+00 | 7 | 30.5
ap-c | 2026-10-13 13:19:00+00 | 50 | 1.1لاحظ أن القراءات في كل 30 ثانية نزلت من قرابة 50 إلى 7 أثناء الانقطاع، أي أن البوابة لم تحفظ أغلب ما وصلها. والسبب أن كل قراءة تذهب إلى المخزن كانت تكتب في SQLite وتنفذ commit داخل حلقة الأحداث Event Loop نفسها، وعلى قرص مشغول يأخذ كل commit وقتاً طويلاً، وأثناءه لا تقرأ البوابة حزم MAVLink، فتمتلئ ذاكرة المقبس Socket وتسقط النواة الحزم الجديدة بصمت. والحل في الكود أعلاه: القراءات تجمع في قائمة في الذاكرة، وتكتب على القرص دفعة واحدة مرتين في الثانية في Thread منفصل.
المحاولة الثالثة: لا شيء يضيع
وبعد التعديلين قطعنا الرابط مرة أخرى، وسجل البوابة كما يلي (مختصراً، حذفنا بعض الأسطر المتشابهة):
13:24:05 gateway sent 46, buffered 0, flushed 0, waiting on disk 0
13:24:06 gateway publish failed (TimeoutError), buffering on disk
13:24:09 gateway NATS disconnected
13:24:10 gateway sent 46, buffered 27, flushed 0, waiting on disk 27
13:24:30 gateway sent 46, buffered 100, flushed 0, waiting on disk 98
13:24:50 gateway sent 46, buffered 172, flushed 0, waiting on disk 168
13:25:10 gateway sent 46, buffered 229, flushed 0, waiting on disk 225
13:25:12 gateway NATS reconnected
13:25:15 gateway sent 53, buffered 240, flushed 240, waiting on disk 0والسؤال نفسه لقاعدة البيانات أعطى ما يلي:
drone_id | rx_bucket | count | max_wait_s
----------+------------------------+-------+------------
ap-c | 2026-10-13 13:23:30+00 | 16 | 0.1
ap-c | 2026-10-13 13:24:00+00 | 49 | 69.0
ap-c | 2026-10-13 13:24:30+00 | 49 | 42.6
ap-c | 2026-10-13 13:25:00+00 | 43 | 13.3
px4-a | 2026-10-13 13:23:30+00 | 19 | 0.9
px4-a | 2026-10-13 13:24:00+00 | 59 | 69.6
px4-a | 2026-10-13 13:24:30+00 | 53 | 42.5
px4-a | 2026-10-13 13:25:00+00 | 45 | 13.3في المخرج أعلاه لاحظ التالي:
- القراءات في كل 30 ثانية بقيت كما هي أثناء الانقطاع، والفرق الوحيد أن أقدمها انتظر 69 ثانية على قرص البوابة قبل أن يصل.
- 240 قراءة انتظرت على القرص، وأرسلت كلها في أقل من ثلاث ثوان بعد عودة الرابط.
- العداد
lateفي الخدمة زاد، لأن هذه القراءات وصلت بعد قراءات أحدث منها، فحفظت في مكانها من الزمن ولم تعد الطائرة إلى الوراء على الخريطة. - الوسيط نفسه لم يعلم بشيء، والمخزن عند الطرف هو الذي حفظ القراءات، وهذا هو المقصود بالتخزين ثم الإرسال.
وانتبه إلى أثر جانبي محتمل: إذا كانت البوابة هي محطة التحكم عند الطيار الآلي، فإعادة تشغيلها أثناء الطيران تعني انقطاع رابط المحطة، وفي هذه المحاكاة قيمة NAV_DLL_ACT هي 2 أي العودة، كما رأينا في مقال برمجة الطائرات.
الكسر الثاني: المصنع يغير الوحدة والساعة
«محطة مصنع X» في الملف vendorx.py ترسل في البرنامج الثابت Firmware 1 الارتفاع بالمتر والوقت UTC كما في توثيقها، وفي البرنامج الثابت 2 ترسل الارتفاع بالقدم مع الحقل alt_unit، والوقت بالتوقيت المحلي UTC+3 دون منطقة زمنية، وكأن المصنع حدث المحطة دون أن يخبر أحداً:
vendorx.py كاملاً
"""A simulated drone dock from "vendor X" that speaks JSON over MQTT, the way many vendor clouds do.
It is not a real product. Its documented payload (firmware 1.x):
{"sn", "fw", "time": "YYYY-MM-DD HH:MM:SS" (UTC), "pos": {"lat","lng"}, "alt": metres above take-off,
"spd_kmh", "hdg", "batt": 0..1, "state"}
FIRMWARE=2 is the "update" the integrator was not told about: altitude in feet with "alt_unit": "ft",
and the time in the dock's local time (UTC+3) without any zone.
"""
import json
import math
import os
import time
from datetime import datetime, timedelta, timezone
import paho.mqtt.client as mqtt
SN = os.environ.get("VX_SN", "VX-1001")
FW = int(os.environ.get("FIRMWARE", "1"))
HOME = (47.3990, 8.5440)
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=SN)
c.connect(os.environ.get("MQTT_HOST", "lab-dops-nats"), 1883)
c.loop_start()
t0 = time.time()
batt = 0.95
print(f"vendor X dock {SN} firmware {FW}.0 publishing to vendorx/{SN}/telemetry", flush=True)
while True:
t = time.time() - t0
a = t / 30 # one circle every ~3 minutes
lat = HOME[0] + 90 / 111320 * math.cos(a)
lng = HOME[1] + 90 / (111320 * math.cos(math.radians(HOME[0]))) * math.sin(a)
alt_m = 45.0
batt = max(0.05, batt - 0.0004)
now = datetime.now(timezone.utc)
msg = {"sn": SN, "fw": f"{FW}.0", "pos": {"lat": round(lat, 7), "lng": round(lng, 7)},
"spd_kmh": round(90 / 30 * 3.6, 1), "hdg": round((math.degrees(a) + 90) % 360, 1),
"batt": round(batt, 3), "state": "PATROL"}
if FW == 1:
msg.update(time=now.strftime("%Y-%m-%d %H:%M:%S"), alt=alt_m)
else:
msg.update(time=(now + timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S"), alt=round(alt_m * 3.28084, 1),
alt_unit="ft")
c.publish(f"vendorx/{SN}/telemetry", json.dumps(msg), qos=1)
time.sleep(1)
شغلنا البرنامج الثابت 2 والمحول ما زال في نسخته الأولى التي تثق بالتوثيق:
docker rm -f lab-dops-vendorx
docker run -d --name lab-dops-vendorx --network lab-dops-cloud -e FIRMWARE=2 lab-dops-py:1 python -u vendorx.py
curl -s http://127.0.0.1:38080/api/state | python3 -c "
import sys, json; s = json.load(sys.stdin)
print([json.dumps({k: d[k] for k in ('drone_id', 'ts', 'rx_ts', 'alt_rel_m')}) for d in s['drones'] if d['drone_id'] == 'vx-1001'])
print([a for a in s['alerts'] if a['drone'] == 'vx-1001']); print(s['counters'])"والمخرج بعد 40 ثانية كان كما يلي:
['{"drone_id": "vx-1001", "ts": "2026-10-13T16:26:52.000Z", "rx_ts": "2026-10-13T13:26:52.308Z", "alt_rel_m": 147.6}']
[{'drone': 'vx-1001', 'type': 'ceiling', 'text': 'altitude 148 m'}, {'drone': 'vx-1001', 'type': 'clock', 'text': 'clock skew -10800 s'}]
{'late': 130, 'future': 40, 'written': 6324, 'duplicates': 57, 'quarantined': 0, 'clock_replaced': 0, 'rejected': 5}والطائرة لم تتغير، ما زالت على ارتفاع 45 متراً، ولكن المنصة تراها على 148 متراً وتطلق تنبيه تجاوز الحد، وترى قراءاتها بعد ثلاث ساعات من الآن. ثم أعدنا المحطة إلى البرنامج الثابت 1، وكأن المصنع تراجع عن التحديث، فكان المخرج بعد 40 ثانية كما يلي، والساعة في السيرفر 13:27:59:
['{"drone_id": "vx-1001", "ts": "2026-10-13T16:27:04.000Z", "rx_ts": "2026-10-13T13:27:04.399Z", "alt_rel_m": 147.6}']
[{'drone': 'vx-1001', 'type': 'ceiling', 'text': 'altitude 148 m'}, {'drone': 'vx-1001', 'type': 'clock', 'text': 'clock skew -10800 s'}]
{'late': 170, 'future': 52, 'written': 6616, 'duplicates': 57, 'quarantined': 0, 'clock_replaced': 0, 'rejected': 5}وهذا أسوأ من الخطأ نفسه، والتفصيل فيما يلي:
- الطائرة متجمدة على الخريطة عند آخر قراءة من البرنامج الثابت 2، لأن وقتها في المستقبل، وكل قراءة صحيحة بعدها تبدو أقدم منها، فزاد العداد
lateمن 130 إلى 170 وهي القراءات الصحيحة التي رفضت الخريطة رسمها. - لا يظهر تنبيه انقطاع، لأن القراءات تصل فعلاً، فالمشغل يرى طائرة «حية» لا تتحرك.
- في قاعدة البيانات 52 صفاً بوقت في المستقبل، وأي لوحة تعرض «آخر ساعة» لن تراها الآن، ثم سوف تظهر فجأة بعد ثلاث ساعات.
والحل في النسخة الثانية من المحول، وهي في core.py عندما يكون VX_ADAPTER يساوي 2:
- الوحدة تقرأ من
alt_unit، والقدم يحول إلى متر، وأي وحدة أخرى تعزل في الموضوعquarantine.vendorxولا تقبل. - إذا اختلف وقت المحطة عن وقت الاستلام بأكثر من 30 ثانية، يستخدم وقت الاستلام بدلاً منه ويحسب في العداد
clock_replaced. - الخدمة لا تسمح لقراءة وقتها في المستقبل أن تصبح «الأحدث» على الخريطة.
VX_ADAPTER=2 docker compose -p lab-dops up -d --no-deps core
FIRMWARE=2 docker compose -p lab-dops up -d --no-deps vendorxوالمخرج بعد 40 ثانية:
['{"drone_id": "vx-1001", "ts": "2026-10-13T13:29:45.950Z", "rx_ts": "2026-10-13T13:29:45.950Z", "alt_rel_m": 44.98848}']
[]
{'late': 0, 'future': 0, 'written': 290, 'duplicates': 0, 'quarantined': 0, 'clock_replaced': 41, 'rejected': 0}الارتفاع 44.98848 متراً، أي 147.6 قدماً بعد التحويل، ولا توجد تنبيهات، وclock_replaced يقول إن 41 قراءة جاء وقتها من السيرفر وليس من المحطة، وهذا رقم تراقبه وتسأل المصنع عنه. ولاحظ أن العدادات بدأت من الصفر لأن الخدمة أعيد تشغيلها، وأن الصفوف الـ 52 التي في المستقبل ما زالت في قاعدة البيانات، وتحتاج حذفاً يدوياً بأمر DELETE يحدد الطائرة والفترة، فالإصلاح يمنع الخطأ القادم ولا يصلح ما دخل.
بعد الهبوط: السجلات
نسخنا سجل PX4 وسجل ArduPilot من المحاكاة كما يفعل تطبيق المحطة بعد الهبوط، ثم قرأناهما بالملف logparse.py في Container:
logparse.py كاملاً
"""Post-flight log parsing: PX4 ULog (.ulg) with pyulog, ArduPilot DataFlash (.bin) with pymavlink.
Both end in the same small summary, so the ops platform can list flights from any autopilot together.
python logparse.py <file>
"""
import json
import sys
from collections import Counter
PX4_NAV = {0: "MANUAL", 2: "POSCTL", 3: "MISSION", 4: "LOITER", 5: "RTL", 14: "OFFBOARD", 17: "TAKEOFF", 18: "LAND"}
def parse_ulog(path):
from pyulog import ULog
# only the topics the summary needs: a long SITL log holds hundreds of MB of others
u = ULog(path, message_name_filter_list=["vehicle_status", "vehicle_global_position", "sensor_combined",
"vehicle_attitude", "vehicle_gps_position"])
dur = (u.last_timestamp - u.start_timestamp) / 1e6
boot_utc = u.msg_info_dict.get("boot_time_utc_us")
topics = {f"{d.name}/{d.multi_id}": d for d in u.data_list}
def rate(name):
d = topics.get(f"{name}/0")
return round(len(d.data["timestamp"]) / dur, 1) if d else None
gp = topics.get("vehicle_global_position/0")
st = topics.get("vehicle_status/0")
modes, last = [], None
if st:
for t, nav in zip(st.data["timestamp"], st.data["nav_state"]):
if nav != last:
modes.append([round((int(t) - u.start_timestamp) / 1e6, 1), PX4_NAV.get(int(nav), int(nav))])
last = nav
return {
"kind": "px4-ulog", "software": u.get_version_info_str(),
"start_unix": (boot_utc + u.start_timestamp) / 1e6 if boot_utc else None, "duration_s": round(dur, 1),
"topics_read": len(u.data_list), "dropouts": len(u.dropouts),
"rate_hz": {"sensor_combined": rate("sensor_combined"), "vehicle_attitude": rate("vehicle_attitude"),
"vehicle_gps_position": rate("vehicle_gps_position")},
"max_alt_amsl_m": round(float(max(gp.data["alt"])), 1) if gp else None,
"modes": modes,
"warnings": [m.message for m in u.logged_messages
if m.log_level_str() in ("WARNING", "ERROR", "CRITICAL", "ALERT", "EMERGENCY")][:10],
}
def parse_bin(path):
from pymavlink import mavutil
mlog = mavutil.mavlink_connection(path)
counts, modes, errors, texts = Counter(), [], [], []
first = last = None
max_alt = None
while True:
m = mlog.recv_msg()
if m is None:
break
t = m.get_type()
counts[t] += 1
ts = getattr(m, "_timestamp", None)
if ts and ts > 1.6e9:
first = first or ts
last = ts
if t == "MODE":
modes.append([round(ts - first, 1) if first else None, mlog.flightmode])
elif t == "ERR":
errors.append(f"Subsys {m.Subsys} ECode {m.ECode}")
elif t == "MSG":
texts.append(m.Message)
elif t == "CTUN":
max_alt = m.Alt if max_alt is None else max(max_alt, m.Alt)
dur = (last - first) if first else 0
return {
"kind": "ardupilot-bin", "software": next((x for x in texts if x.startswith("ArduCopter")), None),
"start_unix": first, "duration_s": round(dur, 1), "message_types": len(counts),
"rate_hz": {k: round(counts[k] / dur, 1) for k in ("IMU", "ATT", "GPS") if dur},
"max_alt_rel_m": round(max_alt, 1) if max_alt is not None else None,
"modes": modes, "errors": errors[:10], "messages": texts[:10],
}
def parse(path):
with open(path, "rb") as f:
head = f.read(7)
if head.startswith(b"ULog"):
return parse_ulog(path)
if head[:2] == b"\xa3\x95": # DataFlash binary message header
return parse_bin(path)
raise ValueError("not a ULog or DataFlash file")
if __name__ == "__main__":
print(json.dumps(parse(sys.argv[1]), indent=1))
docker cp lab-dops-px4a:/root/px4/build/rootfs/log/2026-10-13/12_17_23.ulg px4a.ulg
docker cp lab-dops-ap:/ardupilot/logs/00000001.BIN ap.bin
ls -l px4a.ulg ap.bin
docker run --rm -v "$PWD:/logs:ro" lab-dops-py:1 python logparse.py /logs/px4a.ulgوالملفان كانا كبيرين: سجل PX4 حجمه 418,984,920 بايتاً، وسجل ArduPilot حجمه 398,811,136 بايتاً. والمخرج لسجل PX4 كما يلي، والبرنامج يطبع كل عنصر في سطر، فجمعنا القواميس والقوائم الصغيرة في أسطر قليلة دون تغيير قيمها:
{
"kind": "px4-ulog",
"software": "v1.17.0",
"start_unix": 1791893843.112741,
"duration_s": 1823.7,
"topics_read": 5,
"dropouts": 124,
"rate_hz": { "sensor_combined": 182.2, "vehicle_attitude": 182.5, "vehicle_gps_position": 25.9 },
"max_alt_amsl_m": 56.0,
"modes": [[-0.2, "LOITER"], [26.6, "TAKEOFF"], [40.1, "LOITER"], [192.1, "RTL"], [231.3, "TAKEOFF"],
[242.0, "LOITER"], [271.4, "TAKEOFF"], [271.5, "LOITER"], [338.9, "RTL"], [339.3, "TAKEOFF"],
[340.4, "LOITER"], [760.1, "RTL"], [772.5, "TAKEOFF"], [785.0, "LOITER"], [995.4, "RTL"],
[1011.0, "TAKEOFF"], [1030.2, "LOITER"], [1302.3, "RTL"], [1332.0, "TAKEOFF"], [1350.2, "LOITER"]],
"warnings": []
}
parse_seconds 665ولسجل ArduPilot بنفس الطريقة:
{
"kind": "ardupilot-bin",
"software": "ArduCopter V4.7.1 (dbe79216)",
"start_unix": 1791891580.78334,
"duration_s": 6081.2,
"message_types": 71,
"rate_hz": { "IMU": 45.5, "ATT": 9.2, "GPS": 4.6 },
"max_alt_rel_m": 50.0,
"modes": [[0.0, "GUIDED"], [0.0, "GUIDED"]],
"errors": [],
"messages": ["Arming motors", "ArduCopter V4.7.1 (dbe79216)", "ed3b46d3b2aa", "Param space used: 250/4096",
"RC Protocol: UDP", "New mission", "New rally", "New fence", "Frame: QUAD/PLUS",
"GPS 1: probing for u-blox at 230400 baud"]
}
parse_seconds 1399في المخرج أعلاه لاحظ التالي:
- السجل أغنى بكثير من القياس عن بعد: في سجل PX4 قراءات الحساسات
sensor_combinedبمعدل 182 مرة في الثانية، والقياس عن بعد الذي وصل إلى المنصة قراءتان في الثانية، وصفوف هذه القراءات في TimescaleDB حجمها قرابة 200 بايت للصف كما قسنا، أي أن المنصة رأت جزءاً صغيراً جداً مما حدث. - مدة السجل ليست مدة الطيران على الساعة: سجل PX4 يقول 1823.7 ثانية، أي قرابة نصف ساعة، والمحاكاة كانت تعمل من 12:17 إلى 13:37 تقريباً، أي قرابة 80 دقيقة. والسبب نفسه الذي أخر ساعة ArduPilot: المحاكاة على هذا الجهاز أبطأ من الزمن الحقيقي، والسجل يكتب بزمن المحاكاة.
- تغيرات الوضع تحكي ما حدث: في القائمة خمس عودات RTL، وكل واحدة يتبعها إقلاع من برنامج الطيار. وأغلب ظننا أنها انقطاع رابط محطة التحكم كلما أعدنا تشغيل البوابة، ولكننا لم نحفظ رسائل PX4 من ذلك التشغيل، فلم نتحقق من السبب، وهذا بالضبط ما يحفظه لك السجل إذا قرأت رسائله النصية.
- القراءة بطيئة: 665 ثانية لسجل PX4 حتى مع قراءة خمسة مواضيع فقط، و1399 ثانية لسجل ArduPilot، لذلك لا تقرأ السجل داخل طلب الرفع نفسه كما يفعل
POST /api/logsفي المختبر، وإنما احفظ الملف ورد فوراً، ثم اقرأه في Worker منفصل. - القائمة
warningsفارغة في هذا المخرج لأن النسخة التي شغلناها كانت تقارن مستوى الرسالة برقم بطريقة خاطئة، وأصلحناها في الملف أعلاه باستخدامlog_level_str()ولم نعد تشغيلها على هذا الملف.
وقد شرحنا قراءة ULog بالتفصيل في مقال التطوير، فلا نعيده هنا.
التحليل في Grafana
Grafana يقرأ من TimescaleDB مباشرة، واللوحة في grafana/dashboards/fleet.json فيها أربعة رسوم وجدول، والصورة التالية آخر ثلاث ساعات من المختبر:

في الصورة أعلاه لاحظ التالي:
- القراءات من المستقبل وصلت: القفزة الزرقاء إلى قرابة 150 متراً عند 16:27 هي قراءات البرنامج الثابت 2 التي أرسلت عند 13:27 بالتوقيت العالمي ووقتها مكتوب 16:27، وفي رسم التأخير تظهر عند الوقت نفسه بقيمة سالبة قرابة -2.78 ساعة، أي أنها حفظت قبل أن «تقاس». ولو كان على هذا الرسم تنبيه لرأيت المشكلة قبل ثلاث ساعات.
- ساعة ArduPilot تنجرف: الخط الأخضر في رسم التأخير يرتفع ببطء طوال الوقت، وهو انجراف ساعة المحاكاة الذي رأيناه في التنبيهات.
- PX4 على جهاز مزدحم: القراءات الصفراء بين -50 و0 متر في أول اللوحة وآخرها كانت بعد إعادة إنشاء PX4، حين رفض التسليح بسبب
Accel #0 fail: TIMEOUT!، أي أن المقدر على الأرض كان يعطي ارتفاعاً خاطئاً، والمنصة رسمته كما وصلها. - جدول السجلات فارغ: لأننا قرأنا السجلات من سطر الأوامر في القسم السابق ولم نرفعها عبر
POST /api/logsفي هذا التشغيل، فالواجهة موجودة فيcore.pyولم نختبرها بملف حقيقي.
الفيديو: التأخير لكل بروتوكول
شرحنا في مقال بث الفيديو كيف قسنا التأخير بطرق المشاهدة المختلفة، وهنا نثبت المشاهدة على WebRTC لأنها ما يحتاجه جدار الفيديو، ونغير بروتوكول وصول الفيديو من الطائرة إلى السيرفر، لأن هذا الجزء هو الذي يمر على رابط الطائرة الضعيف. والطريقة كما يلي:
- الكاميرا:
camera.pyيصنع إطارات 640×360 بمعدل 15 إطاراً في الثانية، ويرسم على كل إطار وقت إنشائه بالملي ثانية على شكل 32 مربعاً أبيض وأسود، ثم يرمزها ffmpeg بـ H.264 بمعدل 600 كيلوبت ويرسلها بالبروتوكول المختار إلى MediaMTX. - المشاهد: صفحة
latency.htmlفي Chrome بلا شاشة على الجهاز نفسه، تشغل الفيديو عبر WHEP، وتقرأ المربعات من كل إطار يعرض، وتطرح الوقت المكتوب فيه من ساعتها. - الساعة: المتصفح على Windows وال Containers في Docker Desktop، فتقيس الصفحة الفرق بين الساعتين أولاً بعشرين طلباً إلى
/api/timeوتأخذ الطلب الأقصر، وكان الفرق بين -4 و10 ملي ثوان في القياسات أدناه.
وكانت البداية بدقة 1280×720 ومعدل 30 إطاراً، فلم يستطع ffmpeg أن يرمز بسرعة الزمن الحقيقي على هذا الجهاز وهو مشغول بغيرنا، فخمس ثوان من فيديو اختبار بدقة 960×540 أخذت 14 ثانية، فنزلنا إلى 640×360 بمعدل 15 إطاراً ومستوى الترميز ultrafast. والكاميرا وصفحة القياس والسكربت في البطاقات التالية:
camera.py كاملاً
"""A test "drone camera" for measuring glass-to-glass latency.
Every frame carries the wall clock of the moment it was made (milliseconds, modulo 2^32) as 32 black/white
blocks in the top-left corner, between a white and a black reference block. The viewer page reads the blocks
back from the decoded video and subtracts them from its own clock.
python3 camera.py <rtsp-tcp|rtsp-udp|rtmp|srt> [bitrate_kbps] [host] [path]
"""
import subprocess
import sys
import time
import numpy as np
W, H, FPS = 640, 360, 15
BLOCK, X0, Y0 = 16, 16, 16
proto = sys.argv[1]
kbps = int(sys.argv[2]) if len(sys.argv) > 2 else 600
host = sys.argv[3] if len(sys.argv) > 3 else "lab-dops-mtx"
path = sys.argv[4] if len(sys.argv) > 4 else "cam"
OUT = {
"rtsp-tcp": ["-f", "rtsp", "-rtsp_transport", "tcp", f"rtsp://{host}:8554/{path}"],
"rtsp-udp": ["-f", "rtsp", "-rtsp_transport", "udp", f"rtsp://{host}:8554/{path}"],
"rtmp": ["-f", "flv", f"rtmp://{host}:1935/{path}"],
"srt": ["-f", "mpegts", f"srt://{host}:8890?streamid=publish:{path}&pkt_size=1316&latency=200000"],
}[proto]
ffmpeg = subprocess.Popen(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}",
"-r", str(FPS), "-i", "-", "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
"-pix_fmt", "yuv420p", "-g", str(FPS), "-b:v", f"{kbps}k", "-maxrate", f"{kbps}k",
"-bufsize", f"{kbps // 2}k", *OUT],
stdin=subprocess.PIPE)
# a moving scene so the encoder has real work: 30 prepared frames of shifted stripes and noise, played in a loop
rng = np.random.default_rng(7)
noise = rng.integers(0, 50, size=(H, W), dtype=np.uint8)
stripes = ((np.arange(W + H)[None, :] // 24) % 2 * 120).astype(np.uint8)
SCENE = []
for k in range(FPS):
g = stripes[:, k * 8 % 48:][:, :W].repeat(H, axis=0) + noise
SCENE.append(np.stack([g, g // 2 + 60, 200 - g // 2], axis=2))
def frame(n, stamp):
img = SCENE[n % FPS].copy()
img[0:Y0 * 2 + BLOCK, 0:X0 * 2 + 34 * BLOCK] = 128 # grey band behind the code
bits = [1] + [(stamp >> (31 - i)) & 1 for i in range(32)] + [0] # white ref, 32 bits, black ref
for i, b in enumerate(bits):
x = X0 + i * BLOCK
img[Y0:Y0 + BLOCK, x:x + BLOCK] = 255 if b else 0
return img.tobytes()
start = time.time()
n = 0
late = 0
while True:
due = start + n / FPS
now = time.time()
if now < due:
time.sleep(due - now)
elif now - due > 1 / FPS:
late += 1 # we could not keep up (the pipe was blocked)
stamp = int(time.time() * 1000) % (1 << 32)
try:
ffmpeg.stdin.write(frame(n, stamp))
except BrokenPipeError:
sys.exit("ffmpeg stopped")
n += 1
if n % (FPS * 10) == 0:
print(f"{proto}: {n} frames sent, {late} frames late, {n / (time.time() - start):.1f} fps", flush=True)
web/latency.html كاملاً
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Glass to glass</title>
<style>body { margin: 0; background: #111; color: #eee; font: 16px monospace; } video { width: 640px; } #out { padding: 8px; }</style>
</head>
<body>
<video id="v" muted autoplay playsinline></video>
<div id="out">starting</div>
<script>
// 1) clock offset between this browser and the lab containers, from the round trip with the least delay
// 2) WHEP playback from MediaMTX
// 3) for every decoded frame: read the 32 blocks the camera drew, latency = our clock - camera clock
const W = 640, H = 360, BLOCK = 16, X0 = 16, Y0 = 16;
window.samples = []; window.bad = 0; window.frames = 0; window.offset = null;
const out = document.getElementById('out');
async function clockOffset() {
let best = null;
for (let i = 0; i < 20; i++) {
const t0 = Date.now(); const r = await (await fetch('/api/time', { cache: 'no-store' })).json(); const t1 = Date.now();
if (!best || t1 - t0 < best.rtt) best = { rtt: t1 - t0, off: r.t - (t0 + t1) / 2 };
}
return best;
}
async function play(url) {
const pc = new RTCPeerConnection();
pc.addTransceiver('video', { direction: 'recvonly' });
const v = document.getElementById('v');
pc.ontrack = (e) => { v.srcObject = e.streams[0]; };
await pc.setLocalDescription(await pc.createOffer());
await new Promise(r => { pc.onicegatheringstatechange = () => pc.iceGatheringState === 'complete' && r(); setTimeout(r, 2000); });
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp });
if (!res.ok) throw new Error('WHEP ' + res.status);
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
return v;
}
function decode(ctx) {
const px = ctx.getImageData(0, 0, X0 + 34 * BLOCK + 8, Y0 + BLOCK).data;
const lum = (i) => { const x = X0 + i * BLOCK + BLOCK / 2, y = Y0 + BLOCK / 2, k = (y * (X0 + 34 * BLOCK + 8) + x) * 4; return px[k]; };
const white = lum(0), black = lum(33), mid = (white + black) / 2;
if (white - black < 100) return null; // the code is not readable (corrupt frame)
let v = 0;
for (let i = 1; i <= 32; i++) v = (v * 2) + (lum(i) > mid ? 1 : 0);
return v;
}
(async () => {
const p = new URLSearchParams(location.search);
const off = await clockOffset(); window.offset = off;
let v;
for (let i = 0; ; i++) { // the camera may not be publishing yet: retry for a minute
try { v = await play(p.get('whep')); break; } catch (e) { if (i > 30) throw e; out.textContent = 'waiting: ' + e; await new Promise(r => setTimeout(r, 2000)); }
}
const c = document.createElement('canvas'); c.width = W; c.height = H;
const ctx = c.getContext('2d', { willReadFrequently: true });
const onFrame = () => {
window.frames++;
ctx.drawImage(v, 0, 0, W, H);
const code = decode(ctx);
const now = (Date.now() + off.off) % 2 ** 32; // our clock in the containers' time base
if (code === null) window.bad++;
else {
const lat = now - code;
if (lat >= 0 && lat < 60000) window.samples.push(lat); else window.bad++;
}
v.requestVideoFrameCallback(onFrame);
};
v.requestVideoFrameCallback(onFrame);
setInterval(() => { const s = window.samples.slice(-60).sort((a, b) => a - b);
out.textContent = `offset ${off.off.toFixed(1)} ms (rtt ${off.rtt} ms) frames ${window.frames} bad ${window.bad} median(last 60) ${s[s.length >> 1] ?? '-'} ms`; }, 1000);
})().catch(e => out.textContent = 'error ' + e);
</script>
</body>
</html>
measure-latency.mjs كاملاً
// Glass-to-glass latency per ingest protocol: the test camera publishes to MediaMTX, a headless Chrome on the
// host plays it over WebRTC (WHEP) and reads the time code from every frame.
// node content/articles/drone-ops-lab/lab/measure-latency.mjs [condition]
// condition: clean | loss2 (2% of the camera's packets dropped before MediaMTX reads them)
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { launch } from '../../../../scripts/lab/browser.mjs';
const condition = process.argv[2] || 'clean';
const CAMERA = resolve(dirname(fileURLToPath(import.meta.url)), 'camera.py'); // the current camera.py, mounted
const PROTOS = (process.env.PROTOS || 'rtsp-tcp,rtsp-udp,rtmp,srt').split(',');
const sh = (...a) => execFileSync('docker', a, { encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } }).trim();
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// tc netem is not available in this Docker Desktop kernel, so the weak link is made with iptables rules on the
// receiving side (MediaMTX's INPUT, packets from the camera only); a DROP on the sender's own OUTPUT chain makes
// sendto() fail with EPERM and ffmpeg gives up, which is not what a radio link does
const RULES = {
clean: [],
loss2: (ip) => [['-A', 'INPUT', '-s', ip, '-m', 'statistic', '--mode', 'random', '--probability', '0.02', '-j', 'DROP']],
};
const mtxIptables = (...a) => sh('run', '--rm', '--network', 'container:lab-dops-mtx', '--cap-add', 'NET_ADMIN',
'lab-dops-py:1', 'iptables', ...a);
const pct = (a, p) => a.length ? a[Math.min(a.length - 1, Math.floor(a.length * p))] : null;
const b = await launch({ port: 9452, width: 1000, height: 640 });
try {
for (const proto of PROTOS) {
const cam = `lab-dops-cam-${proto}`; // --init: ffmpeg children are reaped and the container stops cleanly
try { sh('rm', '-f', cam); } catch {}
sh('run', '-d', '--init', '--name', cam, '--network', 'lab-dops-cloud', '-v', `${CAMERA}:/cam/camera.py:ro`,
'--entrypoint', 'sh', 'lab-dops-cam:1', '-c', 'sleep 3600');
const ip = sh('inspect', '-f', '{{(index .NetworkSettings.Networks "lab-dops-cloud").IPAddress}}', cam);
mtxIptables('-F', 'INPUT');
for (const r of (condition === 'clean' ? [] : RULES[condition](ip))) mtxIptables(...r);
const path = `${condition}-${proto}`; // a fresh MediaMTX path per run, so an old publisher cannot block it
const since = new Date().toISOString();
sh('exec', '-d', cam, 'sh', '-c', `python3 -u camera.py ${proto} 600 lab-dops-mtx ${path} > /tmp/cam.log 2>&1`);
for (let i = 0; i < 45 && !sh('logs', '--since', since, 'lab-dops-mtx').includes(`publishing to path '${path}'`); i++) await sleep(2000);
await b.goto('http://127.0.0.1:38080/web/latency.html?whep=http://127.0.0.1:38889/' + path + '/whep', { wait: 1000 });
await sleep(15000); // let the WebRTC jitter buffer settle
const start = await b.evaluate('({ n: window.samples.length, f: window.frames, bad: window.bad })');
await sleep(30000);
const r = await b.evaluate('({ s: window.samples, f: window.frames, bad: window.bad, off: window.offset })');
const s = r.s.slice(start.n).sort((x, y) => x - y);
console.log(JSON.stringify({ condition, proto, frames_30s: r.f - start.f, unreadable: r.bad - start.bad,
samples: s.length, min: s[0], p50: pct(s, 0.5), p95: pct(s, 0.95), max: s[s.length - 1],
clock_offset_ms: Math.round(r.off.off), rtt_ms: r.off.rtt }));
await b.goto('about:blank', { wait: 500 });
console.log(sh('exec', cam, 'sh', '-c', 'tail -2 /tmp/cam.log'));
try { sh('rm', '-f', cam); } catch {}
mtxIptables('-F', 'INPUT');
}
} finally {
await b.close();
}
node content/articles/drone-ops-lab/lab/measure-latency.mjs clean
node content/articles/drone-ops-lab/lab/measure-latency.mjs loss2والنتائج كما يلي، كل خانة من 30 ثانية من القراءات بعد 15 ثانية من بدء المشاهدة، والأرقام بالملي ثانية بعد التقريب، وp50 هو الوسيط، وp95 القيمة التي تحتها 95% من الإطارات:
| البروتوكول | رابط سليم p50 | رابط سليم p95 | فقد 2% p50 | فقد 2% p95 |
|---|---|---|---|---|
| RTSP عبر TCP | 173 | 5817 | 457 | 1050 |
| RTSP عبر UDP | 114 | 182 | لم تظهر صورة في 30 ثانية | |
| RTMP | 37 | 69 | 32 | 71 |
| SRT بمهلة 200 ملي ثانية | 363 | 381 | 386 | 1387 |
وهذا مثلاً السطر الذي طبعه السكربت لـ RTMP على الرابط السليم كما هو:
{"condition":"clean","proto":"rtmp","frames_30s":409,"unreadable":0,"samples":409,"min":18.4697265625,"p50":37.4697265625,"p95":69.4697265625,"max":151.4697265625,"clock_offset_ms":2,"rtt_ms":3}وقبل أن تقرأ الأرقام تذكر الأمور التالية، والسبب أنها تغير معناها:
- الجهاز مشغول: متوسط الحمل في الآلة الافتراضية كان قرابة 8 أثناء قياس الرابط السليم، وقرابة 43 أثناء قياس الفقد، على ثمانية أنوية. وفي تشغيل سابق للرابط السليم والحمل بين 30 و70 كانت الأرقام أسوأ بكثير، فالوسيط لـ SRT كان 1226 ولـ RTSP عبر UDP كان 1398، لذلك قارن بين البروتوكولات في القياس نفسه، ولا تقارن بين قياسين.
- الفقد مصطنع: لم نستطع استخدام
tc netemكما رأينا، فأسقطنا 2% من حزم الكاميرا عشوائياً بقاعدة iptables في مدخل MediaMTX. ولا يوجد تأخير مضاف ولا تذبذب، والرابط داخل جهاز واحد، فالتأخير في الجدول هو الترميز والتوزيع والعرض، وعلى رابط حقيقي يضاف إليه زمن الرابط نفسه. - لم نقس الرابط الأضيق من الفيديو: أردنا أن نحدد سرعة رفع الكاميرا بأقل من معدل الفيديو بالإضافة
hashlimitفي iptables، فرفضتها النواة برسالةExtension hashlimit revision 0 not supported, missing kernel module?، فبقي هذا القياس خارج المقال. - القاعدة في المرسل لا تصلح: في المحاولة الأولى وضعنا قاعدة الإسقاط في مخرج Container الكاميرا، فتوقف ffmpeg مع RTSP عبر UDP برسالة تنتهي بـ
(Operation not permitted)، لأن النواة تخبر البرنامج بأن حزمته رفضت، والراديو الحقيقي لا يفعل ذلك، لذلك نقلنا القاعدة إلى جهة الاستقبال.
وفي النتائج لاحظ التالي:
- RTMP كان الأسرع والأثبت هنا: وسيط قرابة 35 ملي ثانية في الحالتين، والسبب أن TCP داخل جهاز واحد يعيد الحزمة الضائعة في جزء من الملي ثانية، أما على رابط طائرة زمن الذهاب والعودة فيه مئات الملي ثوان فكل إعادة إرسال تؤخر ما بعدها، فلا تأخذ هذا الرقم إلى الحقل.
- RTSP عبر TCP تراكم فيه التأخير: الوسيط 173، ولكن 5% من الإطارات وصلت بعد أكثر من 5.8 ثانية على الرابط السليم، وكان سجل الكاميرا في الوقت نفسه يقول إنها تأخرت في إخراج بعض الإطارات، فما يتأخر عند المرسل يصل دفعة واحدة متأخراً.
- RTSP عبر UDP لم يعرض شيئاً مع الفقد: على الرابط السليم كان من الأقل تأخيراً، ومع فقد 2% لم تظهر صورة في المتصفح طوال القياس، ولم نتعمق في السبب، والدرس أن UDP دون إعادة إرسال قد لا يعطيك صورة أبداً، وليس صورة مشوهة فقط.
- SRT يدفع المهلة ثمناً للثبات: أقل تأخير له قرابة 245 ملي ثانية في القياسين، لأن المهلة التي طلبناها 200 ملي ثانية، وهي الوقت الذي ينتظره ليعيد إرسال الضائع. وعلى الرابط السليم كان الوسيط و p95 متقاربين جداً، ومع الفقد بقي الوسيط تقريباً كما هو، ولكن أبطأ الإطارات وصلت بعد قرابة 1.4 ثانية.
والخلاصة العملية لجدار الفيديو: SRT هو الأنسب للرابط الضعيف، لأنك تحدد بنفسك كم تنتظر الحزمة الضائعة، وRTMP مقبول عندما يكون الرابط جيداً وهو المدعوم في أغلب الأجهزة ومنها DJI كما في مقال المعمارية، وقس على رابطك أنت قبل أن تقرر، لأن الأرقام أعلاه من جهاز واحد مزدحم.
وأما التسجيل فيعمل في MediaMTX من الإعداد record: yes في mediamtx.yml، فيكتب كل مسار في ملفات fMP4 مدة كل منها دقيقة، ويحذفها بعد ساعة بالإعداد recordDeleteAfter، وهذه النسخة المباشرة فقط، أما الملف الأصلي فيرفع من بطاقة الذاكرة بعد الهبوط.
حذف المختبر
docker compose -p lab-dops --profile second-px4 down -v
docker image rm lab-dops-py:1 lab-dops-ardupilot:1 lab-dops-cam:1
docker image rm jonasvautherin/px4-gazebo-headless:1.17.0 bluenviron/mediamtx:1.21.2 grafana/grafana:13.2.3والأمر الأول يحذف كل Container وVolume وشبكة باسم المشروع فقط، والأمران التاليان يحذفان Docker Images التي بنيناها أو نزلناها لهذا المختبر بأسمائها، ومنها Docker Image ال PX4 وحجمها قرابة 10 جيجابايت.
أخطاء شائعة
- الكتابة على القرص داخل حلقة الأحداث: كل
commitمتزامن يوقف استقبال الحزم، والحزم التي لا تقرأ تسقطها النواة بصمت. - إفراغ المخزن رسالة برسالة: على رابط بطيء أو وسيط مشغول لا يفرغ المخزن أبداً.
- رسالة سيئة واحدة في سجل محفوظ: تتوقف الخدمة عندها في كل تشغيل إذا لم تتجاوزها وتعدها.
- قبول وقت المحطة كما هو: ساعة بلا منطقة زمنية تجمد الطائرة على الخريطة دون أي تنبيه انقطاع.
- إعادة تشغيل البوابة أثناء الطيران: إذا كانت هي محطة التحكم عند الطيار الآلي فقد تنفذ الطائرات فعل فقدان الرابط.
- قراءة السجل داخل طلب الرفع: السجل الكبير يأخذ دقائق.
تدريب عملي
- غير
FIRMWAREفيvendorx.pyليرسل"alt_unit": "yd"، وتأكد أن المحول في نسخته الثانية يعزل الرسائل ويزيدquarantined. - أوقف
lab-dops-dbدقيقتين ثم شغله، وتأكد من Grafana أن القراءات لم تضع، لأن المستهلك الدائمdb-writerيكمل من حيث توقف.
الخلاصة
- طائرتان من طيارين آليين مختلفين ومحطة مصنع ثالث على خريطة واحدة تحتاج محولاً لكل لغة ومخططاً واحداً بعدها، والهوية من الرابط وليس من رقم النظام.
- التخزين ثم الإرسال يحتاج أمرين لا يظهران في الرسم: كتابة على القرص لا توقف الاستقبال، وإفراغ أسرع من الوارد.
- الوقت من الطائرة ضروري للترتيب، ولكنه يحتاج فحصاً، فساعة متأخرة أو متقدمة ظهرت في المختبر من تلقاء نفسها.
- السجل بعد الهبوط أغنى بمئات المرات من القياس عن بعد، وقراءته عمل يأخذ دقائق.
- تأخير الفيديو يتحدد ببروتوكول الوصول، وعلى جهاز واحد كان SRT ثابتاً بثمن مهلته، وRTSP عبر UDP لم يعرض شيئاً مع فقد 2%، وقياسك على رابطك هو ما يقرر.
دليلكيف تبني منصة قيادة وتحكم لأسطول طائرات مسيرةكيف تجمع القياس عن بعد والسجلات والفيديو من طائرات مصنعين مختلفين في منصة واحدة تراقب منها الأسطول لحظة بلحظة.
دليلبرمجة الطائرات المسيرة مع PX4 وMAVSDKكيف تكتب برنامجاً يقلع بالطائرة المسيرة ويطير مهمة ويعود، وتختبره في محاكاة، وترى كيف يحميها الطيار الآلي عند الأعطال.
دليلROS 2 وPX4: كيف تطور برمجيات الطائرة المسيرةكيف تطور كود الطائرة المسيرة من المحاكاة إلى الحقل، وتربط ROS 2 بالطيار الآلي PX4، وتقرأ سجل الطيران عندما لا يطير كما توقعت.سجل التحديثات
- أكتوبر 2026: كتابة المقال، والتجربة على PX4 v1.17.0 (jonasvautherin/px4-gazebo-headless:1.17.0 مع Gazebo 8.11.0) وArduCopter 4.7.1 SITL من firmware.ardupilot.org وNATS 2.15.0 وTimescaleDB 2.30.2 على PostgreSQL 18.6 وMediaMTX 1.21.2 وGrafana 13.2.3 وPython 3.13.16 مع pymavlink 2.4.50 وnats-py 2.16.0 وasyncpg 0.32.0 وaiohttp 3.14.5 وpaho-mqtt 2.1.0 وpyulog 1.2.4، ومكتبة MapLibre GL JS 5.24.0، وffmpeg من Debian trixie، على Docker Desktop في 13 أكتوبر 2026.