Raspberry Pi כשרת ליחידות ESP32
עד עכשיו כל לוח עבד לבדו, ואם החשמל נפל, הקריאות שלו אבדו. בפרק הזה נבנה מערכת בת שתי קומות: יחידות ESP32 מודדות ומפרסמות, ו-Raspberry Pi אחד מקבל, שומר ומציג. נכתוב את שני הצדדים, ונלווה את אותה קריאה מן החיישן ועד דף בדפדפן.
- נתקין ב-Pi את המתווך Mosquitto, עם משתמשים וסיסמאות.
- נפרסם קריאות מ-ESP32, ונקבל אותן ב-Pi בעזרת הספרייה
paho-mqtt. - נשמור את הקריאות במסד נתונים SQLite, ונשאל אותו שאלות.
- נציג את הקריאות האחרונות בדף אינטרנט בעזרת Flask.
- נריץ את כל אלה כשירותים, נקבע ל-Pi כתובת קבועה ונקח גיבוי.
23.1 תמונת המערכת (System Overview)
בפרק 22 ראינו שהבקר מדויק יותר בתזמון, ושה-Pi חזק יותר באחסון וברשת. לכן מחלקים את התפקידים על הבסיס הזה:
| החלק | היכן הוא רץ | מה הוא עושה | הסעיף |
|---|---|---|---|
| היחידה | ESP32 | קוראת את החיישן ומפרסמת כל 30 s | 23.3 |
| Mosquitto | ה-Pi | מקבל את ההודעות ומעביר אותן למנויים | 23.2 |
mqtt_store.py | ה-Pi | נרשם להודעות וכותב אותן במסד הנתונים | 23.4 |
readings.db | ה-Pi | קובץ מסד הנתונים | 23.5 |
web_page.py | ה-Pi | מציג את הקריאות האחרונות לדפדפן | 23.6 |
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
listener 1883 allow_anonymous false password_file /etc/mosquitto/passwd
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}'mosquitto_pub בלי -u ובלי -P. מה צפוי לקרות? ואיזו שורה בהגדרות אחראית לכך?23.3 היחידה מפרסמת (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)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 חייב להיות שונה בכל יחידה: אם שתי יחידות מתחברות באותו מזהה, המתווך מנתק את הראשונה, והן מתחלפות בניתוק בלי סוף.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
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() | התוכנית נשארת כאן, והפונקציה מטפלת ברשת ובחיבור מחדש. |
client.subscribe בתוך on_connect ולא מיד אחרי connect?23.5 שמירה במסד נתונים (SQLite)
sqlite3 מובנית בפייתון.נרכז את כל הטיפול במסד בספרייה אחת, שהמנוי, דף האינטרנט והגיבוי משתמשים בה:
# 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 TrueLATEST = """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)))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, המסד היה מבצע אותה.23.6 דף הקריאות האחרונות (Flask)
בפרק 14 כתבנו שרת HTTP קטן על הלוח. ב-Pi משתמשים בספרייה 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.route("/")קושר את הדף הראשי לפונקציהhome.- כל בקשה פותחת חיבור משלה למסד וסוגרת אותו, כי Flask עשויה לשרת בקשות בתהליכונים שונים, וחיבור
sqlite3קשור לתהליכון שלו. - התג
{{ }}בתבנית מציב את הערך, וממיר בו תווים מיוחדים לטקסט בטוח. host="0.0.0.0"הופך את הדף לזמין מהרשת, בכתובת http://mypi.local:5000.
app.run מיועד לפיתוח, ובדף הזה אין סיסמה. הוא מתאים לרשת הבית או המעבדה, ואין להציג אותו באינטרנט.mqtt_store.py ו-web_page.py רצים בשתי תוכניות נפרדות. איך הקריאה מגיעה מהראשונה לשנייה?23.7 ניהול שירות, כתובת קבועה וגיבוי (Services and Backup)
שירותים. הופכים את שתי התוכניות לשני שירותים, כמו בסעיף 22.8. זה קובץ המנוי, וקובץ דף האינטרנט דומה לו, בשינוי התיאור ושם התוכנית:
[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 עושה זאת בבטחה, כפי שראינו בפלט שלמעלה:
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()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 |
| הדף ישן או ריק | שתי התוכניות רצות בשתי תיקיות שונות, ולכן פותחות שני קבצים שונים |
עיקרי הפרק:
- היחידות מודדות ומפרסמות, וה-Pi מקבל, שומר ומציג.
- Mosquitto מודרני צריך
listenerכדי לקבל חיבור מהרשת, ואנחנו מגנים עליו במשתמשים. - ההרשמה נעשית בתוך
on_connectכדי שתתחדש אחרי כל ניתוק. - לא סומכים על הודעה מהרשת: בודקים אותה, ומשתמשים בסימני
?ב-SQL. - שתי התוכניות חולקות את קובץ המסד, ואין ביניהן חיבור ישיר.
- כל תוכנית היא שירות, כתובת ה-Pi קבועה, והגיבוי מחוץ ל-Pi.
| פקודה | מה היא עושה |
|---|---|
| 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 | النسخة الاحتياطية |
allow_anonymous false.home/esp32-3/env, ואין לשנות דבר: התו + בהרשמה של ה-Pi כולל אותה.averages, עם since=time.time() - 3600.readings.db: הראשונה כותבת בו והשנייה קוראת ממנו, ואין חיבור ישיר ביניהן.שאלות לתרגול:
mosquitto_passwd -c ו-mosquitto_passwd? ומתי משתמשים בכל אחת?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){"t": 21.5} בלי המפתח h. מה עושה save_message? ואיזו שגיאה היא תופסת?False ואינה כותבת דבר, אחרי שתפסה KeyError.mqtt_store.py, ואילו ב-web_page.py אנחנו פותחים אותו בכל בקשה?loop_forever, ואילו Flask עשויה לשרת בקשות בתהליכונים שונים, והחיבור קשור לתהליכון שיצר אותו.