برمجة المتحكّمات بلغة مايكروبايثون · الفصل 23 — راسبيري باي خادمًا لوحدات ESP32Raspberry Pi כשרת ליחידות ESP32 · תכנות מיקרו-בקרים בשפת MicroPython⌂📚🏠
الفصل 23

راسبيري باي خادمًا لوحدات ESP32

Raspberry Pi כשרת ליחידות ESP32 – Raspberry Pi as a Server
3 ساعات تعليمية

حتى الآن كانت كل لوحة تعمل وحدها، وإذا انقطعت الكهرباء ضاعت قراءاتها. في هذا الفصل نبني نظاما من طابقين: وحدات ESP32 تقيس وتنشر، وراسبيري باي واحد يستقبل ويخزّن ويعرض. سنكتب الطرفين، ونتابع القراءة نفسها من الحسّاس إلى صفحة في المتصفّح.

في هذا الفصل:
  • نثبّت الوسيط Mosquitto على الباي، مع مستخدمين وكلمات مرور.
  • ننشر القراءات من ESP32، ونستقبلها على الباي بالمكتبة paho-mqtt.
  • نخزّن القراءات في قاعدة بيانات SQLite، ونستعلم عنها.
  • نعرض آخر القراءات في صفحة ويب بـFlask.
  • نشغّل كل ذلك خدمات، ونثبّت عنوان الباي، ونأخذ نسخة احتياطية.

23.1 صورة النظام – תמונת המערכת (System Overview)

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

راسبيري بايESP32esp32-1ESP32esp32-2الوسيطMosquittoالمشتركmqtt_store.pyقاعدة البياناتreadings.dbصفحة الويبweb_page.pyالمتصفّحMQTTHTTP
الطريق الكامل للقراءة. كل مربّع داخل الباي برنامج مستقل، وخدمة مستقلة.
الجزءأين يعملعملهالقسم
الوحدةESP32تقرأ الحسّاس وتنشر كل 30 s23.3
Mosquittoالباييستقبل الرسائل ويوصلها إلى المشتركين23.2
mqtt_store.pyالباييشترك في الرسائل ويكتبها في قاعدة البيانات23.4
readings.dbالبايملف قاعدة البيانات23.5
web_page.pyالباييعرض آخر القراءات للمتصفّح23.6
سؤال 23.1: لماذا لا تكتب كل وحدة قراءاتها في ذاكرتها، كما فعلنا في الفصل 12؟ اذكروا سببين.

23.2 الوسيط Mosquitto – המתווך Mosquitto

في الفصل 16 جرّبنا وسيطا عاما على الإنترنت. الآن يصبح الوسيط عندنا، على الشبكة المحلية، فلا يرى رسائلنا أحد غيرنا. أوامر النظام والبرامج غير المُحاكاة في هذا الفصل لم تُنفَّذ في بناء الكتاب، لأنها تحتاج إلى باي حقيقي؛ ونذكر ذلك عند كل برنامج.

التثبيت ومستخدمان، واحد لكل طرف. كل أمر mosquitto_passwd يسأل عن كلمة المرور.
sudo apt update
sudo apt install mosquitto mosquitto-clients
sudo mosquitto_passwd -c /etc/mosquitto/passwd esp32
sudo mosquitto_passwd /etc/mosquitto/passwd pi-store
sudo chown mosquitto: /etc/mosquitto/passwd
sudo chmod 600 /etc/mosquitto/passwd
sudo nano /etc/mosquitto/conf.d/local.conf
sudo systemctl restart mosquitto
sudo systemctl enable mosquitto
/etc/mosquitto/conf.d/local.conf
listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd
خطأ شائع: منذ الإصدار 2.0 لا يقبل Mosquitto بلا listener إلا اتصالات من الباي نفسه، فترفض الوحدة بينما يعمل كل شيء محليا. والخيار -c ينشئ الملف من جديد ويمحو ما فيه، لذلك نستعمله مع المستخدم الأول فقط.

نجرّب قبل كتابة أي برنامج. في نافذة SSH أولى ننتظر الرسائل، وفي الثانية ننشر رسالة، فتظهر في الأولى. كلمة المرور change-me في هذا الفصل مثال فقط: اختاروا غيرها.

السطر الأول في نافذة، والباقي في نافذة ثانية.
mosquitto_sub -h localhost -u pi-store -P change-me -t "home/#" -v
mosquitto_pub -h localhost -u esp32 -P change-me \
    -t home/test/env -m '{"t": 21.5, "h": 40.0}'
ملاحظة: الاتصال على المنفذ 1883 غير مشفّر: كلمة المرور والرسائل تمر كما هي في الشبكة. هذا مقبول داخل شبكة موثوقة، ولا نفتح هذا المنفذ نحو الإنترنت.
سؤال 23.2: نفّذنا mosquitto_pub بلا -u و-P. ماذا نتوقع؟ وأي سطر في الإعداد مسؤول عن ذلك؟

23.3 الوحدة: ESP32 ينشر – היחידה מפרסמת (The Publisher)

التوصيل كما في القسم 13.1، والاتصال بالشبكة بالملف wifi.py من الفصل 14، وكلمة مرور الشبكة في secrets.py. أما الحمولة فنص JSON قصير، والموضوع يحمل اسم الوحدة.

برنامج الوحدة. عنوان الوسيط هو عنوان الباي على شبكتنا. وتحت المُخرَج: ما وصل إلى الوسيط فعلا.
from machine import Pin
from umqtt.simple import MQTTClient
import dht
import time
import board
import wifi

UNIT = "esp32-1"
BROKER = "192.168.1.50"
MQTT_USER = "esp32"
MQTT_PASS = "change-me"
TOPIC = "home/%s/env" % UNIT

sensor = dht.DHT22(Pin(board.DHT))
wifi.connect()
client = MQTTClient(UNIT, BROKER, user=MQTT_USER, password=MQTT_PASS)
client.connect()

while True:
    sensor.measure()
    t = sensor.temperature()
    h = sensor.humidity()
    payload = '{"t": %.1f, "h": %.1f}' % (t, h)
    client.publish(TOPIC, payload)
    print(TOPIC, payload)
    time.sleep(30)
ESP32 + Pico W · MicroPython 1.29 · simulated board
home/esp32-1/env {"t": 24.5, "h": 60.0}
home/esp32-1/env {"t": 25.5, "h": 60.0}
home/esp32-1/env {"t": 25.5, "h": 60.0}
[mqtt_pub]
home/esp32-1/env {"t": 24.5, "h": 60.0}
home/esp32-1/env {"t": 25.5, "h": 60.0}
home/esp32-1/env {"t": 25.5, "h": 60.0}
...

البرنامج يستعمل board ويعمل كما هو على Pico W أيضا. ولكل وحدة نغيّر سطرا واحدا: UNIT. وفي مشروع يعمل طويلا نضيف إعادة الاتصال عند انقطاع الوسيط، كما في الفصل 16.

خطأ شائع: يجب أن يكون UNIT مختلفا في كل وحدة: إذا اتصلت وحدتان بالمعرّف نفسه، يقطع الوسيط اتصال الأولى، فتتناوبان الانقطاع بلا نهاية.
سؤال 23.3: أضفنا وحدة ثالثة في المخزن باسم esp32-3. على أي موضوع تنشر؟ وهل نحتاج إلى تغيير شيء على الباي؟

23.4 الباي يستقبل: paho-mqtt – הפאי מקבל (The Subscriber)

على الباي نستعمل مكتبة بايثون paho-mqtt، ونثبّتها مع Flask في البيئة الافتراضية كما تعلّمنا في القسم 22.2:

cd ~/project
python -m venv --system-site-packages env
source env/bin/activate
pip install paho-mqtt flask
mqtt_store.py: المكتبة غير مثبّتة في بيئة بناء الكتاب، فلم يُنفَّذ البرنامج، ومرّ بالترجمة فقط. كُتب للإصدار 2 من المكتبة.
import paho.mqtt.client as mqtt
from readings_db import open_db, save_message

db = open_db()


def on_connect(client, userdata, flags, reason_code, properties):
    print("connected:", reason_code)
    client.subscribe("home/+/env")


def on_message(client, userdata, msg):
    ok = save_message(db, msg.topic, msg.payload)
    print(msg.topic, msg.payload, "saved" if ok else "rejected")


client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.username_pw_set("pi-store", "change-me")
client.on_connect = on_connect
client.on_message = on_message
client.connect("localhost", 1883)
client.loop_forever()
الأمرعمله
mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)ننشئ العميل. المعامل الأول يحدّد شكل الدوال المستدعاة، وهو واجب في الإصدار 2.
on_connectتُستدعى بعد كل اتصال ناجح، ولذلك نضع الاشتراك فيها.
on_messageتُستدعى عند كل رسالة. msg.topic نص، وmsg.payload بايتات.
client.loop_forever()يبقى البرنامج هنا، ويتولى الشبكة وإعادة الاتصال.
سؤال 23.4: لماذا وضعنا client.subscribe داخل on_connect وليس بعد connect مباشرة؟

23.5 التخزين: SQLite – שמירה במסד נתונים (SQLite)

قاعدة البيانات (מסד נתונים): ملف منظّم في جداول (טבלאות)، نكتب فيه ونسأله بلغة SQL. وSQLite قاعدة بيانات في ملف واحد بلا خادم، ومكتبتها sqlite3 مبنيّة في بايثون.

نجمع كل التعامل مع القاعدة في مكتبة واحدة، يستعملها المشترك وصفحة الويب والنسخ الاحتياطي:

mpy_lib/pi/readings_db.py: الجدول والكتابة.
# readings_db.py - sensor readings in one SQLite file, on the Pi.
# (c) 2026 Fahed Ghanem - MIT License - fahed-lab.com
import json
import sqlite3
import time

TABLE = """CREATE TABLE IF NOT EXISTS readings (
    id INTEGER PRIMARY KEY,
    unit TEXT NOT NULL,
    received REAL NOT NULL,
    temp REAL NOT NULL,
    hum REAL NOT NULL)"""
ADD = "INSERT INTO readings (unit, received, temp, hum) VALUES (?, ?, ?, ?)"


def open_db(path="readings.db"):
    db = sqlite3.connect(path)
    db.execute(TABLE)
    db.commit()
    return db


def save_message(db, topic, payload, now=None):
    try:
        unit = topic.split("/")[1]
        data = json.loads(payload)
        when = time.time() if now is None else now
        row = (unit, when, float(data["t"]), float(data["h"]))
    except (ValueError, KeyError, IndexError, TypeError):
        return False
    db.execute(ADD, row)
    db.commit()
    return True
تكملة الملف نفسه: الاستعلام والحذف والنسخ الاحتياطي.
LATEST = """SELECT unit, received, temp, hum FROM readings
    ORDER BY id DESC LIMIT ?"""
AVERAGES = """SELECT unit, COUNT(*), AVG(temp), MAX(temp) FROM readings
    WHERE received >= ? GROUP BY unit ORDER BY unit"""
PRUNE = "DELETE FROM readings WHERE received < ?"


def latest(db, count=10):
    return db.execute(LATEST, (count,)).fetchall()


def averages(db, since=0):
    return db.execute(AVERAGES, (since,)).fetchall()


def prune(db, before):
    deleted = db.execute(PRUNE, (before,)).rowcount
    db.commit()
    return deleted


def backup(db, path):
    copy = sqlite3.connect(path)
    db.backup(copy)
    copy.close()

الدالة save_message لا تثق بالرسالة: رسالة تالفة تعيد False، ولا توقف الخادم. والزمن من ساعة الباي عند الوصول، فلا تحتاج الوحدة إلى ساعة مضبوطة. والآن نجرّب المكتبة فعلا: الرسائل التالية هي ما نشرته عند بناء الكتاب وحدتان بالبرنامج نفسه من القسم 23.3، أُضيفت إليها رسالة تالفة. الزمن هنا ممرَّر يدويا حتى يكون المُخرَج واضحا.

الكتابة والاستعلام والحذف والنسخ الاحتياطي، على ملف حقيقي.
from readings_db import open_db, save_message, latest
from readings_db import averages, prune, backup

MESSAGES = [
    ("home/esp32-1/env", b'{"t": 24.5, "h": 60.0}'),
    ("home/esp32-2/env", b'{"t": 21.5, "h": 48.0}'),
    ("home/esp32-1/env", b'{"t": 25.5, "h": 60.0}'),
    ("home/esp32-2/env", b'{"t": 22.0, "h": 48.0}'),
    ("home/esp32-1/env", b'{"t": 25.5, "h": 60.0}'),
    ("home/esp32-2/env", b"sensor error"),
]

db = open_db("readings.db")
for i, (topic, payload) in enumerate(MESSAGES):
    ok = save_message(db, topic, payload, now=15 * i)
    print("saved   " if ok else "rejected", topic)

print("-- newest 2")
for unit, when, temp, hum in latest(db, 2):
    print("%s at %d s: %.1f C, %.1f %%" % (unit, when, temp, hum))

print("-- per unit")
for unit, count, mean, top in averages(db):
    print("%s: %d rows, mean %.1f, max %.1f" % (unit, count, mean, top))

print("-- deleted:", prune(db, 30))
backup(db, "backup.db")
copy = open_db("backup.db")
print("-- rows in backup:", len(latest(copy, 100)))
Raspberry Pi 4/5 · Python 3 · simulated bus
saved    home/esp32-1/env
saved    home/esp32-2/env
saved    home/esp32-1/env
saved    home/esp32-2/env
saved    home/esp32-1/env
rejected home/esp32-2/env
-- newest 2
esp32-1 at 60 s: 25.5 C, 60.0 %
esp32-2 at 45 s: 22.0 C, 48.0 %
-- per unit
esp32-1: 3 rows, mean 25.2, max 25.5
esp32-2: 2 rows, mean 21.8, max 22.0
-- deleted: 2
-- rows in backup: 3
تحذير: لا نركّب أمر SQL من نص وصل عبر الشبكة، بل نستعمل علامات ? كما في المكتبة. لو نشر أحدهم موضوعا يحتوي على أمر SQL لنفّذته القاعدة.
مثال 23.1
3 وحدات تنشر كل 30 s. كم سطرا يضاف في اليوم؟ وكم سطرا يبقى إذا احتفظنا بآخر 30 يوما؟
الحل:
N = 3 · 8640030
N = 8640
N30 = 8640 · 30
N30 = 259200
259200
سؤال 23.5: أي دالة في المكتبة نستعمل لنعرف متوسط حرارة كل وحدة في الساعة الأخيرة؟ وما المعامل الذي نمرّره لها؟

23.6 صفحة الويب: Flask – דף הקריאות האחרונות (Flask)

في الفصل 14 كتبنا خادم HTTP صغيرا على اللوحة. على الباي نستعمل المكتبة Flask، وهي تتولى الطلبات والقوالب: نربط عنوانا بدالة، والدالة تعيد الصفحة.

web_page.py: لم يُنفَّذ في بناء الكتاب لأن Flask غير مثبّتة هناك، ومرّ بالترجمة فقط.
from flask import Flask, render_template_string
from readings_db import open_db, latest
import time

app = Flask(__name__)

PAGE = """<!doctype html>
<meta charset="utf-8">
<meta http-equiv="refresh" content="30">
<title>Readings</title>
<table border="1">
<tr><th>Unit</th><th>Time</th><th>Temp</th><th>Humidity</th></tr>
{% for r in rows %}
<tr><td>{{ r[0] }}</td><td>{{ r[1] }}</td>
<td>{{ r[2] }}</td><td>{{ r[3] }}</td></tr>
{% endfor %}
</table>"""


def pretty(row):
    unit, received, temp, hum = row
    stamp = time.strftime("%H:%M:%S", time.localtime(received))
    return unit, stamp, "%.1f" % temp, "%.1f" % hum


@app.route("/")
def home():
    db = open_db()
    rows = [pretty(r) for r in latest(db, 20)]
    db.close()
    return render_template_string(PAGE, rows=rows)


app.run(host="0.0.0.0", port=5000)
تحذير: خادم app.run مخصّص للتطوير، وهذه الصفحة بلا كلمة مرور. هي مناسبة لشبكة البيت أو المختبر، ولا نعرضها على الإنترنت.
سؤال 23.6: يعمل mqtt_store.py وweb_page.py في برنامجين منفصلين. كيف تصل القراءة من الأول إلى الثاني؟

23.7 من تجربة إلى نظام – ניהול שירות, כתובת קבועה וגיבוי (Services and Backup)

الخدمات. نحوّل البرنامجين إلى خدمتين كما في القسم 22.8. هذا ملف المشترك، وملف صفحة الويب مثله مع تغيير الوصف واسم البرنامج:

/etc/systemd/system/mqtt-store.service
[Unit]
Description=Store MQTT readings
After=network-online.target mosquitto.service
Wants=network-online.target

[Service]
User=student
WorkingDirectory=/home/student/project
ExecStart=/home/student/project/env/bin/python -u mqtt_store.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

السطر After يبدأ الخدمة بعد الشبكة والوسيط، وRestart=always يعيدها إذا انهارت. نفعّل الخدمتين معا: sudo systemctl enable --now mqtt-store web-page.

عنوان ثابت. الوحدات تحتوي عنوان الباي في برنامجها، فلا يصح أن يتغيّر. الطريقة الأبسط هي حجز العنوان في الموجّه (ראוטר) لبطاقة شبكة الباي، فنبقي الإعداد في مكان واحد. والبديل ضبطه على الباي نفسه بالأداة nmtui في الإصدارات التي تدير الشبكة بـNetworkManager، مثل Bookworm. ولا نعتمد في الوحدة على الاسم mypi.local، لأن حلّ هذا الاسم غير مضمون على المتحكّم.

النسخة الاحتياطية (גיבוי). بطاقة microSD قد تتعطل، فنحتفظ بنسخة خارج الباي. برنامج صغير يحذف القديم ويأخذ نسخة كل ليلة، وهو لا ينسخ الملف بأمر cp لأن النسخ أثناء الكتابة قد يعطي ملفا تالفا، بينما backup يتولى ذلك بأمان، كما رأينا في المُخرَج أعلاه:

backup_db.py: مبني على المكتبة التي جرّبناها في القسم 23.5. لا يُنفَّذ هنا لأنه يعتمد على تاريخ اليوم.
from readings_db import open_db, prune, backup
import time

DAY = 86400

db = open_db()
print("deleted", prune(db, time.time() - 30 * DAY))
backup(db, time.strftime("backup-%Y-%m-%d.db"))
db.close()
الأمر الأول يفتح الجدول، والسطر الثاني يُكتب فيه ليعمل الساعة 3 ليلا. والأخير يُنفَّذ على حاسوبنا لسحب النسخ.
crontab -e
0 3 * * * cd ~/project && env/bin/python backup_db.py
scp "student@mypi.local:project/backup-*.db" .

ونحفظ مع القاعدة ملفي Mosquitto وبرامج المشروع، فهي كل ما نحتاج إليه لبناء الباي من جديد.

ما نراهالسبب المحتمل
الوحدة: رفض الاتصال، وmosquitto_sub المحلي يعمللا يوجد listener 1883 في الإعداد
الوحدة: MQTTException: 5اسم المستخدم أو كلمة المرور غير صحيحة
الرسائل تصل إلى mosquitto_sub ولا تُحفظالخدمة mqtt-store متوقفة، ونرى السبب في journalctl
الصفحة قديمة أو فارغةالبرنامجان يعملان في مجلدين مختلفين، فيفتحان ملفين مختلفين

أهمّ أفكار الفصل:

الأمرعمله
mosquitto_passwd [-c] FILE USERيضيف مستخدما. -c ينشئ الملف من جديد.
mosquitto_sub / mosquitto_pubاشتراك ونشر من سطر الأوامر، للتجربة.
client.username_pw_set(user, password)اسم وكلمة مرور في paho-mqtt.
sqlite3.connect(path)يفتح القاعدة، وينشئ الملف إذا لم يكن موجودا.
db.execute(sql, values)ينفّذ أمرا، والقيم مكان علامات ?.
db.commit()يثبّت الكتابة في الملف.
db.backup(copy)نسخة آمنة والقاعدة قيد الاستعمال.
@app.route("/")يربط عنوانا بدالة في Flask.
render_template_string(PAGE, rows=rows)يملأ القالب بالقيم.

قاموس المصطلحات – מילון מונחים:

بالعربيةبالعبريةEnglish
الخادمשרתServer
قاعدة البياناتמסד נתוניםDatabase
الجدولטבלהTable
القالبתבניתTemplate
الموجّهראוטרRouter
النسخة الاحتياطيةגיבויBackup
الإجابات النهائية لأسئلة الفصل
23.1 مساحة الوحدة صغيرة، والوحدة التي تتعطل تأخذ قراءاتها معها. وفي الباي تجتمع قراءات كل الوحدات في مكان واحد نستعلم عنه.
23.2 يرفض الوسيط الاتصال، والسطر المسؤول هو allow_anonymous false.
23.3 home/esp32-3/env، ولا نغيّر شيئا: الرمز + في اشتراك الباي يشملها.
23.4 حتى يتجدد الاشتراك بعد كل إعادة اتصال، مثلا إذا أعيد تشغيل الوسيط.
23.5 averages، مع since=time.time() - 3600.
23.6 عبر الملف readings.db: الأول يكتب فيه، والثاني يقرأ منه، ولا يتصل أحدهما بالآخر.

أسئلة للتمرين:

المستوى الأول — أساسي
1.
ما الفرق بين الأمرين mosquitto_passwd -c وmosquitto_passwd؟ ومتى نستعمل كل واحد؟
الإجابات: الأول ينشئ ملفا جديدا ويمحو القديم، للمستخدم الأول فقط. والثاني يضيف مستخدما إلى ملف موجود.
2.
5 وحدات تنشر كل 60 s. كم سطرا يضاف إلى القاعدة في اليوم؟
الإجابات: 7200 سطرا.
المستوى الثاني — تطبيق
3.
أضيفوا إلى web_page.py العنوان /api/latest الذي يعيد آخر القراءات بصيغة JSON.
الإجابات:
from flask import jsonify


@app.route("/api/latest")
def api_latest():
    db = open_db()
    rows = latest(db, 20)
    db.close()
    return jsonify(rows)
4.
وصلت الرسالة {"t": 21.5} بلا مفتاح h. ماذا تفعل save_message؟ وأي خطأ تلتقطه؟
الإجابات: تعيد False ولا تكتب شيئا، بعد التقاط KeyError.
المستوى الثالث — تفكير
5.
انقطعت الكهرباء عن الباي عشر دقائق بينما بقيت الوحدات تعمل. ما الذي يضيع من القراءات؟ اقترحوا طريقة تقلّل الخسارة دون تغيير الباي.
الإجابات: تضيع قراءات تلك الدقائق، لأن الوحدة لا تخزّن شيئا. يمكن أن تكتبها الوحدة في ملف وقت الانقطاع، كما في الفصل 12، ثم تنشرها مع زمنها عند عودة الوسيط. وعندها نعدّل الدالة التي تحفظ الرسالة لتأخذ الزمن من الرسالة لا من ساعة الباي.
6.
لماذا فتحنا القاعدة مرة واحدة في mqtt_store.py، بينما نفتحها عند كل طلب في web_page.py؟
الإجابات: المشترك يعمل كله في خيط واحد داخل loop_forever، أما Flask فقد تخدم الطلبات في خيوط مختلفة، والاتصال مرتبط بالخيط الذي أنشأه.