383 lines
11 KiB
C
383 lines
11 KiB
C
/*
|
|
* MTG RFID Companion - LittleFS storage manager
|
|
*/
|
|
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/unistd.h>
|
|
#include <dirent.h>
|
|
#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;
|
|
} |