תכנות מיקרו-בקרים בשפת MicroPython · פרק 12 — קבצים ונתוני JSON⌂📚🏠
פרק 12

קבצים ונתוני JSON

Files and JSON Data
2 שעות לימוד

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

בפרק הזה:
  • נקרא קובץ, נכתוב אליו ונוסיף לו, וננהל קבצים בעזרת הפקודות של os.
  • נשמור הגדרות בקובץ JSON שהפסקת חשמל באמצע הכתיבה אינה פוגעת בו.
  • נרשום קריאות חיישן בקובץ CSV וננתח אותן על הלוח עצמו.
  • נקטין את מספר הכתיבות ל-flash בעזרת חוצץ, ונבין מה מפסידים בתמורה.
  • נחבר כרטיס SD, נשמור סיסמה כערך גיבוב, ונדע מה ערך הגיבוב אינו פותר.

12.1 מערכת הקבצים על הלוח (Filesystem)

חלק מזיכרון הפלאש של הלוח מוקצה למערכת קבצים. שם נמצאים boot.py, main.py והספריות שלנו, כפי שראינו בפרק 1. התוכנית פותחת את הקבצים שלה באותה פקודה שאנחנו מכירים מפייתון: open.

מצבמשמעותאם הקובץ קייםאם הקובץ לא קיים
"r"קריאה, וזו ברירת המחדלקוראים מההתחלהשגיאת OSError
"w"כתיבההתוכן נמחק מידנוצר קובץ חדש
"a"הוספהכותבים בסופונוצר קובץ חדש
"rb", "wb"בתים במקום טקסטכמו קודםכמו קודם
כותבים שתי שורות, מוסיפים שלישית וקוראים את הקובץ שורה אחר שורה. כדי לקרוא את כולו בבת אחת: f.read().
with open("note.txt", "w") as f:
    count = f.write("line one\n")
    f.write("line two\n")

print("characters in the first write:", count)

with open("note.txt", "a") as f:
    f.write("line three\n")

with open("note.txt") as f:
    for line in f:
        print("->", line.strip())
ESP32 + Pico W · MicroPython 1.29 · simulated board
characters in the first write: 9
-> line one
-> line two
-> line three

ההוראה with סוגרת את הקובץ ביציאה מהבלוק, גם אם אירעה בו שגיאה. זה חשוב, כי חלק מהכתיבה עלול להישאר בזיכרון ולא להגיע ל-flash לפני הסגירה.

טעות נפוצה: ההוראה f.write מקבלת רק טקסט במצב הטקסט. כדי לכתוב מספר כותבים "%.2f" % value או str(value). ובמצב "w" הקובץ נמחק כבר בפתיחה, לפני שכתבנו משהו.

12.2 ניהול קבצים בעזרת המודול os

פקודהמה היא עושה
os.listdir()רשימת שמות הקבצים בתיקייה הנוכחית.
os.stat(path)[6]גודל הקובץ בבתים. אם הקובץ לא קיים, מתקבלת שגיאת OSError.
os.remove(path)מחיקת קובץ.
os.rename(old, new)שינוי שם.
os.mkdir(path)יצירת תיקייה.
os.statvfs("/")נתונים על מערכת הקבצים: במקום 0 גודל הבלוק, ובמקום 3 מספר הבלוקים הפנויים. המכפלה שלהם היא המקום הפנוי.
מציגים רק קבצי .txt, כי על הלוח יש עוד קבצים רבים.
import os


def texts():
    return sorted(n for n in os.listdir() if n.endswith(".txt"))


def exists(path):
    try:
        os.stat(path)
        return True
    except OSError:
        return False


for name in ("a.txt", "b.txt"):
    with open(name, "w") as f:
        f.write("12345")

print("files:", texts())
print("size of a.txt:", os.stat("a.txt")[6], "bytes")

os.rename("b.txt", "c.txt")
print("after rename:", texts())

os.remove("a.txt")
print("after remove:", texts())
print("a.txt exists:", exists("a.txt"))
ESP32 + Pico W · MicroPython 1.29 · simulated board
files: ['a.txt', 'b.txt']
size of a.txt: 5 bytes
after rename: ['a.txt', 'c.txt']
after remove: ['c.txt']
a.txt exists: False
הערה: במיקרו-פייתון אין את המודול os.path המוכר מפייתון, ולכן כתבנו בעצמנו את הפונקציה exists.

12.3 פורמט JSON (JSON)

JSON: דרך לכתוב מילונים, רשימות ומספרים כטקסט רגיל. אדם יכול לקרוא אותו, וכל שפות התכנות מבינות אותו, ולכן ניפגש בו שוב עם הרשת בחלק הרביעי.
בפייתוןב-JSON
dict{"a": 1}
list, tuple[1, 2]
str"text", במירכאות כפולות בלבד
int, float28, 28.5
True, False, Nonetrue, false, null
dumps הופכת אובייקט לטקסט, ו-loads הופכת טקסט לאובייקט.
import json

node = {"name": "lab-1", "limit": 28.5, "on": True, "pins": [2, 13]}

text = json.dumps(node)
print(text)

back = json.loads(text)
print("limit + 1.5 =", "%.1f" % (back["limit"] + 1.5))
print("second pin  =", back["pins"][1])

try:
    json.loads("{'limit': 28}")
except ValueError:
    print("single quotes are not JSON")
ESP32 + Pico W · MicroPython 1.29 · simulated board
{"limit": 28.5, "on": true, "name": "lab-1", "pins": [2, 13]}
limit + 1.5 = 30.0
second pin  = 13
single quotes are not JSON
טעות נפוצה: מפתחות במילון הם תמיד טקסטים. אין להסתמך על הסדר שלהם: במיקרו-פייתון הם עלולים לצאת בסדר שונה מזה שכתבנו. וההוראה dumps כאן אינה מקבלת את הפרמטר indent, ולכן הטקסט יוצא בשורה אחת.
שאלה 12.1: מה מדפיסה ההוראה json.loads("[1, 2]")[1] + 1?

12.4 קובץ הגדרות

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

הקובץ settings.py, בכל הלוחות
# settings.py - keep a dictionary of settings in a JSON file.
# (c) 2026 Fahed Ghanem - MIT License - fahed-lab.com
import json
import os


def load(path, defaults):
    data = dict(defaults)
    try:
        with open(path) as f:
            data.update(json.load(f))
    except (OSError, ValueError):
        pass
    return data


def save(path, data):
    tmp = path + ".tmp"
    with open(tmp, "w") as f:
        json.dump(data, f)
    os.rename(tmp, path)
שומרים וקוראים, ואז משחיתים את הקובץ בכוונה, כאילו החשמל התנתק באמצע הכתיבה והקובץ הזמני לא היה קיים.
import settings

PATH = "config.json"
DEFAULTS = {"limit": 30, "period_s": 60}

cfg = settings.load(PATH, DEFAULTS)
print("no file yet :", cfg["limit"], cfg["period_s"])

cfg["limit"] = 28
settings.save(PATH, cfg)

cfg = settings.load(PATH, DEFAULTS)
print("after save  :", cfg["limit"], cfg["period_s"])

with open(PATH, "w") as f:
    f.write('{"limit": 2')

cfg = settings.load(PATH, DEFAULTS)
print("broken file :", cfg["limit"], cfg["period_s"])
ESP32 + Pico W · MicroPython 1.29 · simulated board
no file yet : 30 60
after save  : 28 60
broken file : 30 60
שאלה 12.2: למה לא מספיק לכתוב open(path, "w") ישירות בתוך save?

12.5 רישום נתונים בקובץ CSV (Data Logger)

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

לפני שמתחילים, שואלים: האם המקום מספיק?

דוגמה 12.1
רושמים שורה כל 60 s, ואורך שורה הוא 11 בתים, כמו 1234,25.31 עם סימן סוף השורה. המקום הפנוי הוא 1 MB, כלומר 1048576 בתים. לכמה ימים הוא מספיק?
פתרון:
N = 8640060 = 1440
S = N · s = 1440 · 11 = 15840
D = 104857615840 = 66.2
D = 66.2

המקום הפנוי בפועל שונה מלוח ללוח, ולכן קוראים אותו בהוראה os.statvfs. את הניתוח עושים על הלוח עצמו: קוראים את הקובץ שורה אחר שורה ולא משתמשים ב-f.read(), כי הרישום עשוי להיות גדול מכל הזיכרון.

השורה הראשונה היא כותרות העמודות, ולכן מדלגים עליה בעזרת readline.
PATH = "log.csv"

with open(PATH, "w") as f:
    f.write("t_s,volts\n0,1.20\n60,1.35\n120,1.80\n180,1.65\n")

count = 0
total = 0.0
high = 0.0
with open(PATH) as f:
    f.readline()
    for line in f:
        value = float(line.split(",")[1])
        count += 1
        total += value
        high = max(high, value)

print("rows:", count)
print("mean: %.3f V" % (total / count))
print("max : %.2f V" % high)
ESP32 + Pico W · MicroPython 1.29 · simulated board
rows: 4
mean: 1.500 V
max : 1.80 V
הערה: קובץ שנפתח במצב "a" ממשיך אחרי הפעלה מחדש ואינו נמחק. לעומת זאת הזמן של time.ticks_ms מתחיל מחדש, ולכן הוא לא יכול לשמש תאריך. לתאריך אמיתי צריך שעון חיצוני או רשת.

12.6 שחיקת הפלאש: לא כותבים בכל שנייה (Flash Wear)

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

החיישןקריאה כל דקהרשימה בזיכרוןאובדת בהפסקת חשמלקובץ בפלאשנשאר אחרי הפסקת חשמלכל 10 שורות
השורות ממתינות בזיכרון, ואז נכתבות בבת אחת.
עשר קריאות, וכותבים אחת לארבע. שימו לב לשורה לפני האחרונה: זה מה שאובד אם החשמל מתנתק עכשיו.
import board
import os
import time
from convert import to_volts

PATH = "log.csv"
FLUSH_EVERY = 4

pot = board.analog_in(board.POT)
rows = []

for n in range(10):
    rows.append("%d,%.2f\n" % (n, to_volts(pot.read_u16())))
    if len(rows) >= FLUSH_EVERY:
        with open(PATH, "a") as f:
            f.write("".join(rows))
        print("wrote", len(rows), "rows")
        rows.clear()
    time.sleep(1)

print("still in memory:", len(rows), "rows")
print("file size:", os.stat(PATH)[6], "bytes")
ESP32 + Pico W · MicroPython 1.29 · simulated board
wrote 4 rows
wrote 4 rows
still in memory: 2 rows
file size: 56 bytes
השיטהפתיחות של הקובץ ביוםההפסד המרבי בהפסקת חשמל
כתיבה בכל שנייה86400כמעט אין
שורה בכל דקה, וכתיבה כל 10 שורות14410 שורות, כלומר 10 דקות של מדידה

הכלל: מחליטים כמה נתונים מוכנים להפסיד, וכותבים בדיוק בהתאם. קובץ ההגדרות נכתב רק כשהערך משתנה, ולא בלולאה.

אזהרה: טעות שקשה לגלות: תוכנית בתוך while True שכותבת לקובץ בכל סיבוב בלי sleep. במעבדה היא עובדת מצוין, ובשקט היא שוחקת את הפלאש. התיעוד של מיקרו-פייתון ממליץ על מערכת LittleFS, כי היא עמידה יותר בפני פגיעה, ומציין שמערכת FAT אינה עומדת בהפסקת חשמל בזמן כתיבה.

12.7 הקובץ boot.py

בפרק 1 למדנו שהלוח מריץ את boot.py ואחריו את main.py. ב-boot.py שמים את מה שחייב להיות מוכן לפני הפרויקט, כמו תיקיית הרישומים או חיבור כרטיס SD. הוא תמיד מסתיים.

הקובץ boot.py: יוצרים את התיקייה, ומתעלמים מהשגיאה אם היא כבר קיימת.
import os

try:
    os.mkdir("logs")
except OSError:
    pass

וב-main.py הבא יש דרך מילוט: אם הלחצן לחוץ בזמן ההפעלה, הפרויקט לא מתחיל, וקל להגיע ללוח ולתקן את הקבצים:

הקובץ main.py: הפרויקט עצמו נמצא ב-app.py.
import board
from machine import Pin

if Pin(board.BTN, Pin.IN, Pin.PULL_UP).value() == 0:
    print("safe mode: the project did not start")
else:
    import app

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

12.8 כרטיס SD

לרישומים ארוכים משתמשים בכרטיס SD, שמתחבר לאפיק SPI שהכרנו בפרק 10. כאן שני הלוחות נפרדים: ב-ESP32 המחלקה machine.SDCard היא חלק מהקושחה, וב-Pico W צריך את הספרייה sdcard מתוך micropython-lib, שמתקינים כמו בפרק 11.

3V3CSMOSISCKMISOGNDSD3V3CSMOSISCKMISOGND
החיבור לפי השמות ב-board.py. ב-ESP32 אלה אותם הדקים של חריץ 2 כברירת מחדל לפי התיעוד: SCK 18, MOSI 23, MISO 19, CS 5.
ב-ESP32: חריץ 2 הוא מצב SPI.
import machine
import os
import vfs

vfs.mount(machine.SDCard(slot=2), "/sd")
print(os.listdir("/sd"))
vfs.umount("/sd")
ב-Pico W: הספרייה נותנת התקן, ומעליו מחברים מערכת FAT.
import board
import os
import sdcard
import vfs
from machine import Pin

cs = Pin(board.CS, Pin.OUT, value=1)
sd = sdcard.SDCard(board.spi(), cs)
vfs.mount(vfs.VfsFat(sd), "/sd")
print(os.listdir("/sd"))
vfs.umount("/sd")

שתי התוכניות האלה לא הורצו בבניית הספר, כי למדמה אין כרטיס ואין את המחלקה SDCard. הכרטיס צריך אתחול ב-FAT, והוא נפגע בקלות אם החשמל מתנתק בזמן כתיבה. לכן סוגרים את הקובץ ומנתקים את הכרטיס בהוראה vfs.umount לפני שמוציאים אותו.

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

12.9 סיסמה בקובץ: ערך גיבוב (Hash)

אם ההתקן שלנו מבקש סיסמה, איפה שומרים אותה? לא כטקסט בקובץ. שומרים את ערך הגיבוב שלה (hash): מספר שמתקבל מפונקציה חד-כיוונית. מהסיסמה קל לחשב את ערך הגיבוב, אבל מערך הגיבוב אי אפשר לחזור לסיסמה. כדי להשוות, מחשבים את ערך הגיבוב של מה שהמשתמש הקליד ומשווים בין שני הערכים. מלח (salt) הוא בתים אקראיים שמוסיפים לפני החישוב, וכך לשתי סיסמאות זהות יש שני ערכי גיבוב שונים.

שתי השורות האחרונות: אותה סיסמה עם שני מלחים שונים, ולכן שני ערכי גיבוב שונים.
import binascii
import hashlib


def digest(password, salt):
    h = hashlib.sha256(salt + password.encode())
    return binascii.hexlify(h.digest()).decode()


salt = b"salt-one"
stored = digest("blue-river-42", salt)

print(stored)
print("right password:", digest("blue-river-42", salt) == stored)
print("wrong password:", digest("1234", salt) == stored)
print("other salt    :", digest("blue-river-42", b"salt-two") == stored)
ESP32 + Pico W · MicroPython 1.29 · simulated board
08cf86f399462d7b65c50c238a191145ed1e3fb227cf95eca7fe58e4e4cfaad2
right password: True
wrong password: False
other salt    : False
אזהרה: המלח בתוכנית הזאת קבוע, כי המדמה אינו מספק את ההוראה os.urandom. על לוח אמיתי כותבים salt = os.urandom(8), וכך לכל סיסמה יש מלח אקראי משלה. שומרים אותו בקובץ ליד ערך הגיבוב: הוא אינו סוד.

ועכשיו, מה ערך הגיבוב אינו פותר:

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

עיקרי הפרק:

פקודות הפרק:

פקודהמה היא עושה
open(path, mode)פתיחת קובץ במצב "r", "w" או "a".
f.read(), f.readline(), f.write(text)קריאת הכול, קריאת שורה, כתיבת טקסט.
json.dumps(obj), json.loads(text)מאובייקט לטקסט, ומטקסט לאובייקט.
json.dump(obj, f), json.load(f)אותן פקודות עם קובץ.
settings.load(path, defaults), settings.save(path, d)הגדרות בטוחות, מהספרייה שלנו settings.py.
machine.SDCard(slot=2)כרטיס ב-ESP32 במצב SPI.
vfs.mount(dev, "/sd"), vfs.umount("/sd")חיבור מערכת קבצים וניתוקה.
hashlib.sha256(data).digest()ערך גיבוב SHA-256 מבתים.

מילון מונחים:

עבריתEnglishערבית
מערכת קבציםFilesystemنظام الملفات
זיכרון פלאשFlash Memoryذاكرة الفلاش
רישום נתוניםData Loggerسجلّ البيانات
חוצץBufferالمخزن المؤقّت
שחיקהWearالاهتراء
ערך גיבובHashالبصمة
מלחSaltالملح
התשובות הסופיות לשאלות הפרק
12.1 3, כי הטקסט הפך לרשימה של מספרים שלמים.
12.2 כי המצב "w" מוחק את הקובץ בפתיחה. אם החשמל מתנתק לפני שהכתיבה מסתיימת, ההגדרות הישנות והחדשות אובדות יחד.

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

רמה ראשונה — בסיס
1.
בקובץ יש 100 שורות, והרצנו open("a.txt", "w").close(). מה נשאר בו?
תשובות: כלום: המצב "w" מחק את התוכן בפתיחה, והקובץ ריק.
2.
איך כותבים לקובץ טקסט את המספר t = 24.5?
תשובות: f.write("%.1f" % t), כי write מקבלת טקסט.
3.
מה ההבדל בין json.dumps ל-json.dump?
תשובות: הראשונה מחזירה טקסט, והשנייה כותבת לקובץ פתוח.
רמה שנייה — יישום
4.
אורך שורה הוא 20 בתים, שורה נרשמת כל 60 s, והמקום הפנוי הוא 1 MB. לכמה ימים הוא מספיק?
תשובות: D = 36.4 ימים בקירוב.
5.
הוספנו ל-DEFAULTS מפתח חדש, ועל הלוח יש קובץ ישן שאינו מכיר אותו. מה settings.load מחזירה?
תשובות: את ערך ברירת המחדל של המפתח החדש, ואת ערכי הקובץ בשאר המפתחות.
6.
כתבו מונה הפעלות בעזרת הספרייה settings.py: קורא את המספר מקובץ, מגדיל אותו באחד ושומר.
תשובות: s = settings.load(p, {"boots": 0}), אחר כך s["boots"] += 1, ואחר כך settings.save(p, s).
רמה שלישית — העמקה
7.
למה ערך גיבוב לא מתאים לשמירת מפתח של ממשק API שהלוח שולח לשרת?
תשובות: כי הלוח צריך את המפתח עצמו כדי לשלוח אותו, ומערך הגיבוב אי אפשר לחזור למקור.
8.
תכננו סבב של הרישום: אם log.csv עובר גודל מסוים, הוא הופך ל-log_old.csv ומתחיל קובץ חדש.
תשובות: לפני הכתיבה משווים את os.stat(path)[6] לגבול. אם עברנו אותו: מוחקים את הישן אם קיים, ומשתמשים ב-os.rename.