파일 분할 코멘트 추가
This commit is contained in:
2
config.h
2
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"
|
||||
|
||||
225
sdcard_task.cpp
225
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
37
web_html.h
37
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);}
|
||||
<div class="ftable">
|
||||
<table><thead><tr>
|
||||
<th style="width:32px;"><input type="checkbox" class="cba" onchange="togAll(this)"></th>
|
||||
<th>File</th><th>Size</th><th>Date</th>
|
||||
<th>File</th><th>Size</th><th>Date</th><th>Comment</th>
|
||||
</tr></thead><tbody id="fList"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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='<tr><td colspan="4" style="text-align:center;color:#666;padding:20px;">No files</td></tr>';
|
||||
tb.innerHTML='<tr><td colspan="5" style="text-align:center;color:#666;padding:20px;">No files</td></tr>';
|
||||
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='<td><input type="checkbox" class="cbf" value="'+esc(f.name)+'"></td>'
|
||||
+'<td><a class="fl" href="/download?file='+encodeURIComponent(f.name)+'">'+esc(f.name)+'</a></td>'
|
||||
+'<td class="fs">'+fB(f.size)+'</td>'
|
||||
+'<td class="fs">'+(f.modified||'').slice(5)+'</td>';
|
||||
+'<td class="fs">'+(f.modified||'').slice(5)+'</td>'
|
||||
+'<td class="fc" onclick="editCmt(this,\''+esc(f.name)+'\')" title="Click to edit">'
|
||||
+(cmt?esc(cmt):'<span style="color:#555;font-style:italic;">+memo</span>')+'</td>';
|
||||
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):'<span style="color:#555;font-style:italic;">+memo</span>';
|
||||
}).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):'<span style="color:#555;font-style:italic;">+memo</span>';}};
|
||||
}
|
||||
|
||||
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);}
|
||||
|
||||
|
||||
23
web_task.cpp
23
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);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user