תכנות מיקרו-בקרים בשפת MicroPython · פרק 23 — Raspberry Pi כשרת ליחידות ESP32⌂📚🏠
פרק 23

Raspberry Pi כשרת ליחידות ESP32

Raspberry Pi as a Server
3 שעות לימוד

עד עכשיו כל לוח עבד לבדו, ואם החשמל נפל, הקריאות שלו אבדו. בפרק הזה נבנה מערכת בת שתי קומות: יחידות ESP32 מודדות ומפרסמות, ו-Raspberry Pi אחד מקבל, שומר ומציג. נכתוב את שני הצדדים, ונלווה את אותה קריאה מן החיישן ועד דף בדפדפן.

בפרק הזה:
  • נתקין ב-Pi את המתווך Mosquitto, עם משתמשים וסיסמאות.
  • נפרסם קריאות מ-ESP32, ונקבל אותן ב-Pi בעזרת הספרייה paho-mqtt.
  • נשמור את הקריאות במסד נתונים SQLite, ונשאל אותו שאלות.
  • נציג את הקריאות האחרונות בדף אינטרנט בעזרת Flask.
  • נריץ את כל אלה כשירותים, נקבע ל-Pi כתובת קבועה ונקח גיבוי.

23.1 תמונת המערכת (System Overview)

בפרק 22 ראינו שהבקר מדויק יותר בתזמון, ושה-Pi חזק יותר באחסון וברשת. לכן מחלקים את התפקידים על הבסיס הזה:

Raspberry PiESP32esp32-1ESP32esp32-2המתווךMosquittoהמנויmqtt_store.pyמסד נתוניםreadings.dbדף אינטרנטweb_page.pyהדפדפןMQTTHTTP
הדרך המלאה של קריאה. כל ריבוע בתוך ה-Pi הוא תוכנית נפרדת, ושירות נפרד.
החלקהיכן הוא רץמה הוא עושההסעיף
היחידהESP32קוראת את החיישן ומפרסמת כל 30 s23.3
Mosquittoה-Piמקבל את ההודעות ומעביר אותן למנויים23.2
mqtt_store.pyה-Piנרשם להודעות וכותב אותן במסד הנתונים23.4
readings.dbה-Piקובץ מסד הנתונים23.5
web_page.pyה-Piמציג את הקריאות האחרונות לדפדפן23.6
שאלה 23.1: למה כל יחידה לא כותבת את הקריאות שלה בזיכרון שלה, כפי שעשינו בפרק 12? ציינו שתי סיבות.

23.2 המתווך Mosquitto

בפרק 16 ניסינו מתווך ציבורי באינטרנט. עכשיו המתווך הוא שלנו, ברשת המקומית, ואף אחד אחר אינו רואה את ההודעות שלנו. את פקודות המערכת ואת התוכניות שאינן מדומות בפרק הזה לא הרצנו בבניית הספר, כי הן דורשות Pi אמיתי. נציין זאת ליד כל תוכנית.

ההתקנה ושני משתמשים, אחד לכל צד. כל פקודת 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 מקבל חיבורים מה-Pi עצמו בלבד, ולכן היחידה נדחית בזמן שהכול עובד מקומית. האפשרות -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 היחידה מפרסמת (The Publisher)

החיווט כמו בסעיף 13.1, החיבור לרשת נעשה בעזרת הקובץ wifi.py מפרק 14, וסיסמת הרשת נמצאת ב-secrets.py. המטען הוא טקסט JSON קצר, והנושא נושא את שם היחידה.

תוכנית היחידה. כתובת המתווך היא כתובת ה-Pi ברשת שלנו. מתחת לפלט: מה שהגיע בפועל למתווך.
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. באיזה נושא היא מפרסמת? והאם צריך לשנות משהו ב-Pi?

23.4 הפאי מקבל (The Subscriber)

ב-Pi משתמשים בספריית הפייתון 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)

מסד נתונים: קובץ מסודר בטבלאות, שכותבים בו ושואלים אותו בשפת 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 ואינה עוצרת את השרת. הזמן נלקח משעון ה-Pi ברגע ההגעה, ולכן היחידה אינה צריכה שעון מכוון. עכשיו נבדוק את הספרייה בפועל: ההודעות הבאות הן מה ששתי יחידות עם אותה תוכנית מסעיף 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)

בפרק 14 כתבנו שרת HTTP קטן על הלוח. ב-Pi משתמשים בספרייה 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.

כתובת קבועה. היחידות מחזיקות בתוכנית שלהן את כתובת ה-Pi, ולכן היא אינה יכולה להשתנות. הדרך הפשוטה ביותר היא לשריין את הכתובת בנתב לכרטיס הרשת של ה-Pi, וכך ההגדרה נשארת במקום אחד. החלופה היא לקבוע אותה ב-Pi עצמו בעזרת הכלי nmtui, בגרסאות שמנהלות את הרשת בעזרת NetworkManager, כמו Bookworm. ביחידה לא מסתמכים על השם mypi.local, כי פענוח השם הזה אינו מובטח בבקר.

גיבוי. כרטיס microSD עלול להתקלקל, ולכן שומרים עותק מחוץ ל-Pi. תוכנית קטנה מוחקת את הישן ולוקחת עותק בכל לילה. היא אינה מעתיקה את הקובץ בפקודה 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 ואת תוכניות הפרויקט, כי זה כל מה שצריך כדי לבנות את ה-Pi מחדש.

מה שרואיםהסיבה האפשרית
היחידה: החיבור נדחה, ו-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 הזיכרון של היחידה קטן, ויחידה שמתקלקלת לוקחת איתה את הקריאות שלה. ב-Pi מתכנסות הקריאות של כל היחידות במקום אחד, ושואלים אותו שאלות.
23.2 המתווך דוחה את החיבור, והשורה האחראית היא allow_anonymous false.
23.3 home/esp32-3/env, ואין לשנות דבר: התו + בהרשמה של ה-Pi כולל אותה.
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.
החשמל נותק מה-Pi לעשר דקות בזמן שהיחידות המשיכו לעבוד. מה הולך לאיבוד מהקריאות? הציעו דרך שמצמצמת את ההפסד בלי לשנות דבר ב-Pi.
תשובות: הקריאות של אותן דקות הולכות לאיבוד, כי היחידה אינה שומרת דבר. היחידה יכולה לכתוב אותן לקובץ בזמן הניתוק, כמו בפרק 12, ואז לפרסם אותן עם הזמן שלהן כשהמתווך חוזר. במקרה כזה משנים את הפונקציה ששומרת את ההודעה, כך שתיקח את הזמן מן ההודעה ולא מן השעון של ה-Pi.
6.
למה פתחנו את המסד פעם אחת ב-mqtt_store.py, ואילו ב-web_page.py אנחנו פותחים אותו בכל בקשה?
תשובות: המנוי רץ כולו בתהליכון אחד בתוך loop_forever, ואילו Flask עשויה לשרת בקשות בתהליכונים שונים, והחיבור קשור לתהליכון שיצר אותו.