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

الملفات وبيانات JSON

קבצים ונתוני JSON – Files and JSON Data
ساعتان تعليميتان

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

في هذا الفصل:
  • نقرأ ملفا ونكتب فيه ونضيف إليه، وندير الملفات بأوامر os.
  • نحفظ الإعدادات في ملف JSON لا يفسده انقطاع التيار أثناء الكتابة.
  • نسجّل قراءات الحسّاس في ملف CSV ونحلّلها على اللوحة.
  • نقلّل عدد مرات الكتابة في الفلاش بمخزن مؤقّت، ونعرف ما نخسره مقابل ذلك.
  • نوصل بطاقة 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 تغلق الملف عند الخروج من الكتلة، حتى لو وقع خطأ داخلها. وهذا مهم، لأن جزءا من الكتابة قد يبقى في الذاكرة ولا يصل إلى الفلاش قبل الإغلاق.

خطأ شائع: الأمر 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 (פורמט נתונים טקסטואלי): طريقة لكتابة القواميس والقوائم والأعداد نصا عاديا. يقرؤها الإنسان، وتفهمها كل لغات البرمجة، ولذلك سنلقاها مرة أخرى مع الشبكة في الجزء الرابع.
في بايثونفي 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 (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 – הקובץ 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

للسجلات الطويلة نستعمل بطاقة 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)

إذا طلب جهازنا كلمة مرور، فأين نحفظها؟ ليس نصا في ملف. نحفظ بصمتها (ערך גיבוב, hash): عدد ناتج عن دالة تعمل في اتجاه واحد، فمن كلمة المرور نحسب البصمة بسهولة، ومن البصمة لا نستطيع العودة. وللمقارنة نحسب بصمة ما كتبه المستخدم ونقارن بين البصمتين. والملح (מלח) بايتات عشوائية نضيفها قبل الحساب، فتصبح لكلمتي مرور متماثلتين بصمتان مختلفتان.

السطران الأخيران: كلمة المرور نفسها، وملحان مختلفان، فبصمتان مختلفتان.
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.