From 7e0e65297bb60af5e154cf594fdf03d6aa8d98c5 Mon Sep 17 00:00:00 2001 From: byun Date: Tue, 24 Feb 2026 20:19:38 +0000 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=B6=84=ED=95=A0=20?= =?UTF-8?q?=EC=BD=94=EB=A9=98=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.h | 2 + sdcard_task.cpp | 225 +++++++++++++++++++++++++++++++++++++++++++++--- sdcard_task.h | 2 + web_html.h | 37 +++++++- web_task.cpp | 23 +++++ 5 files changed, 276 insertions(+), 13 deletions(-) diff --git a/config.h b/config.h index be75923..0afee7e 100644 --- a/config.h +++ b/config.h @@ -58,6 +58,8 @@ #define SD_WRITE_INTERVAL 1000 // Flush to SD every N ms #define LOG_DIR "/logs" #define CSV_HEADER "Timestamp,Direction,Data\r\n" +#define LOG_MAX_FILE_SIZE (100UL * 1024 * 1024) // 100MB auto-rotate +#define LOG_ROTATE_MIDNIGHT true // New file at midnight // --- NTP Configuration --- #define NTP_SERVER "pool.ntp.org" diff --git a/sdcard_task.cpp b/sdcard_task.cpp index d61b8dc..d0b36ea 100644 --- a/sdcard_task.cpp +++ b/sdcard_task.cpp @@ -9,6 +9,42 @@ static File logFile; static bool sdReady = false; static SemaphoreHandle_t sdMutex = NULL; +// --- 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(); @@ -25,16 +61,20 @@ void sdTaskInit() { } else { sdReady = true; uint64_t cardSize = SD.cardSize() / (1024 * 1024); - Serial.printf("[SD] Card mounted. Type: %d, Size: %lluMB\n", cardType, cardSize); + 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"); } - // Don't auto-start logging - user starts manually via web UI 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))); } } @@ -42,6 +82,9 @@ void sdTaskInit() { NULL, TASK_PRIORITY_SD_LOG, NULL, 0); } +// ============================================================ +// Create new log file +// ============================================================ bool sdCreateNewLogFile() { if (!sdReady) return false; xSemaphoreTake(sdMutex, portMAX_DELAY); @@ -67,15 +110,43 @@ bool sdCreateNewLogFile() { } logFile.print(CSV_HEADER); logFile.flush(); - Serial.printf("[SD] New log file: %s\n", currentLogFileName); + + // 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]; @@ -85,26 +156,34 @@ void sdLoggingTask(void *param) { 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'); - // Write data as plain string (no hex encoding) 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; } - // Skip control chars (0x00~0x1F) silently } len += snprintf(csvLine + len, sizeof(csvLine) - len, "\"\r\n"); - logFile.write((uint8_t*)csvLine, len); - pendingWrites++; + + 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) { @@ -116,6 +195,7 @@ void sdLoggingTask(void *param) { lastFlush = xTaskGetTickCount(); } + // Batch flush if (pendingWrites >= 50) { if (logFile) { xSemaphoreTake(sdMutex, portMAX_DELAY); @@ -125,9 +205,23 @@ void sdLoggingTask(void *param) { 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) { @@ -136,6 +230,8 @@ void sdStopLogging() { logFile.close(); xSemaphoreGive(sdMutex); } + logStartDay = -1; + logFileSize = 0; Serial.println("[SD] Logging stopped"); } @@ -146,10 +242,84 @@ void sdStartLogging() { 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; @@ -158,10 +328,14 @@ String sdGetFileList() { 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 += file.name(); + json += fname; json += "\",\"size\":"; json += String(file.size()); time_t t = file.getLastWrite(); @@ -170,17 +344,47 @@ String sdGetFileList() { strftime(timeBuf, sizeof(timeBuf), "%Y-%m-%d %H:%M:%S", tm); json += ",\"modified\":\""; json += timeBuf; - json += "\"}"; + 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(); } - json += "]}"; + + 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); @@ -192,6 +396,7 @@ bool sdDeleteFile(const char *filename) { 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; diff --git a/sdcard_task.h b/sdcard_task.h index 81b6189..5b305f9 100644 --- a/sdcard_task.h +++ b/sdcard_task.h @@ -19,5 +19,7 @@ void sdStartLogging(); String sdGetFileList(); bool sdDeleteFile(const char *filename); bool sdCardPresent(); +String sdGetComment(const char *filename); +bool sdSetComment(const char *filename, const char *comment); #endif diff --git a/web_html.h b/web_html.h index 8cd9062..63ccb78 100644 --- a/web_html.h +++ b/web_html.h @@ -91,6 +91,7 @@ tr:active{background:rgba(233,69,96,0.1);} .cba,.cbf{width:18px;height:18px;accent-color:var(--btn);} .fl{color:var(--rx);text-decoration:none;} .fs{color:#888;} +.fc{color:var(--ok);cursor:pointer;font-size:12px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} /* ===== STATUS BAR ===== */ .sbar{display:flex;align-items:center;padding:3px 10px;background:var(--panel);border-top:1px solid var(--border);font-size:10px;color:#777;gap:10px;flex-shrink:0;flex-wrap:wrap;} @@ -119,6 +120,7 @@ tr:active{background:rgba(233,69,96,0.1);} .ftool{gap:4px;} .ftool button{padding:5px 10px;font-size:10px;} th,td{padding:5px 6px;font-size:11px;} + .fc{max-width:100px;font-size:10px;} .sbar{font-size:9px;gap:6px;padding:2px 6px;} } @@ -253,7 +255,7 @@ tr:active{background:rgba(233,69,96,0.1);}
- +
FileSizeDateFileSizeDateComment
@@ -536,23 +538,52 @@ function loadFiles(){ fetch('/api/files').then(r=>r.json()).then(d=>{ const tb=document.getElementById('fList');tb.innerHTML=''; if(!d.files||!d.files.length){ - tb.innerHTML='No files'; + tb.innerHTML='No files'; document.getElementById('fInfo').textContent='0 files';return; } let tot=0; d.files.forEach(f=>{ tot+=f.size; + let cmt=f.comment||''; let tr=document.createElement('tr'); tr.innerHTML='' +''+esc(f.name)+'' +''+fB(f.size)+'' - +''+(f.modified||'').slice(5)+''; + +''+(f.modified||'').slice(5)+'' + +'' + +(cmt?esc(cmt):'+memo')+''; tb.appendChild(tr); }); document.getElementById('fInfo').textContent=d.files.length+' files ('+fB(tot)+')'; + if(d.totalMB!==undefined){ + let free=d.totalMB-d.usedMB; + document.getElementById('fInfo').textContent+= + ' | SD: '+d.usedMB+'MB/'+d.totalMB+'MB ('+free+'MB free)'; + } }).catch(()=>addSys('[File list error]')); } +function editCmt(td,fname){ + if(td.querySelector('input')) return; // already editing + let old=td.textContent.trim(); + if(old==='+memo') old=''; + let inp=document.createElement('input'); + inp.type='text';inp.value=old;inp.maxLength=100; + inp.style.cssText='width:100%;background:var(--term-bg);color:var(--text);border:1px solid var(--ok);padding:2px 4px;font-size:12px;border-radius:3px;'; + inp.placeholder='Add comment...'; + td.innerHTML='';td.appendChild(inp);inp.focus(); + function save(){ + let v=inp.value.trim(); + fetch('/api/comment',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({file:fname,comment:v})}) + .then(r=>r.json()).then(()=>{ + td.innerHTML=v?esc(v):'+memo'; + }).catch(()=>{td.textContent=old||'+memo';}); + } + inp.onblur=save; + inp.onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();inp.blur();}if(e.key==='Escape'){td.innerHTML=old?esc(old):'+memo';}}; +} + function togAll(cb){document.querySelectorAll('.cbf').forEach(c=>c.checked=cb.checked);} function getSel(){return Array.from(document.querySelectorAll('.cbf:checked')).map(c=>c.value);} diff --git a/web_task.cpp b/web_task.cpp index a3a1a9a..46836b5 100644 --- a/web_task.cpp +++ b/web_task.cpp @@ -353,6 +353,29 @@ void setupWebRoutes() { server.streamFile(file, "text/csv"); file.close(); }); + + // --- Set/Update file comment --- + server.on("/api/comment", HTTP_POST, []() { + if (!server.hasArg("plain")) { + server.send(400, "application/json", "{\"error\":\"no body\"}"); + return; + } + StaticJsonDocument<512> doc; + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + server.send(400, "application/json", "{\"error\":\"invalid json\"}"); + return; + } + const char *fname = doc["file"]; + const char *comment = doc["comment"] | ""; + if (!fname) { + server.send(400, "application/json", "{\"error\":\"missing file\"}"); + return; + } + bool ok = sdSetComment(fname, comment); + String resp = "{\"ok\":" + String(ok ? "true" : "false") + "}"; + server.send(200, "application/json", resp); + }); } // ============================================================