/* * MTG RFID Companion - LittleFS storage manager */ #include #include #include #include #include #include #include "esp_log.h" #include "esp_littlefs.h" #include "cJSON.h" #include "storage_manager.h" static const char *TAG = "storage"; #define STORAGE_PARTITION_LABEL "storage" #define STORAGE_BOOT_COUNT_FILE STORAGE_BASE_PATH "/boot_count.txt" #define STORAGE_TEST_FILE STORAGE_BASE_PATH "/test.txt" static const char SAMPLE_MAPPINGS[] = "{\n" " \"04A2B3C4\": \"Sol Ring\",\n" " \"04E1F2A3\": \"Atraxa, Praetors' Voice\"\n" "}\n"; static const char SAMPLE_CARD[] = "{\n" " \"name\": \"Sol Ring\",\n" " \"mana_cost\": \"{1}\",\n" " \"type\": \"Artifact\",\n" " \"oracle\": \"Tap: Add CC.\",\n" " \"price_usd\": \"1.25\"\n" "}\n"; bool storage_file_exists(const char *path) { struct stat st; return (stat(path, &st) == 0) && S_ISREG(st.st_mode); } esp_err_t storage_read_text(const char *path, char **out_buf, size_t *out_len) { if (out_buf == NULL || out_len == NULL) { return ESP_ERR_INVALID_ARG; } *out_buf = NULL; *out_len = 0; FILE *f = fopen(path, "rb"); if (f == NULL) { return ESP_ERR_NOT_FOUND; } if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return ESP_FAIL; } long size = ftell(f); if (size < 0) { fclose(f); return ESP_FAIL; } rewind(f); char *buf = malloc((size_t)size + 1); if (buf == NULL) { fclose(f); return ESP_ERR_NO_MEM; } size_t rd = fread(buf, 1, (size_t)size, f); fclose(f); if (rd != (size_t)size) { free(buf); return ESP_FAIL; } buf[rd] = '\0'; *out_buf = buf; *out_len = rd; return ESP_OK; } esp_err_t storage_write_text(const char *path, const char *text) { char tmp[288]; const size_t len = strlen(path); if (len > sizeof(tmp) - 8) { return ESP_ERR_INVALID_ARG; } snprintf(tmp, sizeof(tmp), "%s.tmp", path); FILE *f = fopen(tmp, "wb"); if (f == NULL) { ESP_LOGE(TAG, "open %s failed", tmp); return ESP_FAIL; } size_t wr = fwrite(text, 1, strlen(text), f); if (wr != strlen(text) || fflush(f) != 0 || fclose(f) != 0) { ESP_LOGE(TAG, "write %s failed", tmp); remove(tmp); return ESP_FAIL; } if (rename(tmp, path) != 0) { ESP_LOGE(TAG, "rename %s -> %s failed", tmp, path); remove(tmp); return ESP_FAIL; } return ESP_OK; } esp_err_t storage_manager_bind_uid(const char *uid, const char *card_name) { cJSON *root = NULL; char *existing = NULL; size_t existing_len = 0; if (storage_read_text(STORAGE_MAPPINGS, &existing, &existing_len) == ESP_OK && existing != NULL) { root = cJSON_Parse(existing); } free(existing); if (root == NULL || !cJSON_IsObject(root)) { if (root) cJSON_Delete(root); root = cJSON_CreateObject(); } if (root == NULL) { return ESP_ERR_NO_MEM; } /* If previous mapping was for a different card, purge cached files so new card fetches clean */ cJSON *prev = cJSON_GetObjectItem(root, uid); if (prev == NULL || prev->valuestring == NULL || strcasecmp(prev->valuestring, card_name) != 0) { char old_json[64]; char old_jpg[64]; snprintf(old_json, sizeof(old_json), STORAGE_CARDS_DIR "/%s.json", uid); snprintf(old_jpg, sizeof(old_jpg), STORAGE_CARDS_DIR "/%s.jpg", uid); remove(old_json); remove(old_jpg); ESP_LOGI(TAG, "purged old card cache for %s due to new binding -> '%s'", uid, card_name); } cJSON_DeleteItemFromObject(root, uid); cJSON_AddItemToObject(root, uid, cJSON_CreateString(card_name)); char *out = cJSON_Print(root); esp_err_t ret = out ? storage_write_text(STORAGE_MAPPINGS, out) : ESP_ERR_NO_MEM; free(out); cJSON_Delete(root); return ret; } esp_err_t storage_manager_unbind_uid(const char *uid) { if (uid == NULL || uid[0] == '\0') { return ESP_ERR_INVALID_ARG; } char *existing = NULL; size_t existing_len = 0; cJSON *root = NULL; if (storage_read_text(STORAGE_MAPPINGS, &existing, &existing_len) == ESP_OK && existing != NULL) { root = cJSON_Parse(existing); } free(existing); if (root == NULL || !cJSON_IsObject(root)) { if (root) cJSON_Delete(root); return ESP_ERR_NOT_FOUND; } if (!cJSON_HasObjectItem(root, uid)) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; } cJSON_DeleteItemFromObject(root, uid); char old_json[64]; char old_jpg[64]; snprintf(old_json, sizeof(old_json), STORAGE_CARDS_DIR "/%s.json", uid); snprintf(old_jpg, sizeof(old_jpg), STORAGE_CARDS_DIR "/%s.jpg", uid); remove(old_json); remove(old_jpg); ESP_LOGI(TAG, "unbound UID %s and removed cached files", uid); char *out = cJSON_Print(root); esp_err_t ret = out ? storage_write_text(STORAGE_MAPPINGS, out) : ESP_ERR_NO_MEM; free(out); cJSON_Delete(root); return ret; } esp_err_t storage_manager_lookup_name(const char *uid, char **out) { *out = NULL; char *buf = NULL; size_t len = 0; if (storage_read_text(STORAGE_MAPPINGS, &buf, &len) != ESP_OK || buf == NULL) { free(buf); return ESP_ERR_NOT_FOUND; } cJSON *root = cJSON_Parse(buf); free(buf); if (root == NULL || !cJSON_IsObject(root)) { if (root) cJSON_Delete(root); return ESP_ERR_NOT_FOUND; } cJSON *item = cJSON_GetObjectItem(root, uid); if (item == NULL) { char prefix[48]; snprintf(prefix, sizeof(prefix), "%s&name=", uid); cJSON *child = NULL; cJSON_ArrayForEach(child, root) { if (child->string && strncmp(child->string, prefix, strlen(prefix)) == 0) { item = child; break; } } } esp_err_t ret = (item && cJSON_IsString(item) && item->valuestring) ? ESP_OK : ESP_ERR_NOT_FOUND; if (ret == ESP_OK) { *out = strdup(item->valuestring); if (*out == NULL) { ret = ESP_ERR_NO_MEM; } } cJSON_Delete(root); return ret; } static esp_err_t seed_defaults(void) { /* Create /spiffs/cards directory */ mkdir(STORAGE_CARDS_DIR, 0755); if (!storage_file_exists(STORAGE_MAPPINGS)) { ESP_LOGI(TAG, "seeding %s", STORAGE_MAPPINGS); esp_err_t ret = storage_write_text(STORAGE_MAPPINGS, SAMPLE_MAPPINGS); if (ret != ESP_OK) { return ret; } } char sample_path[64]; snprintf(sample_path, sizeof(sample_path), STORAGE_CARDS_DIR "/sample.json"); if (!storage_file_exists(sample_path)) { ESP_LOGI(TAG, "seeding %s", sample_path); esp_err_t ret = storage_write_text(sample_path, SAMPLE_CARD); if (ret != ESP_OK) { return ret; } } return ESP_OK; } static void cross_reboot_persistence_test(void) { /* Persistent boot counter: proves writes survive a reset/reboot. */ char buf[16] = {0}; uint32_t count = 0; FILE *f = fopen(STORAGE_BOOT_COUNT_FILE, "rb"); if (f != NULL) { if (fread(buf, 1, sizeof(buf) - 1, f) > 0) { count = (uint32_t)strtoul(buf, NULL, 10); ESP_LOGI(TAG, "boot_count read back = %lu (persistence across reboots OK)", (unsigned long)count); } fclose(f); } count++; snprintf(buf, sizeof(buf), "%lu\n", (unsigned long)count); storage_write_text(STORAGE_BOOT_COUNT_FILE, buf); ESP_LOGI(TAG, "boot_count updated to %lu", (unsigned long)count); /* Sanity write/read within the same boot. */ storage_write_text(STORAGE_TEST_FILE, "MTG-RFID-COMPANION persistence test\n"); char *rd = NULL; size_t rd_len = 0; if (storage_read_text(STORAGE_TEST_FILE, &rd, &rd_len) == ESP_OK && rd != NULL) { ESP_LOGI(TAG, "test.txt echoed back: %s", rd); free(rd); } else { ESP_LOGE(TAG, "test.txt read-back failed"); } } esp_err_t storage_manager_init(void) { esp_vfs_littlefs_conf_t conf = { .base_path = STORAGE_BASE_PATH, .partition_label = STORAGE_PARTITION_LABEL, .format_if_mount_failed = true, }; esp_err_t ret = esp_vfs_littlefs_register(&conf); if (ret != ESP_OK) { ESP_LOGE(TAG, "LittleFS mount failed: %s", esp_err_to_name(ret)); return ret; } size_t total = 0; size_t used = 0; if (esp_littlefs_info(STORAGE_PARTITION_LABEL, &total, &used) == ESP_OK) { ESP_LOGI(TAG, "LittleFS mounted at %s: %d bytes total, %d used", STORAGE_BASE_PATH, (int)total, (int)used); } else { ESP_LOGI(TAG, "LittleFS mounted at %s", STORAGE_BASE_PATH); } ret = seed_defaults(); if (ret != ESP_OK) { ESP_LOGE(TAG, "seeding default files failed: %s", esp_err_to_name(ret)); return ret; } cross_reboot_persistence_test(); return ESP_OK; } esp_err_t storage_manager_get_info(size_t *total_bytes, size_t *used_bytes) { if (total_bytes == NULL || used_bytes == NULL) { return ESP_ERR_INVALID_ARG; } return esp_littlefs_info(STORAGE_PARTITION_LABEL, total_bytes, used_bytes); } esp_err_t storage_manager_count_cached(int *json_count, int *jpg_count) { if (json_count == NULL || jpg_count == NULL) { return ESP_ERR_INVALID_ARG; } *json_count = 0; *jpg_count = 0; DIR *d = opendir(STORAGE_CARDS_DIR); if (d == NULL) { return ESP_OK; } struct dirent *entry; while ((entry = readdir(d)) != NULL) { if (entry->d_type == DT_REG || entry->d_type == DT_UNKNOWN) { size_t nlen = strlen(entry->d_name); if (nlen > 5 && strcmp(entry->d_name + nlen - 5, ".json") == 0) { (*json_count)++; } else if (nlen > 4 && strcmp(entry->d_name + nlen - 4, ".jpg") == 0) { (*jpg_count)++; } } } closedir(d); return ESP_OK; } esp_err_t storage_manager_clear_all_cards(bool reset_mappings) { DIR *d = opendir(STORAGE_CARDS_DIR); if (d != NULL) { struct dirent *entry; while ((entry = readdir(d)) != NULL) { if (entry->d_name[0] == '.') { continue; } char path[300]; snprintf(path, sizeof(path), STORAGE_CARDS_DIR "/%s", entry->d_name); unlink(path); } closedir(d); ESP_LOGI(TAG, "Cleared all files in %s", STORAGE_CARDS_DIR); } if (reset_mappings) { storage_write_text(STORAGE_MAPPINGS, "{\n}\n"); ESP_LOGI(TAG, "Reset %s to empty", STORAGE_MAPPINGS); } return ESP_OK; }