شبكة WiFi وخادم HTTP
حتى الآن كانت اللوحة تكلّمنا عبر سلك. في هذا الفصل تدخل الشبكة اللاسلكية نفسها التي يدخلها هاتفنا، ونفتح في المتصفّح صفحة تسكن داخل اللوحة: نقرأ منها حسّاسا ونضبط ثنائيا باعثا للضوء دون أي سلك. نبني ذلك بأدوات صغيرة نفهم كل سطر فيها، ونعرف أيضا حدودها، لأن هذه الصفحة مفتوحة لكل من في الشبكة.
- نشرح عنوان IP والمنفذ والفرق بين العميل والخادم.
- نربط اللوحة بالشبكة بمهلة انتظار، ونعالج فشل الاتصال، ونبقي كلمة المرور خارج البرنامج.
- نشغّل اللوحة محطة في شبكة قائمة، ونقطة وصول تنشئ شبكتها الخاصة.
- نكتب خادم HTTP بالمقبس: نقرأ الطلب والمسار ونبني الاستجابة.
- نتحكّم في ثنائي من صفحة، ونعرض قراءة، ونستقبل قيمة من نموذج ونتحقّق منها.
- نحدّد ما يجوز وصله بخادم لا تشفير فيه ولا مصادقة.
14.1 العنوان والمنفذ والعميل والخادم – כתובת IP, פורט ומודל לקוח-שרת (IP, Port, Client-Server)
قبل سطر الشيفرة الأول نحتاج إلى أربع كلمات. لكل جهاز في الشبكة عنوان، وفي كل جهاز عدة برامج، ولذلك يحتاج الوصول إلى برنامج معيّن إلى عنوان ورقم معا. وفي كل محادثة طرف يطلب وطرف يجيب.
في هذا الفصل اللوحة هي الخادم. وفي الفصل التالي نعكس الأدوار فتصبح عميلا.
14.2 الانضمام إلى الشبكة – הצטרפות לרשת (Joining a Network)
يعمل الاتصال اللاسلكي في مايكروبايثون عبر الصنف 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 – קובץ הסודות וספריית 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 بالمقبس – שרת 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 | IP Address |
| المنفذ | פורט | 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؟