רשת WiFi ושרת HTTP
עד עכשיו הלוח דיבר איתנו דרך כבל. בפרק הזה הוא נכנס לאותה רשת אלחוטית שהטלפון שלנו מחובר אליה, ואנחנו פותחים בדפדפן דף שנמצא בתוך הלוח: קוראים ממנו חיישן ושולטים בנורית בלי שום כבל. נבנה את כל זה מכלים קטנים שנבין כל שורה בהם, ונכיר גם את גבולותיהם, כי הדף הזה פתוח לכל מי שנמצא ברשת.
- נסביר מהן כתובת IP ופורט, ומה ההבדל בין לקוח לשרת.
- נחבר את הלוח לרשת עם פסק זמן, נטפל בכשל בחיבור, ונשאיר את הסיסמה מחוץ לתוכנית.
- נפעיל את הלוח כתחנה ברשת קיימת וכנקודת גישה שיוצרת רשת משלה.
- נכתוב שרת HTTP בעזרת socket: נקרא את הבקשה והנתיב, ונבנה את התגובה.
- נשלוט בנורית מתוך דף, נציג קריאה, ונקבל ערך מטופס ונוודא אותו.
- נקבע מה מותר לחבר לשרת שאין בו הצפנה ואין בו אימות.
14.1 כתובת IP, פורט ומודל לקוח-שרת (IP, Port, Client-Server)
לפני שורת הקוד הראשונה אנחנו צריכים ארבע מילים. לכל מכשיר ברשת יש כתובת, ובכל מכשיר רצות כמה תוכניות, ולכן כדי להגיע לתוכנית מסוימת צריך גם כתובת וגם מספר. בכל שיחה יש צד שמבקש וצד שעונה.
בפרק הזה הלוח הוא השרת. בפרק הבא נהפוך את התפקידים והלוח יהיה הלקוח.
14.2 הצטרפות לרשת (Joining a Network)
בחיבור האלחוטי ב-MicroPython משתמשים במחלקה network.WLAN, ולה שני מצבים: במצב תחנה הלוח מצטרף לרשת קיימת כמו הטלפון, ובמצב נקודת גישה הוא יוצר רשת משלו.
נתחיל בשאלה: מה הלוח רואה סביבו? כל תוצאה בסריקה כוללת את שם הרשת, את הערוץ ואת עוצמת האות. עוצמת האות rssi היא מספר שלילי, וככל שהוא קרוב יותר לאפס האות חזק יותר.
import network
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
for ssid, bssid, channel, rssi, sec, hidden in wlan.scan():
print(ssid.decode(), channel, rssi)Home 6 -50 Lab 11 -71 Cafe 1 -85
הפקודה connect אינה ממתינה: היא מתחילה את החיבור וחוזרת מיד. לכן אנחנו שואלים isconnected() בלולאה, וקובעים לה פסק זמן כדי שהתוכנית לא תיתקע לנצח אם הסיסמה שונתה או שהנתב כבה.
import network
import time
from secrets import WIFI_SSID, WIFI_PASS
WAIT_S = 4
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.disconnect()
wlan.connect(WIFI_SSID, WIFI_PASS)
waited = 0
while not wlan.isconnected() and waited < WAIT_S:
print("waiting", waited)
time.sleep(1)
waited += 1
if wlan.isconnected():
print("IP:", wlan.ifconfig()[0])
else:
print("failed, status", wlan.status())
wlan.active(False)waiting 0 IP: 192.168.1.50
לפני connect קוראים ל-disconnect(): אם ניסיון קודם עדיין רץ מהפעלה קודמת, ESP32 נותן את השגיאה Wifi Internal Error. השגיאה הזאת הופיעה בפועל כשניסינו את התוכנית על לוח אמיתי.
וזו אותה תוכנית כשהרשת רחוקה מהישג יד:
waiting 0 waiting 1 waiting 2 waiting 3 failed, status 201
המספר ש-status() מחזירה מתפרש לפי קבוע מהמודול network, למשל STAT_WRONG_PASSWORD או STAT_NO_AP_FOUND. הערכים המספריים עשויים להיות שונים בין הלוחות, ולכן משווים תמיד לקבוע ולא למספר.
while not wlan.isconnected(): pass בלי פסק זמן היא הסיבה המפורסמת ביותר ללוח "מת": אין הודעה ואין שגיאה, והתוכנית כולה עומדת על אותה שורה.wlan.active(False) אחרי הכישלון, ולא משאירים אותו פעיל?14.3 קובץ הסודות וספריית wifi.py
שימו לב שהסיסמה לא הופיעה באף תוכנית. היא נמצאת בקובץ אחד שאנחנו מעלים ללוח ולא מעלים לשום מקום אחר:
# secrets.py - your network. Keep it off GitHub and shared folders. WIFI_SSID = "my-network" WIFI_PASS = "my-password" AP_PASS = "pick-a-new-one"
מכיוון שכל תוכנית בהמשך הספר מתחילה בהצטרפות לרשת, נעביר את הלולאה לספרייה. הספרייה לא מדפיסה דבר: אם החיבור נכשל היא מעלה שגיאה, והתוכנית שקוראת לה מחליטה מה לעשות.
# wifi.py - join the network named in secrets.py, or say why not.
# (c) 2026 Fahed Ghanem - MIT License - fahed-lab.com
import network
import time
from secrets import WIFI_SSID, WIFI_PASS
def connect(timeout_s=15):
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if wlan.isconnected():
return wlan
wlan.disconnect()
wlan.connect(WIFI_SSID, WIFI_PASS)
start = time.ticks_ms()
while not wlan.isconnected():
if time.ticks_diff(time.ticks_ms(), start) > timeout_s * 1000:
status = wlan.status()
wlan.active(False)
raise OSError("WiFi failed, status %d" % status)
time.sleep_ms(200)
return wlanimport wifi
try:
wlan = wifi.connect(timeout_s=5)
print("IP:", wlan.ifconfig()[0])
except OSError as e:
print("no network:", e)IP: 192.168.1.50
וכשהחיבור נכשל, השגיאה מגיעה אל except:
no network: WiFi failed, status 201
if wlan.isconnected(): return wlan לפני connect. מה התועלת שלה כשמפעילים את התוכנית מחדש בלי לנתק את המתח?14.4 הלוח כנקודת גישה (Access Point Mode)
בשטח או בסדנה לא תמיד יש נתב. אז הלוח יוצר רשת משלו: אנחנו בוחרים אותה מרשימת הרשתות בטלפון, ופותחים את הכתובת שהוא מקצה לעצמו.
import network
from secrets import AP_PASS
ap = network.WLAN(network.AP_IF)
ap.active(True)
ap.config(ssid="lab-board", key=AP_PASS)
ap.active(False)
ap.active(True)
print("network:", ap.config("ssid"))
print("IP:", ap.ifconfig()[0])network: lab-board IP: 192.168.4.1
קודם מפעילים את הממשק, אחר כך מגדירים אותו, ואז מפעילים אותו מחדש, כי כך זה עובד גם בגרסה ישנה: ב-ESP32 עם קושחה ישנה, הגדרה לפני ההפעלה נותנת שגיאה. לא ניסינו את הסדר הזה ב-Pico W אמיתי.
security והקבועים שלו תלויה בלוח, ולכן נפנה לתיעוד של הלוח שבידינו. כדאי שסיסמת נקודת הגישה לא תהיה סיסמת הבית.כל מה שיבוא בהמשך הפרק, מהשרת ועד הדפים, פועל בשני המצבים. השרת לא מתעניין איך הלקוח הגיע, אלא רק בכך שהגיע.
14.5 שרת HTTP ב-socket (An HTTP Server on a Socket)
השורה הראשונה בבקשה אומרת הכול: השיטה GET, אחריה הנתיב /on, ואחריו הגרסה. גם התגובה היא טקסט בסדר קבוע: שורת המצב, אחריה הכותרות, אחריהן שורה ריקה, ואז הדף עצמו.
import socket
import wifi
wlan = wifi.connect()
print("open http://%s/" % wlan.ifconfig()[0])
addr = socket.getaddrinfo("0.0.0.0", 80)[0][-1]
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(addr)
server.listen(2)
while True:
conn, peer = server.accept()
request = conn.recv(1024)
print(peer[0], request.split(b"\r\n")[0].decode())
conn.sendall(b"HTTP/1.0 200 OK\r\n"
b"Content-Type: text/html\r\n\r\n"
b"<h1>Hello from the board</h1>")
conn.close()open http://192.168.1.50/ 192.168.1.20 GET / HTTP/1.1 [http_sent] HTTP/1.0 200 OK Content-Type: text/html <h1>Hello from the board</h1> ...
| פקודה | מה היא עושה |
|---|---|
| getaddrinfo("0.0.0.0", 80) | כתובת שמשמעותה: האזן בכל הממשקים של הלוח, בפורט 80. |
| setsockopt(... SO_REUSEADDR, 1) | מאפשר להפעיל את התוכנית מחדש מיד על אותו פורט. |
| server.listen(2) | מתחיל להאזין, ומשאיר שני לקוחות בהמתנה. |
| server.accept() | ממתין ללקוח, ומחזיר שקע ייחודי לו יחד עם הכתובת שלו. |
| conn.recv(1024) | קורא עד 1024 בתים מן הבקשה. |
| conn.sendall(...) | שולח את התגובה כולה. |
| conn.close() | סוגר את החיבור, ואז הדפדפן יודע שהדף נגמר. |
\r\n\r\n היא מה שמפריד ביניהם. בלעדיה כל הדף נקרא כאילו הוא כותרות, ולא מופיע דבר בדפדפן.conn.close() בלולאה. מה המשתמש רואה? ומה קורה ללוח אחרי כמה בקשות?14.6 דף ששולט בנורית (A Page that Drives the LED)
נתחיל במה שחוזר על עצמו: פתיחת השקע, פירוק השורה הראשונה ובניית התגובה. נאגד את כל זה בספרייה קטנה, כדי שבתוכנית יישארו רק השורות שאומרות מה המכשיר עושה.
# miniweb.py - a tiny web server on plain sockets, one client at a time.
# (c) 2026 Fahed Ghanem - MIT License - fahed-lab.com
import socket
def listen(port=80):
addr = socket.getaddrinfo("0.0.0.0", port)[0][-1]
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(addr)
server.listen(2)
return server
def parse(raw):
parts = raw.split(b"\r\n")[0].decode().split(" ")
if len(parts) < 2:
return "", {}
target = parts[1].split("?", 1)
args = {}
if len(target) == 2:
for pair in target[1].split("&"):
kv = pair.split("=", 1)
if len(kv) == 2:
args[kv[0]] = kv[1]
return target[0], args
def reply(conn, body, status="200 OK", kind="text/html"):
head = "HTTP/1.0 %s\r\nContent-Type: %s\r\n\r\n" % (status, kind)
conn.sendall(head.encode() + body.encode())parse מחזירה עבור הבקשה GET /set?level=60&mode=slow HTTP/1.1?חותכים את השורה ברווחים, והחלק השני הוא היעד. אחר כך חותכים אותו ב-?: לפניו הנתיב, ואחריו הפרמטרים, מופרדים ב-&. כל הערכים הם מחרוזות.
('/set', {'mode': 'slow', 'level': '60'})from machine import Pin
import board
import miniweb
import wifi
PAGE = """<h1>LED: %s</h1>
<a href="/on">ON</a> <a href="/off">OFF</a>"""
led = Pin(board.LED, Pin.OUT)
wifi.connect()
server = miniweb.listen()
while True:
conn, peer = server.accept()
conn.settimeout(3)
try:
path, args = miniweb.parse(conn.recv(1024))
print(peer[0], path)
if path == "/on":
led.value(1)
elif path == "/off":
led.value(0)
if path in ("/", "/on", "/off"):
state = "ON" if led.value() else "OFF"
miniweb.reply(conn, PAGE % state)
else:
miniweb.reply(conn, "Not found", "404 Not Found")
except OSError as e:
print("client error:", e)
finally:
conn.close()192.168.1.20 / 192.168.1.20 /on 192.168.1.20 /favicon.ico 192.168.1.20 /off [pins] 2 1 2 0 ...
192.168.1.20 / 192.168.1.20 /on 192.168.1.20 /favicon.ico 192.168.1.20 /off [pins] 100 1 100 0 ...
הביטו בבקשה השלישית: לא לחצנו על שום דבר שמוביל ל-/favicon.ico. הדפדפנים מבקשים את הקובץ הזה מעצמם כדי להציג את אייקון האתר, ולכן כל שרת צריך להשיב 404 על מה שהוא לא מכיר. וגם גישה לנתיב לא מוכר לא משנה את מצב הנורית.
ולמה conn.settimeout(3)? כי השרת הזה משרת לקוח אחד בכל פעם. לקוח שפתח חיבור ולא שלח דבר עוצר את כל השרת ב-recv. פסק הזמן מעלה OSError, ו-finally סוגר את השקע בכל מקרה, והשרת חוזר ל-accept.
accept ממתין, לא רצים לולאת מדידה או טיימר בתוכנית הראשית. את זה נפתור בפרק התכנות האסינכרוני./on? ולמה זה לא מזיק כאן, ומתי כן?14.7 הצגת קריאה וטופס (Showing a Reading, Receiving a Value)
נעשה צעד אחרון: דף שמציג את המתח של הפוטנציומטר, ובו טופס ששולט בעוצמת התאורה של נורית חיצונית. הטופס ב-HTML לא צריך שום קוד מיוחד: כשלוחצים על הלחצן הדפדפן מבקש /set?level=60, כלומר נתיב ופרמטר, וזה בדיוק מה שאנחנו יודעים לקרוא.
import board
import miniweb
import wifi
from convert import to_volts
PAGE = """<h1>%.2f V</h1><p>LED: %d%%</p>
<form action="/set">
<input name="level" type="number" min="0" max="100">
<button>Set</button></form>"""
pot = board.analog_in(board.POT)
led = board.pwm(board.LED_EXT, 1000)
led.duty_u16(0)
level = 0
wifi.connect()
server = miniweb.listen()
def handle(path, args):
global level
if path == "/set":
value = int(args["level"])
if not 0 <= value <= 100:
raise ValueError("range")
level = value
led.duty_u16(level * 65535 // 100)
elif path != "/":
return "Not found", "404 Not Found"
return PAGE % (to_volts(pot.read_u16()), level), "200 OK"
while True:
conn, peer = server.accept()
conn.settimeout(3)
try:
path, args = miniweb.parse(conn.recv(1024))
try:
body, status = handle(path, args)
except (KeyError, ValueError):
body, status = "Bad value", "400 Bad Request"
print(path, args, status, "level", level)
miniweb.reply(conn, body, status)
except OSError as e:
print("client error:", e)
finally:
conn.close()/ {} 200 OK level 0
/set {'level': '60'} 200 OK level 60
/set {'level': 'abc'} 400 Bad Request level 60
/set {'level': '150'} 400 Bad Request level 60
/set {} 400 Bad Request level 60
[pwm]
13 0
13 39321
.../ {} 200 OK level 0
/set {'level': '60'} 200 OK level 60
/set {'level': 'abc'} 400 Bad Request level 60
/set {'level': '150'} 400 Bad Request level 60
/set {} 400 Bad Request level 60
[pwm]
15 0
15 39321
...level=60. איזה מחזור עבודה התוכנית מציבה ב-duty_u16?min ו-max בטופס אינן מגנות על דבר: מספיק שמישהו יכתוב את הכתובת ישירות בדפדפן כדי לעקוף את הטופס. כל ערך שמגיע מהרשת בודקים בלוח עצמו, ולעולם לא סומכים עליו. לכן התוכנית משיבה 400 ולא משנה את המצב.a%20b עבור a b. האם הספרייה miniweb מפענחת את הקידוד הזה? ומה עושים אם צריך?14.8 אבטחה: מה מותר לחבר (Security Limits)
השרת שכתבנו עובד, אבל עלינו לומר במפורש מה חסר בו:
- אין הצפנה: כל בקשה וכל תגובה הן טקסט גלוי. סיסמת הרשת מונעת מזרים להיכנס לרשת, אבל היא לא מסתירה את התעבורה ממי שכבר נכנס אליה.
- אין אימות: השרת לא שואל מי המבקש. כל מכשיר ברשת, כולל הטלפון של אורח, יכול לבקש
/on. - אין תוספת פשוטה שפותרת את זה: גם סיסמה שהדף שולח בבקשת HTTP עוברת כטקסט גלוי.
| מה מחברים לשרת הזה | ההחלטה | הסיבה |
|---|---|---|
| נורית, קריאת טמפרטורה או אור, תצוגה במעבדה | מתאים | במקרה הגרוע התוצאה מפריעה אך אינה מסוכנת. |
| מנוע קטן, זמזם, משאבה קטנה במתח נמוך | בזהירות | מגבילים את זמן ההפעלה בתוכנית, ולא משאירים אותם פועלים ללא השגחה. |
| מנעול דלת, תנור, כל עומס במתח הרשת | לא | כל אחד ברשת יכול להפעיל אותו, וחזרה על בקשה אחת עלולה לגרום לשריפה או לנזק. |
| פתיחת פורט בנתב אל האינטרנט | לא | השרת נחשף לכל העולם בלי שום הגנה. |
הכלל שלנו: השרת הזה מיועד לרשת מעבדה שאנחנו מכירים את כל מי שנמצא בה. לכל מפעיל שמחברים אליו בוחרים מצב שאינו מזיק כשהרשת נופלת, וזו החלטה של התוכנית ולא של הרשת.
14.9 הבדלים בין הלוחות ופריסה (Board Differences, Deployment)
נחזור למה שהבטחנו בפרק הקלט האנלוגי. ב-ESP32 בלוק הממירים השני משמש את ה-WiFi, ולכן קריאה מהדק שנמצא בו נכשלת כל עוד הכרטיס פעיל:
from machine import ADC, Pin import network wlan = network.WLAN(network.STA_IF) wlan.active(True) pot = ADC(Pin(25), atten=ADC.ATTN_11DB) print(pot.read_u16())
OSError: ADC2 is in use by WiFi
בגלל זה board.POT ב-ESP32 הוא הדק 34, מן התחום 32–39. ב-Pico W אין מגבלה כזאת, והדקים GP26–GP28 פועלים עם הרשת.
כל התוכניות בפרק פעלו בשני הלוחות בלי לשנות שורה, וזה מה שהראו המסכים למעלה. העבודה היחידה היא להעלות ללוח את הקבצים שהתוכנית מייבאת, ואז לקרוא לתוכנית main.py כדי שתרוץ בהפעלה:
mpremote cp secrets.py wifi.py miniweb.py board.py convert.py : mpremote cp http_panel.py :main.py mpremote reset
network.WLAN: מערכת לינוקס היא שמצטרפת לרשת, ודי בכך שהתוכנית תפתח את השקע. לפאי יש פרק משלו בחלק השישי.עיקרי הפרק:
- כדי להגיע לתוכנית ברשת צריך כתובת IP ופורט.
- ממתינים תמיד עם פסק זמן, וכישלון הוא מצב רגיל שהתוכנית מטפלת בו.
- סיסמאות ומפתחות נמצאים ב-secrets.py, ולעולם לא נכנסים לקוד משותף.
- פרוטוקול HTTP הוא טקסט: בקשה, אחריה שורת מצב וכותרות, שורה ריקה ודף.
- כל ערך מהרשת בודקים בלוח, וכל שקע סוגרים ב-
finally. - אין כאן הצפנה ואימות: השרת מיועד לרשת מעבדה ולעומסים שאינם מזיקים.
- ב-ESP32 עם WiFi קוראים רק מן ההדקים 32–39.
פקודות הפרק:
| פקודה | מה היא עושה |
|---|---|
| network.WLAN(network.STA_IF) | כרטיס במצב תחנה. AP_IF לנקודת גישה. |
| wlan.active(True) | הפעלת הכרטיס. |
| wlan.scan() | רשימת הרשתות סביב הלוח. |
| wlan.connect(ssid, key) | תחילת ההצטרפות, בלי המתנה. |
| wlan.isconnected() | האם ההצטרפות הושלמה? |
| wlan.status() | מצב החיבור, משווים לקבועים network.STAT_*. |
| wlan.ifconfig()[0] | כתובת הלוח. |
| ap.config(ssid=..., key=...) | שם נקודת הגישה והסיסמה שלה. |
| wifi.connect(timeout_s) | הספרייה שלנו: הצטרפות עם פסק זמן, או OSError. |
| miniweb.listen(port) | הספרייה שלנו: שקע שרת מוכן. |
| miniweb.parse(raw) | הספרייה שלנו: הנתיב והפרמטרים מתוך הבקשה. |
| miniweb.reply(conn, body, status) | הספרייה שלנו: שליחת תגובה. |
מילון מונחים:
| עברית | English | ערבית |
|---|---|---|
| כתובת IP | IP Address | عنوان IP |
| פורט | Port | المنفذ |
| נתב | Router | الموجّه |
| שרת | Server | الخادم |
| לקוח | Client | العميل |
| דפדפן | Browser | المتصفّح |
| תחנה | Station | المحطة |
| נקודת גישה | Access Point | نقطة الوصول |
| פסק זמן | Timeout | المهلة |
| שקע | Socket | المقبس |
| בקשה | Request | الطلب |
| תגובה | Response | الاستجابة |
| נתיב | Path | المسار |
| כותרת | Header | الترويسة |
| טופס | Form | النموذج |
| הצפנה | Encryption | التشفير |
| אימות | Authentication | المصادقة |
/on נשלחת שוב. זה לא מזיק כי התוצאה זהה בכל פעם. זה מזיק כשהבקשה משנה משהו בכל חזרה, למשל /toggle או הפעלת משאבה למשך זמן קבוע.%XX לבית, או מסתפקים בערכים מספריים כמו כאן.שאלות לתרגול:
parse מחזירה עבור הבקשה GET /on HTTP/1.1?/on ומילון פרמטרים ריק./toggle שהופך את מצבה. האם די בשורה הזאת לבדה?if path == "/toggle":
led.value(1 - led.value())level=25. מה הערך של duty_u16?/api שמחזיר את הקריאה בפורמט JSON במקום דף.import json
if path == "/api":
volts = to_volts(pot.read_u16())
text = json.dumps({"volts": volts, "level": level})
miniweb.reply(conn, text, "200 OK", "application/json")wifi.connect(15) בתוך try פעמיים בלולאת for, ויוצאים בהצלחה הראשונה. אחרי הפעם השנייה נותנים לשגיאה להגיע לתוכנית, או עוברים למצב נקודת גישה.settimeout. מה קורה לשאר המשתמשים? ולמה זה לא קורה בתוכנית שלנו?recv ואינו משרת אף אחד. בתוכנית שלנו פסק הזמן מסתיים אחרי 3 שניות, finally סוגר את השקע, והשרת חוזר ל-accept./on?pw=1234?