תכנות מיקרו-בקרים בשפת MicroPython · פרק 15 — בקשות HTTP וממשקי API⌂📚🏠
פרק 15

בקשות HTTP וממשקי API

HTTP Requests and APIs
2 שעות לימוד

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

בפרק הזה:
  • נשלח בקשות get ו-post ונקרא את קוד המצב, את הטקסט ואת ה-JSON.
  • נסגור כל תגובה, ונדע מה דולף אם לא נעשה זאת.
  • נטפל בניתוק רשת, בפסק זמן, בשגיאה מצד השרת ובתשובה פגומה.
  • נכוונן את השעון בעזרת NTP ונהפוך אותו לזמן מקומי.
  • נעריך את גודל התשובה לפני שמבקשים אותה, ונדע מה HTTPS מוסיף ומה הוא לא מוסיף.

15.1 הבקשה הראשונה (The First Request)

הלוח הוא עכשיו לקוח. הספרייה requests כלולה מראש בגרסאות MicroPython שיש בהן רשת בשני הלוחות, והיא פותחת את השקע, כותבת את הבקשה וקוראת את התגובה במקומנו.

הלוח: הלקוחהנתבהאינטרנטהשירות: השרת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 (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 (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 ושירותים אחרים

לפי הכתובת requests בוחרת את הדרך: מספיק לכתוב https:// והבקשה הופכת למוצפנת, וזה מגן על התוכן, כולל המפתח, ממי שמאזין ברשת. אבל יש לזה מחיר: לחיצת היד של ההצפנה לוקחת זמן וזיכרון רבים בהרבה מבקשה רגילה.

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

פעמים רבות רוצים לשלוח את הקריאות לטבלה, כמו Google Sheets, או לפלטפורמת נתונים. הרעיון בכל השירותים האלה זהה: השירות נותן לנו כתובת ומפתח, והלוח שולח post בפורמט JSON דרך HTTPS מדי פעם, בצורה שהכרנו בפרק הזה. הפרטים משתנים משירות לשירות, ולכן נלך לפי התיעוד שלו ולא נציג אותם כאן.

וב-Raspberry Pi 4/5 התוכנית קצרה יותר: מערכת ההפעלה היא שמתחברת לרשת, ולכן אין צורך ב-wifi.py, והספרייה requests היא המקור שהספרייה של MicroPython מחקה. מתקינים אותה בסביבה ייעודית. התוכנית הבאה עברה הידור בלבד ולא הורצה בבניית הספר, כי הדמיית הפאי אינה כוללת רשת:

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رمز الحالة
מפתח APIAPI Keyمفتاح API
גוף הבקשה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 מבקשים שעה אחת בעזרת פרמטר מתאים, או מציבים בין הלוח לשירות שרת קטן שמקצר את התשובה, למשל Raspberry Pi.

שאלות לתרגול:

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