14.3.3. Higiena systemu plików

Pamięć flash i karta SD w wysłanej kamerze zapełniają się plikami, których żaden operator nie będzie ręcznie usuwał. Dwie decyzje dotyczące tej pamięci pozostają z produktem przez cały okres jego użytkowania: która powierzchnia przechowuje jaki rodzaj danych oraz jak zorganizowane są katalogi, aby operacje na plikach nadal działały w miarę gromadzenia rekordów przez aplikację.

14.3.3.1. Gdzie co trafia

Kod i zasoby znajdują się w zamrożonych modułach oraz ROMFS, które kompilacja zatwierdza w momencie wysyłki. Stan aplikacji – wszystko, co aplikacja zapisuje w czasie działania, wszystko, co rośnie, wszystko, co zmienia się między uruchomieniami – musi przebywać gdzie indziej. Kamera udostępnia do tego dwie zapisywalne powierzchnie:

  • Wewnętrzna pamięć flash pod /flash: niewielki zapisywalny system plików montowany przed uruchomieniem jakiegokolwiek kodu aplikacji. Właściwe miejsce na małe rekordy o stałym rozmiarze, które przetrwają ponowne uruchomienia: konfigurację, którą aplikacja aktualizuje w czasie działania, ostatnio znaną kalibrację, narastający licznik, jednowierszowy plik znacznika mówiący „ta kamera została przygotowana”. Ograniczona liczba cykli zapisu – nowoczesna wewnętrzna pamięć flash znosi od tysięcy do dziesiątek tysięcy zapisów na sektor, a nie miliony, więc zapisy muszą być rzadkie, a nie wykonywane na każdą ramkę.

  • Karta SD pod /sdcard: większy zapisywalny system plików montowany, gdy karta jest obecna. Właściwe miejsce na obszerne pliki o zmiennym rozmiarze: przechwycone obrazy i wideo, pliki dziennika, dane do dostrajania modelu, wszystko, co może urosnąć do megabajtów lub gigabajtów. Większa pojemność zapisu niż wewnętrzna pamięć flash, ale wciąż skończona; wymienna, łatwa do zastąpienia i powierzchnia najbardziej narażona na zniknięcie w trakcie zapisu przez aplikację.

Właściwa odpowiedź na pytanie gdzie coś zapisać to niemal zawsze „flash dla małych rekordów o stałym rozmiarze, SD dla wszystkiego innego”. Te dwie powierzchnie nie są wymienne: aplikacja, która zapisuje swój narastający plik dziennika do /flash, wyczerpie wytrzymałość zapisu pamięci flash we wdrożeniu, które na karcie SD przebiegłoby bez problemu.

14.3.3.2. Traktuj obie jako mogące zawieść

Zarówno /flash, jak i /sdcard mogą zawieść. Karta SD może zostać wysunięta, pamięć flash może ulec uszkodzeniu wskutek utraty zasilania w trakcie zapisu, w obu może zabraknąć miejsca, a każda operacja na którejkolwiek z nich może zgłosić OSError z powodów, których aplikacja nie będzie miała szansy zdiagnozować w terenie.

Dwa wzorce sprawiają, że aplikacja przetrwa taką sytuację:

  • Opakowuj montowanie i operacje w bloki try. Każde open(), os.listdir(), os.rename() wykonywane na ścieżkach z danymi użytkownika może się nie powieść. Przechwyć OSError, zapisz to w dzienniku i przejdź do zdefiniowanej alternatywy – zapisz do /flash, jeśli /sdcard zniknęło, lub pomiń operację, jeśli żadna z powierzchni nie jest dostępna.

  • Zapisy atomowe dla plików, które muszą przetrwać utratę zasilania. Zapisz do tymczasowej ścieżki, zamknij uchwyt, a następnie wykonaj os.rename() na docelową nazwę. Albo zmiana nazwy się powiodła i plik jest nową wersją, albo nie i plik jest starą wersją. Nie ma trzeciego stanu, w którym plik jest zapisany do połowy:

    import os
    
    def write_config_atomic(path, contents):
        tmp = path + '.tmp'
        with open(tmp, 'w') as f:
            f.write(contents)
            f.flush()
        os.rename(tmp, path)
    

    Wzorzec ten działa zarówno na pamięci flash, jak i na karcie SD. Nie sprawdza się on w przypadku plików na tyle dużych, że plik tymczasowy zajmuje całą wolną przestrzeń systemu plików; zarezerwuj go dla małych rekordów.

14.3.3.3. Pułapka wolnego katalogu

VFS MicroPython nie indeksuje zawartości katalogów tak, jak robi to system plików na komputerze stacjonarnym. os.listdir() i os.stat() przeszukują leżącą u podstaw tablicę plików liniowo. Katalog ze stu plikami jest w porządku; katalog z dziesięcioma tysiącami plików jest nieznośnie wolny, gdzie każde os.listdir() trwa sekundy, a każde open() sprawdza tablicę po drodze.

Aplikacje, które zapisują dzienniki lub przechwycone obrazy na dysk, napotykają ten problem najszybciej. Naiwny schemat /sdcard/logs/<timestamp>.log, który otwiera jeden nowy plik na minutę, zapełnia katalog logs/ pół milionem plików w ciągu roku wdrożenia. Na długo przedtem aplikacja zaczyna nie nadążać z liczbą klatek, ponieważ każde otwarcie pliku trwa dłużej niż interwał ramki.

Właściwym wzorcem jest rozdzielenie plików w drzewie datowanych podkatalogów, tak aby żaden pojedynczy katalog nigdy nie zawierał więcej niż kilkaset wpisów:

import os
import time

LOG_ROOT = '/sdcard/logs'

def log_path(now=None):
    if now is None:
        now = time.localtime()
    year, month, day, hour = now[0], now[1], now[2], now[3]
    directory = '{}/{:04d}/{:02d}/{:02d}'.format(
        LOG_ROOT, year, month, day)
    _makedirs(directory)
    return '{}/{:02d}.log'.format(directory, hour)

def _makedirs(path):
    # os.makedirs equivalent -- create each level if missing
    parts = path.split('/')
    for i in range(2, len(parts) + 1):
        sub = '/'.join(parts[:i])
        try:
            os.mkdir(sub)
        except OSError:
            pass

Roczne rejestrowanie po jednym pliku na godzinę jest teraz rozłożone na 365 katalogów dni, z których każdy zawiera co najwyżej 24 pliki; os.listdir() wykonywane na dowolnym katalogu pozostaje tanie, a pętla ramek aplikacji nie zacina się na operacjach plikowych w miarę starzenia się wdrożenia.

Ta sama zasada dotyczy przechwytywanych obrazów, śladów sensora i wszystkiego innego, dla czego aplikacja zapisuje plik na zdarzenie. Jeśli częstotliwość zdarzeń jest wysoka, drzewo powinno być głębsze (rok/miesiąc/dzień/godzina lub rok/miesiąc/dzień/godzina/minuta), aby każdy katalog liścia pozostawał mały. Jeśli częstotliwość zdarzeń jest niska, wystarczy drzewo rok/miesiąc.

14.3.3.4. Ścieżki per urządzenie

We flocie liczącej więcej niż jedną kamerę pliki dziennika muszą identyfikować, z której fizycznej jednostki pochodzą. machine.unique_id() zwraca identyfikator sprzętowy wpisany w kamerę fabrycznie; jest to ta sama wartość po ponownych uruchomieniach, po aktualizacjach oprogramowania układowego i po wymianach karty SD. Umieść go w ścieżce dziennika lub w rekordach dziennika, a operator patrzący na stos kart SD lub na scentralizowany dziennik będzie w stanie rozpoznać, która jest która:

import binascii
import machine

UNIT_ID = binascii.hexlify(machine.unique_id()).decode()

LOG_ROOT = '/sdcard/logs/' + UNIT_ID

W połączeniu ze wzorcem datowanych podkatalogów układ przyjmuje postać /sdcard/logs/<unit-id>/2026/06/09/14.log – godzina rekordów jednej jednostki, w katalogu na tyle płytkim, by go przejść, na ścieżce, która nazywa jednostkę w samym systemie plików.

14.3.3.5. Łącząc wszystko w całość

Zapisywalna pamięć wysłanej kamery wygląda mniej więcej tak:

  • /flash – konfiguracja, kalibracja, znacznik przygotowania. Zapisywana rzadko, odczytywana często. Wzorzec atomowej zmiany nazwy dla każdego pliku, którego utrata uniemożliwiłaby kolejne uruchomienie.

  • /sdcard/logs/<unit-id>/<year>/<month>/<day>/<hour>.log – dziennik operacyjny. Zapisywany w sposób ciągły, rotowany przez ścieżkę, nigdy nie zapisywany przez katalog z tysiącami sąsiadów.

  • /sdcard/captures/<unit-id>/<year>/<month>/<day>/ – obrazy lub wideo przechwytywane przez aplikację. Ten sam kształt drzewa, ten sam powód.

Taki układ kosztuje aplikację około dwudziestu wierszy kodu i chroni ją przed trybami awarii, które wyłączają kamerę po miesiącach wdrożenia.