From 429d704c8ce4db3194aa76d3f883ccdad8a80333 Mon Sep 17 00:00:00 2001 From: Stepan Usatiuk Date: Wed, 8 Oct 2025 22:19:21 +0200 Subject: [PATCH] vibecoded gb emulator --- Firmware/.gitignore | 1 + Firmware/.vscode/settings.json | 17 +- Firmware/dependencies.lock | 21 + Firmware/main/CMakeLists.txt | 8 +- Firmware/main/idf_component.yml | 17 + Firmware/main/include/apps/gameboy_app.hpp | 15 + Firmware/main/include/fs_helper.hpp | 26 + Firmware/main/src/app_main.cpp | 5 + Firmware/main/src/apps/gameboy_app.cpp | 569 +++++++++++++++++++++ Firmware/main/src/fs_helper.cpp | 68 +++ Firmware/partitions.csv | 6 + Firmware/sdkconfig | 34 +- 12 files changed, 781 insertions(+), 6 deletions(-) create mode 100644 Firmware/dependencies.lock create mode 100644 Firmware/main/idf_component.yml create mode 100644 Firmware/main/include/apps/gameboy_app.hpp create mode 100644 Firmware/main/include/fs_helper.hpp create mode 100644 Firmware/main/src/apps/gameboy_app.cpp create mode 100644 Firmware/main/src/fs_helper.cpp create mode 100644 Firmware/partitions.csv diff --git a/Firmware/.gitignore b/Firmware/.gitignore index 20eed54..4ab6637 100644 --- a/Firmware/.gitignore +++ b/Firmware/.gitignore @@ -2,3 +2,4 @@ build cmake-build* .idea .cache +managed_components \ No newline at end of file diff --git a/Firmware/.vscode/settings.json b/Firmware/.vscode/settings.json index e05293d..4cd6cc7 100644 --- a/Firmware/.vscode/settings.json +++ b/Firmware/.vscode/settings.json @@ -1,5 +1,20 @@ { "idf.flashType": "JTAG", "idf.port": "/dev/tty.usbmodem12401", - "C_Cpp.intelliSenseEngine": "default" + "C_Cpp.intelliSenseEngine": "default", + "files.associations": { + "bitset": "cpp", + "chrono": "cpp", + "algorithm": "cpp", + "random": "cpp", + "fstream": "cpp", + "streambuf": "cpp", + "regex": "cpp", + "*.inc": "cpp", + "vector": "cpp", + "esp_partition.h": "c", + "cstring": "cpp", + "array": "cpp", + "string_view": "cpp" + } } diff --git a/Firmware/dependencies.lock b/Firmware/dependencies.lock new file mode 100644 index 0000000..904177b --- /dev/null +++ b/Firmware/dependencies.lock @@ -0,0 +1,21 @@ +dependencies: + idf: + source: + type: idf + version: 5.5.1 + joltwallet/littlefs: + component_hash: 8e12955f47e27e6070b76715a96d6c75fc2b44f069e8c33679332d9bdd3120c4 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.20.1 +direct_dependencies: +- idf +- joltwallet/littlefs +manifest_hash: 261ed140a57f28f061ce29bcf3ae4833c35c16fa5e15670490bf2aacedefa622 +target: esp32h2 +version: 2.0.0 diff --git a/Firmware/main/CMakeLists.txt b/Firmware/main/CMakeLists.txt index 65ac89f..eaf2227 100644 --- a/Firmware/main/CMakeLists.txt +++ b/Firmware/main/CMakeLists.txt @@ -4,6 +4,7 @@ idf_component_register(SRCS src/apps/menu_app.cpp src/apps/clock_app.cpp src/apps/tetris_app.cpp + src/apps/gameboy_app.cpp src/display.cpp src/bat_mon.cpp src/spi_global.cpp @@ -14,5 +15,8 @@ idf_component_register(SRCS src/buttons.cpp src/power_helper.cpp src/buzzer.cpp - PRIV_REQUIRES spi_flash esp_driver_i2c driver sdk-esp esp_timer nvs_flash - INCLUDE_DIRS "include") + src/fs_helper.cpp + PRIV_REQUIRES spi_flash esp_driver_i2c driver sdk-esp esp_timer nvs_flash littlefs + INCLUDE_DIRS "include" "Peanut-GB") + +littlefs_create_partition_image(littlefs ../flash_data FLASH_IN_PROJECT) \ No newline at end of file diff --git a/Firmware/main/idf_component.yml b/Firmware/main/idf_component.yml new file mode 100644 index 0000000..ec9faa5 --- /dev/null +++ b/Firmware/main/idf_component.yml @@ -0,0 +1,17 @@ +## IDF Component Manager Manifest File +dependencies: + ## Required IDF version + idf: + version: '>=4.1.0' + # # Put list of dependencies here + # # For components maintained by Espressif: + # component: "~1.0.0" + # # For 3rd party components: + # username/component: ">=1.0.0,<2.0.0" + # username2/component2: + # version: "~1.0.0" + # # For transient dependencies `public` flag can be set. + # # `public` flag doesn't have an effect dependencies of the `main` component. + # # All dependencies of `main` are public by default. + # public: true + joltwallet/littlefs: ^1.20 diff --git a/Firmware/main/include/apps/gameboy_app.hpp b/Firmware/main/include/apps/gameboy_app.hpp new file mode 100644 index 0000000..9af77fc --- /dev/null +++ b/Firmware/main/include/apps/gameboy_app.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include "app_framework.hpp" + +#include +#include + +namespace apps { + +inline constexpr char kGameboyAppName[] = "Game Boy"; +inline constexpr std::string_view kGameboyAppNameView = kGameboyAppName; + +std::unique_ptr createGameboyAppFactory(); + +} // namespace apps diff --git a/Firmware/main/include/fs_helper.hpp b/Firmware/main/include/fs_helper.hpp new file mode 100644 index 0000000..9ad4a71 --- /dev/null +++ b/Firmware/main/include/fs_helper.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +class FsHelper { +public: + static FsHelper& get(); + + esp_err_t mount(); + void unmount(); + + bool isMounted() const { return mounted; } + const char* basePath() const { return kBasePath; } + const char* partitionLabel() const { return kPartitionLabel; } + +private: + FsHelper() = default; + + static constexpr const char* kBasePath = "/lfs"; + static constexpr const char* kPartitionLabel = "littlefs"; + static constexpr const bool kFormatOnFailure = true; + + bool mounted = false; +}; diff --git a/Firmware/main/src/app_main.cpp b/Firmware/main/src/app_main.cpp index ef42f70..8ef2c55 100644 --- a/Firmware/main/src/app_main.cpp +++ b/Firmware/main/src/app_main.cpp @@ -4,6 +4,7 @@ #include "app_framework.hpp" #include "apps/clock_app.hpp" +#include "apps/gameboy_app.hpp" #include "apps/menu_app.hpp" #include "apps/tetris_app.hpp" #include "config.hpp" @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -111,6 +113,8 @@ extern "C" void app_main() { DispTools::clear(); Buzzer::get().init(); + FsHelper::get().mount(); + static PlatformFramebuffer framebuffer; static PlatformInput input; static PlatformClock clock; @@ -122,6 +126,7 @@ extern "C" void app_main() { system.registerApp(apps::createMenuAppFactory()); system.registerApp(apps::createClockAppFactory()); system.registerApp(apps::createTetrisAppFactory()); + system.registerApp(apps::createGameboyAppFactory()); system.run(); } diff --git a/Firmware/main/src/apps/gameboy_app.cpp b/Firmware/main/src/apps/gameboy_app.cpp new file mode 100644 index 0000000..7460a99 --- /dev/null +++ b/Firmware/main/src/apps/gameboy_app.cpp @@ -0,0 +1,569 @@ +#include "apps/gameboy_app.hpp" + +#include "app_framework.hpp" +#include "font16x8.hpp" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace apps { +namespace { + +constexpr int kMenuStartY = 48; +constexpr int kMenuSpacing = font16x8::kGlyphHeight + 6; + +constexpr std::array kRomExtensions = {".gb", ".gbc"}; + +struct RomEntry { + std::string name; // short display name + std::string fullPath; // absolute path on LittleFS +}; + +class GameboyApp final : public IApp { +public: + explicit GameboyApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {} + + void onStart() override { + prevInput = {}; + statusMessage.clear(); + ensureFilesystemReady(); + refreshRomList(); + mode = Mode::Browse; + browserDirty = true; + } + + void onStop() override { unloadRom(); } + + void step() override { + const InputState input = context.input.readState(); + + switch (mode) { + case Mode::Browse: + handleBrowserInput(input); + renderBrowser(); + break; + case Mode::Running: + handleGameInput(input); + if (!gbReady) { + mode = Mode::Browse; + browserDirty = true; + break; + } + gb_run_frame(&gb); + renderGameFrame(); + break; + } + + prevInput = input; + } + + AppSleepPlan sleepPlan(uint32_t /*now*/) const override { + if (mode == Mode::Running) + return {}; + AppSleepPlan plan; + plan.slow_ms = 140; + plan.normal_ms = 50; + return plan; + } + +private: + enum class Mode { Browse, Running }; + + AppContext& context; + IFramebuffer& framebuffer; + + Mode mode = Mode::Browse; + std::vector roms; + std::size_t selectedIndex = 0; + bool browserDirty = true; + std::string statusMessage; + InputState prevInput{}; + + // Emulator state + struct gb_s gb{}; + bool gbReady = false; + std::vector romData; + std::vector cartRam; + std::array frameBuffer{}; + bool frameDirty = false; + std::string activeRomName; + std::string activeRomSavePath; + + bool ensureFilesystemReady() { + esp_err_t err = FsHelper::get().mount(); + if (err != ESP_OK) { + setStatus("LittleFS mount failed"); + return false; + } + + const std::string romDir = romDirectory(); + struct stat st{}; + if (stat(romDir.c_str(), &st) == 0) { + if (S_ISDIR(st.st_mode)) + return true; + setStatus("ROM path not dir"); + return false; + } + + if (mkdir(romDir.c_str(), 0775) == 0) + return true; + + if (errno == EEXIST) + return true; + + setStatus("ROM dir mkdir failed"); + return false; + } + + [[nodiscard]] std::string romDirectory() const { + std::string result(FsHelper::get().basePath()); + if (!result.empty() && result.back() != '/') + result.push_back('/'); + result.append("roms"); + return result; + } + + static bool hasRomExtension(std::string_view name) { + const auto dotPos = name.find_last_of('.'); + if (dotPos == std::string_view::npos) + return false; + std::string ext(name.substr(dotPos)); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return std::any_of(kRomExtensions.begin(), kRomExtensions.end(), + [&](std::string_view allowed) { return ext == allowed; }); + } + + void refreshRomList() { + roms.clear(); + + if (!FsHelper::get().isMounted() && !ensureFilesystemReady()) + return; + + const std::string dirPath = romDirectory(); + DIR* dir = opendir(dirPath.c_str()); + if (!dir) { + setStatus("No /lfs/roms directory"); + return; + } + + while (dirent* entry = readdir(dir)) { + const std::string_view name(entry->d_name); + if (name == "." || name == "..") + continue; + if (!hasRomExtension(name)) + continue; + RomEntry rom; + rom.fullPath = dirPath; + if (!rom.fullPath.empty() && rom.fullPath.back() != '/') + rom.fullPath.push_back('/'); + rom.fullPath.append(entry->d_name); + rom.name = std::string(name); + // Trim extension for display. + const auto dotPos = rom.name.find_last_of('.'); + if (dotPos != std::string::npos) + rom.name.resize(dotPos); + roms.push_back(std::move(rom)); + } + closedir(dir); + + std::sort(roms.begin(), roms.end(), [](const RomEntry& a, const RomEntry& b) { return a.name < b.name; }); + + if (selectedIndex >= roms.size()) + selectedIndex = roms.empty() ? 0 : roms.size() - 1; + + browserDirty = true; + + if (roms.empty()) + setStatus("Copy .gb/.gbc to /lfs/roms"); + else + statusMessage.clear(); + } + + void handleBrowserInput(const InputState& input) { + if (input.select && !prevInput.select) { + refreshRomList(); + browserDirty = true; + if (roms.empty()) + return; + } + + if (roms.empty()) + return; + const auto wrapDecrement = [&]() { + if (selectedIndex == 0) + selectedIndex = roms.size() - 1; + else + --selectedIndex; + browserDirty = true; + }; + + const auto wrapIncrement = [&]() { + selectedIndex = (selectedIndex + 1) % roms.size(); + browserDirty = true; + }; + + if (input.up && !prevInput.up) + wrapDecrement(); + else if (input.down && !prevInput.down) + wrapIncrement(); + + const bool launchRequested = (input.a && !prevInput.a) || (input.start && !prevInput.start); + if (launchRequested) + loadRom(selectedIndex); + } + + void renderBrowser() { + if (!browserDirty) + return; + browserDirty = false; + + DispTools::draw_to_display_async_wait(); + framebuffer.clear(false); + + const std::string_view title = "GAME BOY"; + const int titleWidth = font16x8::measureText(title, 2, 1); + const int titleX = (framebuffer.width() - titleWidth) / 2; + font16x8::drawText(framebuffer, titleX, 12, title, 2, true, 1); + + if (roms.empty()) { + font16x8::drawText(framebuffer, 24, kMenuStartY + 12, "NO ROMS FOUND", 1, true, 1); + font16x8::drawText(framebuffer, 24, kMenuStartY + kMenuSpacing + 12, "/LFS/ROMS", 1, true, 1); + } else { + const std::size_t visibleCount = + static_cast(std::max(1, (framebuffer.height() - kMenuStartY - 64) / kMenuSpacing)); + std::size_t first = 0; + if (roms.size() > visibleCount) { + if (selectedIndex >= visibleCount / 2) + first = selectedIndex - visibleCount / 2; + if (first + visibleCount > roms.size()) + first = roms.size() - visibleCount; + } + + for (std::size_t i = 0; i < visibleCount && (first + i) < roms.size(); ++i) { + const std::size_t idx = first + i; + const bool selected = (idx == selectedIndex); + const int y = kMenuStartY + static_cast(i) * kMenuSpacing; + const std::string labelText = roms[idx].name.empty() ? "(unnamed)" : roms[idx].name; + if (selected) + font16x8::drawText(framebuffer, 16, y, ">", 1, true, 1); + font16x8::drawText(framebuffer, selected ? 32 : 40, y, labelText, 1, true, 1); + } + + font16x8::drawText(framebuffer, 16, framebuffer.height() - 52, "A/START PLAY", 1, true, 1); + font16x8::drawText(framebuffer, 16, framebuffer.height() - 32, "SELECT RESCAN", 1, true, 1); + } + + if (!statusMessage.empty()) { + const int textWidth = font16x8::measureText(statusMessage, 1, 1); + const int x = std::max(12, (framebuffer.width() - textWidth) / 2); + font16x8::drawText(framebuffer, x, framebuffer.height() - 16, statusMessage, 1, true, 1); + } + + DispTools::draw_to_display_async_start(); + } + + bool loadRom(std::size_t index) { + if (index >= roms.size()) + return false; + + unloadRom(); + + const RomEntry& rom = roms[index]; + + FILE* file = std::fopen(rom.fullPath.c_str(), "rb"); + if (!file) { + setStatus("Open ROM failed"); + return false; + } + + if (std::fseek(file, 0, SEEK_END) != 0) { + setStatus("ROM seek failed"); + std::fclose(file); + return false; + } + const long size = std::ftell(file); + if (size <= 0) { + setStatus("ROM empty"); + std::fclose(file); + return false; + } + std::rewind(file); + + romData.resize(static_cast(size)); + const size_t readBytes = std::fread(romData.data(), 1, romData.size(), file); + std::fclose(file); + if (readBytes != romData.size()) { + setStatus("ROM read failed"); + romData.clear(); + return false; + } + + std::memset(&gb, 0, sizeof(gb)); + const auto initResult = gb_init(&gb, &GameboyApp::romRead, &GameboyApp::cartRamRead, &GameboyApp::cartRamWrite, + &GameboyApp::errorCallback, this); + if (initResult != GB_INIT_NO_ERROR) { + setStatus(initErrorToString(initResult)); + romData.clear(); + return false; + } + + gb.direct.priv = this; + gb.direct.joypad = 0xFF; + gb.direct.interlace = false; + gb.direct.frame_skip = false; + + gb_init_lcd(&gb, &GameboyApp::lcdDrawLine); + + const uint_fast32_t saveSize = gb_get_save_size(&gb); + cartRam.assign(static_cast(saveSize), 0); + activeRomSavePath = buildSavePath(rom.fullPath); + loadSaveFile(); + + gbReady = true; + mode = Mode::Running; + frameDirty = true; + activeRomName = rom.name.empty() ? "Game" : rom.name; + + setStatus("Running " + activeRomName); + return true; + } + + void unloadRom() { + if (!gbReady) { + romData.clear(); + cartRam.clear(); + activeRomName.clear(); + activeRomSavePath.clear(); + return; + } + + maybeSaveRam(); + + gbReady = false; + romData.clear(); + cartRam.clear(); + activeRomName.clear(); + activeRomSavePath.clear(); + std::memset(&gb, 0, sizeof(gb)); + mode = Mode::Browse; + browserDirty = true; + } + + void handleGameInput(const InputState& input) { + if (!gbReady) + return; + + uint8_t joypad = 0xFF; + if (input.a) + joypad &= ~JOYPAD_A; + if (input.b) + joypad &= ~JOYPAD_B; + if (input.select) + joypad &= ~JOYPAD_SELECT; + if (input.start) + joypad &= ~JOYPAD_START; + if (input.up) + joypad &= ~JOYPAD_UP; + if (input.down) + joypad &= ~JOYPAD_DOWN; + if (input.left) + joypad &= ~JOYPAD_LEFT; + if (input.right) + joypad &= ~JOYPAD_RIGHT; + + gb.direct.joypad = joypad; + + const bool exitComboPressed = input.start && input.select; + const bool exitComboJustPressed = exitComboPressed && !(prevInput.start && prevInput.select); + if (exitComboJustPressed) { + setStatus("Saved " + activeRomName); + unloadRom(); + } + } + + void renderGameFrame() { + if (!frameDirty) + return; + frameDirty = false; + + DispTools::draw_to_display_async_wait(); + framebuffer.clear(false); + + const int offsetX = (framebuffer.width() - LCD_WIDTH) / 2; + const int offsetY = (framebuffer.height() - LCD_HEIGHT) / 2; + + for (int y = 0; y < LCD_HEIGHT; ++y) { + const int dstY = offsetY + y; + if (dstY < 0 || dstY >= framebuffer.height()) + continue; + for (int x = 0; x < LCD_WIDTH; ++x) { + const int dstX = offsetX + x; + if (dstX < 0 || dstX >= framebuffer.width()) + continue; + const bool on = frameBuffer[static_cast(y) * LCD_WIDTH + x] != 0; + framebuffer.drawPixel(dstX, dstY, on); + } + } + + if (!activeRomName.empty()) + font16x8::drawText(framebuffer, 16, 16, activeRomName, 1, true, 1); + font16x8::drawText(framebuffer, 16, framebuffer.height() - 24, "START+SELECT BACK", 1, true, 1); + + DispTools::draw_to_display_async_start(); + } + + void maybeSaveRam() { + if (cartRam.empty() || activeRomSavePath.empty()) + return; + + FILE* file = std::fopen(activeRomSavePath.c_str(), "wb"); + if (!file) { + setStatus("Save write failed"); + return; + } + const size_t written = std::fwrite(cartRam.data(), 1, cartRam.size(), file); + std::fclose(file); + if (written != cartRam.size()) { + setStatus("Save short write"); + } + } + + void loadSaveFile() { + if (cartRam.empty() || activeRomSavePath.empty()) + return; + + FILE* file = std::fopen(activeRomSavePath.c_str(), "rb"); + if (!file) + return; + + const size_t read = std::fread(cartRam.data(), 1, cartRam.size(), file); + std::fclose(file); + if (read != cartRam.size()) { + // Ignore partial save files; keep data read so far. + setStatus("Partial save loaded"); + } + } + + void setStatus(std::string message) { + statusMessage = std::move(message); + browserDirty = true; + } + + static std::string buildSavePath(const std::string& romPath) { + std::string result = romPath; + const auto dot = result.find_last_of('.'); + if (dot != std::string::npos) + result.resize(dot); + result.append(".sav"); + return result; + } + + static GameboyApp* fromGb(struct gb_s* gb) { + if (!gb) + return nullptr; + return static_cast(gb->direct.priv); + } + + static uint8_t romRead(struct gb_s* gb, const uint_fast32_t addr) { + auto* self = fromGb(gb); + if (!self || addr >= self->romData.size()) + return 0xFF; + return self->romData[static_cast(addr)]; + } + + static uint8_t cartRamRead(struct gb_s* gb, const uint_fast32_t addr) { + auto* self = fromGb(gb); + if (!self || addr >= self->cartRam.size()) + return 0xFF; + return self->cartRam[static_cast(addr)]; + } + + static void cartRamWrite(struct gb_s* gb, const uint_fast32_t addr, const uint8_t value) { + auto* self = fromGb(gb); + if (!self || addr >= self->cartRam.size()) + return; + self->cartRam[static_cast(addr)] = value; + } + + static void errorCallback(struct gb_s* gb, const enum gb_error_e err, const uint16_t val) { + auto* self = fromGb(gb); + if (!self) + return; + + char buf[64]; + std::snprintf(buf, sizeof(buf), "EMU %s %04X", errorToString(err), val); + self->setStatus(buf); + self->unloadRom(); + } + + static void lcdDrawLine(struct gb_s* gb, const uint8_t pixels[160], const uint_fast8_t line) { + auto* self = fromGb(gb); + if (!self || line >= LCD_HEIGHT) + return; + + const std::size_t offset = static_cast(line) * LCD_WIDTH; + for (int x = 0; x < LCD_WIDTH; ++x) { + // Collapse 2-bit colour into monochrome. + const uint8_t shade = pixels[x] & 0x03u; + self->frameBuffer[offset + static_cast(x)] = (shade >= 2) ? 1 : 0; + } + self->frameDirty = true; + } + + static const char* initErrorToString(enum gb_init_error_e err) { + switch (err) { + case GB_INIT_NO_ERROR: + return "OK"; + case GB_INIT_CARTRIDGE_UNSUPPORTED: + return "Unsupported cart"; + case GB_INIT_INVALID_CHECKSUM: + return "Bad checksum"; + default: + return "Init failed"; + } + } + + static const char* errorToString(enum gb_error_e err) { + switch (err) { + case GB_UNKNOWN_ERROR: + return "Unknown"; + case GB_INVALID_OPCODE: + return "Bad opcode"; + case GB_INVALID_READ: + return "Bad read"; + case GB_INVALID_WRITE: + return "Bad write"; + case GB_HALT_FOREVER: + return "Halt"; + default: + return "Error"; + } + } +}; + +class GameboyAppFactory final : public IAppFactory { +public: + const char* name() const override { return kGameboyAppName; } + std::unique_ptr create(AppContext& context) override { return std::make_unique(context); } +}; + +} // namespace + +std::unique_ptr createGameboyAppFactory() { return std::make_unique(); } + +} // namespace apps diff --git a/Firmware/main/src/fs_helper.cpp b/Firmware/main/src/fs_helper.cpp new file mode 100644 index 0000000..2d79ad1 --- /dev/null +++ b/Firmware/main/src/fs_helper.cpp @@ -0,0 +1,68 @@ +#include "fs_helper.hpp" + +#include +#include +#include + +#include + +namespace { +constexpr const char* kTag = "FsHelper"; +} // namespace + +FsHelper& FsHelper::get() { + static FsHelper instance; + return instance; +} + +esp_err_t FsHelper::mount() { + if (mounted) + return ESP_OK; + + esp_vfs_littlefs_conf_t conf{}; + conf.base_path = kBasePath; + conf.partition_label = kPartitionLabel; + conf.format_if_mount_failed = kFormatOnFailure; + conf.dont_mount = false; +#if ESP_IDF_VERSION_MAJOR >= 5 + conf.read_only = false; +#endif + + const esp_err_t err = esp_vfs_littlefs_register(&conf); + if (err != ESP_OK) { + if (err == ESP_ERR_NOT_FOUND) { + ESP_LOGE(kTag, "Failed to find LittleFS partition '%s'", kPartitionLabel); + } else if (err == ESP_FAIL) { + ESP_LOGE(kTag, "Failed to mount LittleFS at %s (consider enabling format)", + kBasePath); + } else { + ESP_LOGE(kTag, "esp_vfs_littlefs_register failed: %s", esp_err_to_name(err)); + } + return err; + } + + mounted = true; + + size_t total = 0; + size_t used = 0; + const esp_err_t infoErr = esp_littlefs_info(kPartitionLabel, &total, &used); + if (infoErr == ESP_OK) { + ESP_LOGI(kTag, "LittleFS mounted at %s (%zu / %zu bytes used)", kBasePath, used, total); + } else { + ESP_LOGW(kTag, "LittleFS mounted but failed to query usage: %s", esp_err_to_name(infoErr)); + } + + return ESP_OK; +} + +void FsHelper::unmount() { + if (!mounted) + return; + const esp_err_t err = esp_vfs_littlefs_unregister(kPartitionLabel); + if (err != ESP_OK) { + ESP_LOGW(kTag, "Failed to unmount LittleFS (%s)", esp_err_to_name(err)); + return; + } + mounted = false; + ESP_LOGI(kTag, "LittleFS unmounted from %s", kBasePath); +} diff --git a/Firmware/partitions.csv b/Firmware/partitions.csv new file mode 100644 index 0000000..1c5550c --- /dev/null +++ b/Firmware/partitions.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +phy_init, data, phy, 0x10000, 0x1000, +factory, app, factory, 0x20000, 0x150000, +littlefs, data, littlefs,, 0x290000, diff --git a/Firmware/sdkconfig b/Firmware/sdkconfig index 1b9a307..8ef91cc 100644 --- a/Firmware/sdkconfig +++ b/Firmware/sdkconfig @@ -599,13 +599,13 @@ CONFIG_ESPTOOLPY_MONITOR_BAUD=115200 # # Partition Table # -CONFIG_PARTITION_TABLE_SINGLE_APP=y +# CONFIG_PARTITION_TABLE_SINGLE_APP is not set # CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE is not set # CONFIG_PARTITION_TABLE_TWO_OTA is not set # CONFIG_PARTITION_TABLE_TWO_OTA_LARGE is not set -# CONFIG_PARTITION_TABLE_CUSTOM is not set +CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" -CONFIG_PARTITION_TABLE_FILENAME="partitions_singleapp.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" CONFIG_PARTITION_TABLE_OFFSET=0x8000 CONFIG_PARTITION_TABLE_MD5=y # end of Partition Table @@ -2409,6 +2409,34 @@ CONFIG_WL_SECTOR_SIZE=4096 CONFIG_WIFI_PROV_SCAN_MAX_ENTRIES=16 CONFIG_WIFI_PROV_AUTOSTOP_TIMEOUT=30 CONFIG_WIFI_PROV_BLE_SEC_CONN=y + +# +# LittleFS +# +# CONFIG_LITTLEFS_SDMMC_SUPPORT is not set +CONFIG_LITTLEFS_MAX_PARTITIONS=3 +CONFIG_LITTLEFS_PAGE_SIZE=256 +CONFIG_LITTLEFS_OBJ_NAME_LEN=64 +CONFIG_LITTLEFS_READ_SIZE=128 +CONFIG_LITTLEFS_WRITE_SIZE=128 +CONFIG_LITTLEFS_LOOKAHEAD_SIZE=128 +CONFIG_LITTLEFS_CACHE_SIZE=512 +CONFIG_LITTLEFS_BLOCK_CYCLES=512 +CONFIG_LITTLEFS_USE_MTIME=y +# CONFIG_LITTLEFS_USE_ONLY_HASH is not set +# CONFIG_LITTLEFS_HUMAN_READABLE is not set +CONFIG_LITTLEFS_MTIME_USE_SECONDS=y +# CONFIG_LITTLEFS_MTIME_USE_NONCE is not set +# CONFIG_LITTLEFS_SPIFFS_COMPAT is not set +# CONFIG_LITTLEFS_FLUSH_FILE_EVERY_WRITE is not set +# CONFIG_LITTLEFS_FCNTL_GET_PATH is not set +# CONFIG_LITTLEFS_MULTIVERSION is not set +# CONFIG_LITTLEFS_MALLOC_STRATEGY_DISABLE is not set +CONFIG_LITTLEFS_MALLOC_STRATEGY_DEFAULT=y +# CONFIG_LITTLEFS_MALLOC_STRATEGY_INTERNAL is not set +CONFIG_LITTLEFS_ASSERTS=y +# CONFIG_LITTLEFS_MMAP_PARTITION is not set +# end of LittleFS # end of Component config # CONFIG_IDF_EXPERIMENTAL_FEATURES is not set