#include "sdcard_task.h" #include "serial_task.h" #include static SPIClass vspi(VSPI); volatile bool sdLoggingActive = false; volatile bool sdAutoStart = false; char currentLogFileName[64] = ""; static File logFile; static bool sdReady = false; static SemaphoreHandle_t sdMutex = NULL; // --- Persistent autostart flag --- #define AUTOSTART_FLAG "/logs/.autostart" // --- File rotation tracking --- static int logStartDay = -1; // Day-of-year when current log started static size_t logFileSize = 0; // Running byte count of current file // Get current day-of-year (1~366) static int getDayOfYear() { time_t now; time(&now); struct tm tm; localtime_r(&now, &tm); return tm.tm_yday; } // Check if file rotation is needed static bool needsRotation() { // 1. Midnight check: day changed since file was created if (LOG_ROTATE_MIDNIGHT && logStartDay >= 0) { int today = getDayOfYear(); if (today != logStartDay) { Serial.printf("[SD] Midnight rotation (day %d -> %d)\n", logStartDay, today); return true; } } // 2. Size check: file exceeds max size if (logFileSize >= LOG_MAX_FILE_SIZE) { Serial.printf("[SD] Size rotation (%.1fMB >= %.0fMB)\n", logFileSize / (1024.0 * 1024.0), LOG_MAX_FILE_SIZE / (1024.0 * 1024.0)); return true; } return false; } // ============================================================ // Init // ============================================================ void sdTaskInit() { sdMutex = xSemaphoreCreateMutex(); vspi.begin(SD_VSPI_SCLK, SD_VSPI_MISO, SD_VSPI_MOSI, SD_VSPI_CS); if (!SD.begin(SD_VSPI_CS, vspi, 4000000)) { Serial.println("[SD] Card mount FAILED!"); sdReady = false; } else { uint8_t cardType = SD.cardType(); if (cardType == CARD_NONE) { Serial.println("[SD] No card detected!"); sdReady = false; } else { sdReady = true; uint64_t cardSize = SD.cardSize() / (1024 * 1024); uint64_t usedSize = SD.usedBytes() / (1024 * 1024); Serial.printf("[SD] Card: %lluMB total, %lluMB used, %lluMB free\n", cardSize, usedSize, cardSize - usedSize); vspi.setFrequency(20000000); if (!SD.exists(LOG_DIR)) { SD.mkdir(LOG_DIR); Serial.println("[SD] Created /logs directory"); } // Check persistent autostart flag sdAutoStart = SD.exists(AUTOSTART_FLAG); if (sdAutoStart) { // Auto-start logging immediately (field deployment mode) Serial.println("[SD] *** AUTOSTART MODE - starting logging immediately ***"); // sdCreateNewLogFile takes mutex, so don't hold it here sdLoggingActive = false; // Will be set true after file creation } else { sdLoggingActive = false; Serial.println("[SD] Ready (logging OFF - start via web UI)"); } Serial.printf("[SD] Auto-rotate: midnight=%s, maxSize=%luMB\n", LOG_ROTATE_MIDNIGHT ? "ON" : "OFF", (unsigned long)(LOG_MAX_FILE_SIZE / (1024 * 1024))); } } xTaskCreatePinnedToCore(sdLoggingTask, "SDLog", TASK_STACK_SD_LOG, NULL, TASK_PRIORITY_SD_LOG, NULL, 0); // Deferred autostart (after task is running) if (sdReady && sdAutoStart) { sdCreateNewLogFile(); sdLoggingActive = true; Serial.printf("[SD] Autostart logging: %s\n", currentLogFileName); } } // ============================================================ // Create new log file // ============================================================ bool sdCreateNewLogFile() { if (!sdReady) return false; xSemaphoreTake(sdMutex, portMAX_DELAY); if (logFile) { logFile.flush(); logFile.close(); } struct tm timeinfo; time_t now; time(&now); localtime_r(&now, &timeinfo); snprintf(currentLogFileName, sizeof(currentLogFileName), "%s/LOG_%04d%02d%02d_%02d%02d%02d.csv", LOG_DIR, timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); logFile = SD.open(currentLogFileName, FILE_WRITE); if (!logFile) { Serial.printf("[SD] Failed to create: %s\n", currentLogFileName); xSemaphoreGive(sdMutex); return false; } logFile.print(CSV_HEADER); logFile.flush(); // Reset rotation tracking logStartDay = timeinfo.tm_yday; logFileSize = strlen(CSV_HEADER); Serial.printf("[SD] New log: %s (day=%d)\n", currentLogFileName, logStartDay); xSemaphoreGive(sdMutex); return true; } // ============================================================ // Auto-rotate: close current, open new (called inside mutex) // ============================================================ static void rotateLogFile() { // Close current if (logFile) { logFile.flush(); logFile.close(); Serial.printf("[SD] Rotated: %s (%.1fMB)\n", currentLogFileName, logFileSize / (1024.0 * 1024.0)); } // Create new file (releases and re-takes mutex internally) // We need to release mutex first since sdCreateNewLogFile takes it xSemaphoreGive(sdMutex); sdCreateNewLogFile(); xSemaphoreTake(sdMutex, portMAX_DELAY); } // ============================================================ // SD Logging Task // ============================================================ void sdLoggingTask(void *param) { Serial.println("[Task] SDLog started on core " + String(xPortGetCoreID())); TickType_t lastFlush = xTaskGetTickCount(); TickType_t lastRotateCheck = xTaskGetTickCount(); int pendingWrites = 0; char csvLine[LOG_LINE_MAX_LEN + 64]; while (true) { LogEntry *entry; if (xQueueReceive(queueSD, &entry, pdMS_TO_TICKS(100)) == pdTRUE) { if (sdLoggingActive && sdReady && logFile) { xSemaphoreTake(sdMutex, portMAX_DELAY); // Check rotation before writing if (needsRotation()) { rotateLogFile(); } int len = snprintf(csvLine, sizeof(csvLine), "\"%s\",\"%c\",\"", entry->timestamp, entry->direction == 'T' ? 'T' : 'R'); for (int i = 0; i < entry->dataLen && len < (int)sizeof(csvLine) - 4; i++) { char c = entry->data[i]; if (c == '"') { csvLine[len++] = '"'; csvLine[len++] = '"'; } else if (c >= 0x20) { csvLine[len++] = c; } } len += snprintf(csvLine + len, sizeof(csvLine) - len, "\"\r\n"); if (logFile) { logFile.write((uint8_t*)csvLine, len); logFileSize += len; pendingWrites++; } xSemaphoreGive(sdMutex); } vPortFree(entry); } // Periodic flush if (pendingWrites > 0 && (xTaskGetTickCount() - lastFlush) > pdMS_TO_TICKS(SD_WRITE_INTERVAL)) { if (logFile) { xSemaphoreTake(sdMutex, portMAX_DELAY); logFile.flush(); xSemaphoreGive(sdMutex); } pendingWrites = 0; lastFlush = xTaskGetTickCount(); } // Batch flush if (pendingWrites >= 50) { if (logFile) { xSemaphoreTake(sdMutex, portMAX_DELAY); logFile.flush(); xSemaphoreGive(sdMutex); } pendingWrites = 0; lastFlush = xTaskGetTickCount(); } // Periodic midnight check even when idle (every 30s) if (sdLoggingActive && logFile && (xTaskGetTickCount() - lastRotateCheck) > pdMS_TO_TICKS(30000)) { lastRotateCheck = xTaskGetTickCount(); if (needsRotation()) { xSemaphoreTake(sdMutex, portMAX_DELAY); rotateLogFile(); xSemaphoreGive(sdMutex); } } } } // ============================================================ // Stop / Start Logging // ============================================================ void sdStopLogging() { sdLoggingActive = false; if (logFile) { xSemaphoreTake(sdMutex, portMAX_DELAY); logFile.flush(); logFile.close(); xSemaphoreGive(sdMutex); } logStartDay = -1; logFileSize = 0; Serial.println("[SD] Logging stopped"); } void sdStartLogging() { if (!sdReady) { Serial.println("[SD] Card not ready!"); return; } if (!logFile || !sdLoggingActive) sdCreateNewLogFile(); sdLoggingActive = true; Serial.println("[SD] Logging started"); } // ============================================================ // Comments - stored in /logs/.comments (filename|comment per line) // ============================================================ #define COMMENTS_FILE "/logs/.comments" // Read all comments into a temp map, find one for given filename String sdGetComment(const char *filename) { if (!sdReady) return ""; // Note: caller should NOT hold sdMutex (or use recursive mutex) File f = SD.open(COMMENTS_FILE, FILE_READ); if (!f) return ""; String target = String(filename) + "|"; String line; String result = ""; while (f.available()) { line = f.readStringUntil('\n'); line.trim(); if (line.startsWith(target)) { result = line.substring(target.length()); break; } } f.close(); return result; } bool sdSetComment(const char *filename, const char *comment) { if (!sdReady) return false; // Read existing comments (skip target file) String allLines = ""; File f = SD.open(COMMENTS_FILE, FILE_READ); String target = String(filename) + "|"; if (f) { while (f.available()) { String line = f.readStringUntil('\n'); line.trim(); if (line.length() == 0) continue; if (line.startsWith(target)) continue; // Skip old comment for this file allLines += line + "\n"; } f.close(); } // Append new comment (if not empty) if (comment && strlen(comment) > 0) { allLines += String(filename) + "|" + String(comment) + "\n"; } // Rewrite file f = SD.open(COMMENTS_FILE, FILE_WRITE); if (!f) return false; f.print(allLines); f.close(); return true; } // Remove comment when file is deleted static void sdRemoveComment(const char *filename) { sdSetComment(filename, ""); // Empty = remove } // ============================================================ // File List (includes comments) // ============================================================ String sdGetFileList() { if (!sdReady) return "{\"files\":[]}"; xSemaphoreTake(sdMutex, portMAX_DELAY); // Pre-load all comments String commentsRaw = ""; File cf = SD.open(COMMENTS_FILE, FILE_READ); if (cf) { commentsRaw = cf.readString(); cf.close(); } String json = "{\"files\":["; File dir = SD.open(LOG_DIR); bool first = true; if (dir && dir.isDirectory()) { File file = dir.openNextFile(); while (file) { if (!file.isDirectory()) { String fname = file.name(); // Skip the .comments metadata file if (fname == ".comments") { file = dir.openNextFile(); continue; } if (!first) json += ","; first = false; json += "{\"name\":\""; json += fname; json += "\",\"size\":"; json += String(file.size()); time_t t = file.getLastWrite(); struct tm *tm = localtime(&t); char timeBuf[32]; strftime(timeBuf, sizeof(timeBuf), "%Y-%m-%d %H:%M:%S", tm); json += ",\"modified\":\""; json += timeBuf; json += "\""; // Find comment for this file String key = fname + "|"; int idx = commentsRaw.indexOf(key); if (idx >= 0) { int start = idx + key.length(); int end = commentsRaw.indexOf('\n', start); if (end < 0) end = commentsRaw.length(); String cmt = commentsRaw.substring(start, end); cmt.trim(); // Escape quotes in comment for JSON cmt.replace("\\", "\\\\"); cmt.replace("\"", "\\\""); json += ",\"comment\":\""; json += cmt; json += "\""; } json += "}"; } file = dir.openNextFile(); } dir.close(); } uint64_t totalMB = SD.totalBytes() / (1024 * 1024); uint64_t usedMB = SD.usedBytes() / (1024 * 1024); json += "],\"totalMB\":"; json += String((uint32_t)totalMB); json += ",\"usedMB\":"; json += String((uint32_t)usedMB); json += "}"; xSemaphoreGive(sdMutex); return json; } // ============================================================ // Delete File (also removes comment) // ============================================================ bool sdDeleteFile(const char *filename) { if (!sdReady) return false; xSemaphoreTake(sdMutex, portMAX_DELAY); char path[128]; snprintf(path, sizeof(path), "%s/%s", LOG_DIR, filename); if (sdLoggingActive && strcmp(path, currentLogFileName) == 0) { xSemaphoreGive(sdMutex); return false; } bool result = SD.remove(path); if (result) sdRemoveComment(filename); Serial.printf("[SD] Delete %s: %s\n", path, result ? "OK" : "FAIL"); xSemaphoreGive(sdMutex); return result; } bool sdCardPresent() { return sdReady; } // ============================================================ // Autostart - persistent flag on SD card // ============================================================ bool sdGetAutoStart() { if (!sdReady) return false; return SD.exists(AUTOSTART_FLAG); } void sdSetAutoStart(bool enable) { if (!sdReady) return; xSemaphoreTake(sdMutex, portMAX_DELAY); if (enable) { // Create flag file File f = SD.open(AUTOSTART_FLAG, FILE_WRITE); if (f) { f.println("autostart=1"); f.close(); } sdAutoStart = true; Serial.println("[SD] Autostart ENABLED (will log on next boot)"); } else { // Remove flag file if (SD.exists(AUTOSTART_FLAG)) { SD.remove(AUTOSTART_FLAG); } sdAutoStart = false; Serial.println("[SD] Autostart DISABLED"); } xSemaphoreGive(sdMutex); }