برمجة المتحكّمات بلغة مايكروبايثون · الفصل 15 — طلبات HTTP وواجهات APIבקשות HTTP וממשקי API · תכנות מיקרו-בקרים בשפת MicroPython⌂📚🏠
الفصل 15

طلبات HTTP وواجهات API

בקשות HTTP וממשקי API – HTTP Requests and APIs
ساعتان تعليميتان

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

في هذا الفصل:
  • نرسل طلبات get وpost ونقرأ رمز الحالة والنص وJSON.
  • نغلق كل استجابة، ونعرف ماذا يتسرّب إن لم نفعل.
  • نعالج انقطاع الشبكة والمهلة والخطأ من الخادم والجواب غير السليم.
  • نضبط الساعة عبر NTP ونحوّلها إلى الوقت المحلي.
  • نقدّر حجم الجواب قبل طلبه، ونعرف ما يضيفه HTTPS وما لا يضيفه.

15.1 الطلب الأول – הבקשה הראשונה (The First Request)

اللوحة الآن عميل. والمكتبة requests موجودة مسبقا في نسخ مايكروبايثون ذات الشبكة على اللوحتين، وهي تفتح المقبس وتكتب الطلب وتقرأ الاستجابة عنا.

اللوحة: العميلالموجّهالإنترنتالخدمة: الخادمJSON
عكسنا الأدوار: الطلب يخرج من اللوحة، والجواب يأتي من الخدمة.
واجهة API (ממשק תכנות): مجموعة عناوين تنشرها خدمة ليتحدث معها برنامج لا إنسان، وهي تجيب عادة بصيغة JSON لا بصفحة.
النطاق example.com محجوز للأمثلة، والجواب هنا من العالم المُحاكى.
import requests
import wifi

wifi.connect()
r = requests.get("http://api.example.com/v1/hello")
print(r.status_code)
print(r.text)
r.close()
ESP32 + Pico W · MicroPython 1.29 · simulated board
200
hello from the server

العدد الأول هو رمز الحالة (קוד סטטוס). هو أول ما نقرؤه، لأن الخادم يجيب حتى حين يرفض طلبنا:

الرمزمعناهماذا نفعل
200نجح الطلبنقرأ الجواب
201نجح وأُنشئ سجل جديدنكمل
400طلبنا غير سليمنصلح البرنامج
401ينقصنا إذن أو مفتاحنفحص المفتاح
404العنوان غير موجودنفحص العنوان
429طلبات كثيرة جدانبطئ
500خطأ عند الخادمنحاول لاحقا
خطأ شائع: كل استجابة تحجز مقبسا وذاكرة حتى نغلقها. والمقابس على المتحكّم قليلة. برنامج ينسى r.close() يعمل جيدا في أول الطلبات، ثم يتوقف بخطأ OSError بعد عدد منها. ولا يظهر هذا في تجربة قصيرة، وإنما بعد ساعات من التشغيل.
سؤال 15.1: طلبنا عنوانا فكان r.status_code هو 429. هل المشكلة في الشبكة؟ وما التغيير في البرنامج؟

15.2 خدمة الطقس: قراءة JSON – שירות מזג אוויר: קריאת JSON (A Weather Service: Reading JSON)

تعرّفنا إلى JSON في فصل الملفات. والأمر r.json() يحوّل الجواب إلى قاموس جاهز. نضع هنا timeout، ونغلق الاستجابة في finally، وهذا الشكل سنكرره دائما. عنوان الخدمة في المثال ليس خدمة فعلية، والجواب من العالم المُحاكى.

نقرأ الحقول ونحوّل وحدة الرياح.
import requests
import wifi

URL = "http://api.example.com/v1/weather?city=Haifa"

wifi.connect()
r = requests.get(URL, timeout=5)
try:
    if r.status_code == 200:
        data = r.json()
        print(data["city"])
        print("temp: %.1f C" % data["temp_c"])
        print("wind: %.1f km/h" % (data["wind_ms"] * 3.6))
    else:
        print("server said", r.status_code)
finally:
    r.close()
ESP32 + Pico W · MicroPython 1.29 · simulated board
Haifa
temp: 24.5 C
wind: 18.0 km/h
مثال 15.1
أعطت الخدمة سرعة الرياح 5 m/s. كم تساوي بوحدة km/h؟
الحل:
v = 5 · 3.6
v = 18 km/h
v = 18 km/h
سؤال 15.2: الخدمة قد لا تعيد حقل wind_ms في بعض الأوقات. ماذا يحدث للبرنامج؟ وكيف نكتب القراءة بأمان؟

15.3 إرسال البيانات والمفاتيح – שליחת נתונים ומפתחות (Sending Data, API Keys)

الأمر post يرسل بيانات في جسم الطلب. والمعامل json= يحوّل القاموس إلى نص ويضع الترويسة المناسبة.

نرسل قراءة، فيعود رقم السجل. وفي آخر الشاشة ما خرج من اللوحة.
import board
import requests
import wifi

URL = "http://api.example.com/v1/readings"

wifi.connect()
pot = board.analog_in(board.POT)
reading = {"sensor": "pot", "raw": pot.read_u16()}
r = requests.post(URL, json=reading, timeout=5)
try:
    print(r.status_code, r.text)
finally:
    r.close()
ESP32 + Pico W · MicroPython 1.29 · simulated board
201 {"id":17}
[http_sent]
POST http://api.example.com/v1/readings {'raw': 32776, 'sensor': 'pot'}

أغلب الخدمات الحقيقية تطلب مفتاحا (מפתח API) يعرّف صاحب الحساب. نضيفه إلى secrets.py سطرا جديدا، ونقرؤه كما نقرأ كلمة مرور الشبكة. المقطع التالي مرّ بالترجمة فقط ولم يُشغَّل في بناء الكتاب، لأن ملف الأسرار في الكتاب لا يحوي مفتاحا:

المفتاح في ترويسة، واسمها وصيغتها تحدّدهما كل خدمة في توثيقها
import requests
from secrets import API_KEY

headers = {"Authorization": "Bearer " + API_KEY}
r = requests.get(URL, headers=headers, timeout=5)
قاعدة ثابتة: المفتاح مال: من يملكه يستهلك حسابنا باسمنا. لا نضع مفتاحا ولا كلمة مرور في شيفرة مشتركة، ولا في صورة شاشة، ولا في رسالة لزميل. وإن تسرّب مفتاح، فلا يكفي حذفه من الملف: نلغيه من حساب الخدمة ونصدر غيره.
سؤال 15.3: زميل رفع برنامجه إلى مستودع عام، وفيه مفتاح، ثم حذف السطر في تعديل لاحق. هل انتهت المشكلة؟

15.4 حين لا يسير الطلب كما نتمنى – טיפול בשגיאות ובפסק זמן (Errors and Timeouts)

على المتحكّم الذي يعمل وحده لا أحد يعيد تشغيل البرنامج. لذلك نميّز أربع حالات:

الحالةكيف تظهرمثال
لا طريق إلى الخادمOSError عند الطلبالشبكة منقطعة، أو انتهت المهلة
الخادم أجاب برفضرمز حالة غير 200404، 500
الجواب ليس JSONValueError عند r.json()صفحة خطأ بدل البيانات
البيانات ناقصةKeyErrorحقل غير موجود

نجمع المعالجة في دالة واحدة تعيد البيانات أو None، فلا يحتاج باقي البرنامج إلى التفكير في ذلك:

أربعة طلبات وأربع نتائج. العنوان الأخير لا يعرفه العالم المُحاكى، فيفشل الاتصال.
import requests
import wifi

BASE = "http://api.example.com/v1/"


def get_json(url):
    try:
        r = requests.get(url, timeout=5)
    except OSError as e:
        print("network error:", e)
        return None
    try:
        if r.status_code != 200:
            print("status:", r.status_code)
            return None
        return r.json()
    except ValueError:
        print("bad JSON")
        return None
    finally:
        r.close()


wifi.connect()
for name in ("hello", "busy", "broken", "gone"):
    print(name, "->", get_json(BASE + name))
ESP32 + Pico W · MicroPython 1.29 · simulated board
hello -> {'ok': True}
status: 500
busy -> None
bad JSON
broken -> None
network error: -202
gone -> None
مثال 15.2
نحاول الطلب 3 مرات، بمهلة 5 s لكل محاولة، وننتظر 2 s بين محاولتين. كم يتعطّل البرنامج في أسوأ حالة؟
الحل:
T = 3 · 5 + 2 · 2
T = 19 s

في هذا الزمن لا يعمل شيء آخر في البرنامج. فإذا كان يقود مشغّلا، نضعه أولا في حالة آمنة، ثم نطلب.

T = 19 s
ملاحظة: دون timeout قد ينتظر الطلب طويلا جدا إذا صمت الخادم. لذلك نضعها في كل طلب.
سؤال 15.4: لماذا كتبنا try مرتين في get_json، بدل واحدة كبيرة؟

15.5 الساعة من الشبكة: NTP – שעון מהרשת: NTP (Network Time)

لا تعرف اللوحة كم الساعة حين تبدأ، إذ لا بطارية عادة لساعتها الداخلية. والبروتوكول NTP يسأل خادم وقت في الإنترنت ويضبطها. الأمر ntptime.settime() يضع في الساعة التوقيت العالمي UTC، وعلينا نحن إضافة فرق المنطقة: في إسرائيل ساعتان في الشتاء، وثلاث ساعات في الصيف.

الوقت المعروض من ساعة المُحاكاة، بفرق ثلاث ساعات.
import ntptime
import time
import wifi

OFFSET_S = 3 * 3600

wifi.connect()
try:
    ntptime.settime()
except OSError as e:
    print("no time:", e)

t = time.gmtime()
print("UTC:   %04d-%02d-%02d %02d:%02d" % t[:5])
t = time.gmtime(time.time() + OFFSET_S)
print("local: %04d-%02d-%02d %02d:%02d" % t[:5])
ESP32 + Pico W · MicroPython 1.29 · simulated board
UTC:   2027-01-01 00:00
local: 2027-01-01 03:00
مثال 15.3
كم ثانية نضيف إلى time.time() لنحصل على فرق ثلاث ساعات؟
الحل:
s = 3 · 3600
s = 10800 s
s = 10800 s
تحذير: الساعة تنحرف مع الوقت وتضيع عند انقطاع التغذية. نضبطها عند كل تشغيل، ونعيد الضبط كل بضع ساعات، لا في كل دورة من الحلقة: فهذا إزعاج للخادم وقد يوقفنا.
سؤال 15.5: اللوحة تسجل قراءات بالوقت. هل نخزّن الوقت المحلي أم UTC؟ ولماذا؟

15.6 الجواب الكبير والذاكرة – תשובה גדולה וזיכרון (Large Responses and Memory)

الجواب كله يدخل إلى الذاكرة: r.content يقرأ الجواب كاملا مرة واحدة، وr.json() يبني منه قاموسا يأخذ مكانا إضافيا. وعلى المتحكّم تنتهي الذاكرة بخطأ MemoryError. كمية الذاكرة الحرة نقيسها على لوحتنا بـgc.mem_free()، فهي تتغيّر بحسب البرنامج.

توقّعات أسبوع، ساعة بساعة. الحجم محسوب من النص الذي يعيده العالم المُحاكى.
import gc
import requests
import wifi

URL = "http://api.example.com/v1/forecast?days=7"

wifi.connect()
gc.collect()
r = requests.get(URL, timeout=5)
try:
    print("bytes:", len(r.content))
    hours = r.json()["hours"]
    print("items:", len(hours))
    print("first: %.1f C" % hours[0]["temp_c"])
finally:
    r.close()
del hours
gc.collect()
ESP32 + Pico W · MicroPython 1.29 · simulated board
bytes: 6285
items: 168
first: 20.5 C
مثال 15.4
كل عنصر في الجواب مثل {"t":100,"temp_c":21.5,"wind_ms":5.5} وطوله 37 بايتا. ما الحجم التقريبي لتوقّعات أسبوع، ساعة بساعة؟
الحل:
n = 7 · 24
n = 168
S = 168 · 37
S = 6216 B
S = 6216 B

أفضل علاج هو أن نطلب أقل: كثير من الخدمات تقبل معاملا يحدّد الأيام أو الحقول. وإن لم يكن، نقرأ الجواب قطعا بقطع من r.raw دون تخزينه كله. وهذا المقطع مرّ بالترجمة فقط ولم يُشغَّل في بناء الكتاب، لأن العالم المُحاكى لا يوفّر r.raw:

قراءة الجواب قطعا صغيرة
r = requests.get(URL, timeout=5)
total = 0
while True:
    chunk = r.raw.read(256)
    if not chunk:
        break
    total += len(chunk)
r.close()
print(total)
سؤال 15.6: خدمة تعيد 6216 B في طلب واحد، ونحتاج منها إلى رقم واحد. ما البديل الأفضل من تكبير الذاكرة؟

15.7 HTTPS وخدمات أخرى – HTTPS ושירותים אחרים

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

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

وكثيرا ما يُطلب إرسال القراءات إلى جدول، مثل Google Sheets، أو إلى منصة بيانات. الفكرة في كل هذه الخدمات واحدة: تعطينا الخدمة عنوانا ومفتاحا، وترسل اللوحة post بصيغة JSON عبر HTTPS كل فترة، بالشكل الذي عرفناه في هذا الفصل. والتفاصيل تتغيّر من خدمة إلى أخرى، فنتبع توثيقها، ولا نعرضها هنا.

وعلى Raspberry Pi 4/5 البرنامج أقصر: نظام التشغيل هو الذي يتصل بالشبكة، ولذا لا نحتاج إلى wifi.py، والمكتبة requests هي الأصل الذي تقلّده مكتبة مايكروبايثون. نثبّتها في بيئة خاصة. البرنامج التالي مرّ بالترجمة فقط ولم يُشغَّل في بناء الكتاب، لأن مُحاكاة الباي لا تحوي شبكة:

python3 -m venv ~/lab
~/lab/bin/pip install requests
~/lab/bin/python weather_pi.py
الطلب نفسه على الباي: الأمر with يغلق الاستجابة بنفسه
import requests

URL = "http://api.example.com/v1/weather?city=Haifa"

with requests.get(URL, timeout=5) as r:
    r.raise_for_status()
    data = r.json()
print("temp: %.1f C" % data["temp_c"])

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

أوامر الفصل:

الأمرعمله
requests.get(url, timeout=5)طلب يعيد استجابة.
requests.post(url, json=d, timeout=5)إرسال قاموس بصيغة JSON.
requests.get(url, headers=h)طلب مع ترويسات، مثل مفتاح الخدمة.
r.status_codeرمز الحالة.
r.text, r.contentالجواب نصا أو بايتات.
r.json()الجواب قاموسا. ValueError إن لم يكن سليما.
r.close()إغلاق الاستجابة وتحرير المقبس.
ntptime.settime()ضبط الساعة على UTC.
time.gmtime(s)تحويل الثواني إلى تاريخ ووقت.
gc.mem_free()الذاكرة الحرة بالبايتات.

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

بالعربيةبالعبريةEnglish
واجهة APIממשק תכנותAPI
رمز الحالةקוד סטטוסStatus Code
مفتاح APIמפתח APIAPI Key
جسم الطلبגוף הבקשהRequest Body
التوقيت العالميזמן אוניברסליUTC
خادم الوقتשרת זמןTime Server
الشهادةתעודהCertificate
الإجابات النهائية لأسئلة الفصل
15.1 لا: الشبكة تعمل والخادم أجاب. يجب تقليل وتيرة الطلبات.
15.2 يتوقف بخطأ KeyError. نكتب data.get("wind_ms") ونفحص إن كانت النتيجة None.
15.3 لا: السطر باق في تاريخ المستودع. يجب إلغاء المفتاح وإصدار غيره.
15.4 في try الأولى لا توجد استجابة بعد فلا شيء نغلقه. والثانية لها finally يغلق الاستجابة بعد أن وُجدت.
15.5 UTC: فهو لا يقفز عند الانتقال إلى التوقيت الصيفي، ونحوّله إلى المحلي عند العرض فقط.
15.6 نطلب ساعة واحدة بمعامل مناسب، أو نضع بين اللوحة والخدمة خادما صغيرا يختصر الجواب، مثل راسبيري باي.

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

المستوى الأول — أساسي
1.
ما رمز الحالة الذي يدل على مفتاح ناقص؟ وما الرمز الذي يطلب منا الإبطاء؟
الإجابات: 401 و429.
2.
حوّلوا رياحا سرعتها 12 m/s إلى km/h.
الإجابات: v = 43.2 km/h.
3.
أين نضع r.close() ليعمل حتى لو حدث خطأ في قراءة JSON؟
الإجابات: في finally.
المستوى الثاني — تطبيق
4.
اكتبوا برنامجا يضيء الثنائي إذا تجاوزت الرياح 30 km/h. وماذا يطبع إذا أعادت الخدمة الجواب في هذا الفصل؟
الإجابات:
from machine import Pin
import board
import requests
import wifi

URL = "http://api.example.com/v1/weather?city=Haifa"
LIMIT_KMH = 30

led = Pin(board.LED, Pin.OUT)
wifi.connect()
r = requests.get(URL, timeout=5)
try:
    kmh = r.json()["wind_ms"] * 3.6
finally:
    r.close()
led.value(1 if kmh > LIMIT_KMH else 0)
print("%.1f km/h, LED %d" % (kmh, led.value()))
يطبع: 18.0 km/h, LED 0
5.
اكتبوا دالة تستدعي get_json عدة مرات مع انتظار بين المحاولات.
الإجابات:
import time


def get_retry(url, tries=3, wait_s=2):
    for k in range(tries):
        data = get_json(url)
        if data is not None:
            return data
        if k < tries - 1:
            time.sleep(wait_s)
    return None
المستوى الثالث — تعمّق
6.
برنامج يطلب الطقس كل دقيقة، ويتوقف بعد عدة ساعات برسالة OSError، ولا مشكلة في الشبكة. ما أول ما نفحصه؟
الإجابات: أن كل استجابة تُغلق، حتى في مسار الخطأ: تسرّب المقابس يظهر هكذا بعد عدد كبير من الطلبات.
7.
الخدمة تعيد S بايت، والذاكرة الحرة التي قسناها تقارب S. لماذا قد يفشل r.json() رغم نجاح r.content؟
الإجابات: النص يبقى في الذاكرة، ويبني r.json() بجانبه قاموسا له أيضا حجم، فلا تكفي الذاكرة لهما معا.