diff --git a/data/settings.json b/data/settings.json index aaa818d..3863e4c 100644 --- a/data/settings.json +++ b/data/settings.json @@ -2,5 +2,8 @@ "wifi": { "ssid": "Ti", "password": "94544549" + }, + "backend": { + "url": "http://192.168.5.18:3000/api/data" } } \ No newline at end of file diff --git a/include/init.h b/include/init.h index e2c610a..6ff47fa 100644 --- a/include/init.h +++ b/include/init.h @@ -19,6 +19,9 @@ void connectWiFi(); // 初始化系统 void initSystem(Adafruit_ST7735& tft); +// 获取后端接口 URL(来自 settings.json) +const String& getBackendUrl(); + } // namespace init } // namespace esp32 diff --git a/src/ad7606.cpp b/src/ad7606.cpp index c70d7f3..2ffc4fa 100644 --- a/src/ad7606.cpp +++ b/src/ad7606.cpp @@ -66,7 +66,8 @@ void readAll(int16_t* data8) { } // 读取8个16位(RD/SC 由 SPI SCLK 提供) - SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0)); + // 提升串行读取速率以缩短读出时间(与 TFT 共用 SPI,使用事务隔离) + SPI.beginTransaction(SPISettings(16000000, MSBFIRST, SPI_MODE0)); digitalWrite(PIN_AD_CS, LOW); for (int i = 0; i < 8; ++i) { uint16_t raw = SPI.transfer16(0x0000); diff --git a/src/init.cpp b/src/init.cpp index 2fd9e6d..04538a0 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -10,6 +10,7 @@ namespace init { // WiFi 配置变量 static String wifiSSID = ""; static String wifiPassword = ""; +static String backendUrl = ""; // TFT 显示器引用 (在 initSystem 中设置) static Adafruit_ST7735* tftPtr = nullptr; @@ -53,6 +54,15 @@ bool loadSettings() { wifiSSID = doc["wifi"]["ssid"].as(); wifiPassword = doc["wifi"]["password"].as(); + // 读取后端接口(可选) + if (doc.containsKey("backend")) { + backendUrl = doc["backend"]["url"].as(); + } + if (backendUrl.isEmpty()) { + // 回退:保持为空或设为默认 + backendUrl = "http://nf.xn--876a.net/api/data"; + } + tftLog("Config loaded:", GREEN); tftPtr->print(" SSID: "); tftLog(wifiSSID.c_str(), WHITE); @@ -103,5 +113,8 @@ void initSystem(Adafruit_ST7735& tft) { } } +// 提供后端 URL 给外部使用 +const String& getBackendUrl() { return backendUrl; } + } // namespace init } // namespace esp32 diff --git a/src/main.cpp b/src/main.cpp index 83de1e6..b280333 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,11 +1,18 @@ #include #include +#include +#include #include #include +#include +#include +#include +#include +#include +#include "ad7606.h" #include "init.h" #include "ui.h" #include "utils.h" -#include "ad7606.h" U8G2_FOR_ADAFRUIT_GFX u8g2; // 给 Adafruit_GFX 套一层 U8g2 的“文字引擎” using namespace esp32; @@ -22,7 +29,7 @@ Adafruit_ST7735 tft(TFT_CS, TFT_DC, TFT_RST); using namespace esp32::utils; using namespace esp32::ui; -const int BTN_LEFT = 12; // 左按钮 +const int BTN_LEFT = 9; // 左按钮 const int BTN_RIGHT = 11; // 右按钮 const int BTN_UP = 10; // 上按钮 @@ -31,14 +38,37 @@ const int BTN_UP = 10; // 上按钮 // 应用状态 struct AppState { // 环形缓冲:宽度=屏幕宽;时间窗口=3秒 - static const int GraphW = 128; // 与屏幕宽度一致 + static const int GraphW = 128; // 与屏幕宽度一致 int16_t samples[GraphW]; - int writeIdx = 0; // 下一个写入位置 - int sampleCount = 0; // 已写入样本(<=GraphW) - int16_t lastRaw = 0; // 最新原始码 - uint32_t lastSampleMs = 0; // 上一次采样时间 - static const uint32_t WindowMs = 3000; // 3 秒窗口 - static const uint32_t SampleIntervalMs = WindowMs / GraphW; // 约 23ms + int writeIdx = 0; // 下一个写入位置 + int sampleCount = 0; // 已写入样本(<=GraphW) + int16_t lastRaw = 0; // 最新原始码 + uint32_t lastSampleMs = 0; // 上一次采样时间 + static const uint32_t WindowMs = 3000; // 3 秒窗口 + static const uint32_t SampleIntervalMs = WindowMs / GraphW; // 约 23ms + + // 标定/拟合:mN = a*code + b(至少2点生效) + float target_mN[4]; // 预设档位:0, 3.6, 7.2, 10.8 + float capturedCode[4]; // 各档记录到的原始码 + bool captured[4]; // 是否已记录 + int presetIdx = 0; // 当前要记录的档位索引 + int capturedCount = 0; // 已记录的档位数 + bool fitReady = false; // 已具备>=2点 + float fitA = 0.0f; // mN = a*code + b + float fitB = 0.0f; + + // 录制 + static const int RecCap = 16384; + int16_t recBuf[RecCap]; + int recLen = 0; + bool recActive = false; + uint32_t recStartMs = 0; + uint32_t recEndMs = 0; + + // 上传状态提示 + char statusMsg[64]; + uint32_t statusUntilMs = 0; + uint16_t statusColor = WHITE; }; static UI appUI; @@ -55,23 +85,129 @@ static const int ICON_W = 48; // 图标宽度 static const int ICON_H = 48; // 图标高度 static const int SPACING = 72; // 图标中心间距(略大于宽度,保证边缘留白) static const int CENTER_X = (SCREEN_W - ICON_W) / 2; // 中心图标左上角 X -static const int BASE_Y = 40; // 图标顶端 Y(上方留空间给标题) +static const int BASE_Y = 40; // 图标顶端 Y(上方留空间给标题) static const int LABEL_MARGIN = 12; // 图标与文字竖向间距 static const int CHAR_W = 14; static const int CHAR_H = 14; +// 采样队列:将高速采样与 UI 渲染解耦 +static QueueHandle_t g_sampleQ = nullptr; // 队列元素:int16_t(CH0 原始码) + +// 采样任务:固定频率触发 AD7606 读取,推入队列 +static void samplerTask(void* param) { + // 目标采样率(可按需调高/调低)。示例:1000Hz + const TickType_t period = pdMS_TO_TICKS(1); + TickType_t last = xTaskGetTickCount(); + for (;;) { + int16_t s = ad7606::readCH0(); + if (g_sampleQ) { + // 队列满则丢弃最旧样本:先试一次无阻塞发送,失败则腾挪1个旧样本 + if (xQueueSendToBack(g_sampleQ, &s, 0) != pdTRUE) { + int16_t dummy; + xQueueReceive(g_sampleQ, &dummy, 0); + xQueueSendToBack(g_sampleQ, &s, 0); + } + } + vTaskDelayUntil(&last, period); + } +} + // 工具:推入一条新样本(原码) static inline void pushSample(AppState& s, int16_t code, uint32_t nowMs) { // 控制写入速率,保持一屏=3秒 - if (nowMs - s.lastSampleMs < AppState::SampleIntervalMs) return; + if (nowMs - s.lastSampleMs < AppState::SampleIntervalMs) + return; s.lastSampleMs = nowMs; s.samples[s.writeIdx] = code; s.writeIdx = (s.writeIdx + 1) % AppState::GraphW; - if (s.sampleCount < AppState::GraphW) s.sampleCount++; + if (s.sampleCount < AppState::GraphW) + s.sampleCount++; s.lastRaw = code; } +// 发送录制数据到服务器(JSON) +static void postRecording(AppState& s) { + if (s.recLen <= 0) { + s.statusColor = YELLOW; + snprintf(s.statusMsg, sizeof(s.statusMsg), "无数据可上传"); + s.statusUntilMs = millis() + 2000; + return; + } + if (WiFi.status() != WL_CONNECTED) { + s.statusColor = RED; + snprintf(s.statusMsg, sizeof(s.statusMsg), "未连接WiFi"); + s.statusUntilMs = millis() + 2000; + return; + } + + String payload; + // 估算预留空间:每个数最多6字符+逗号 + payload.reserve(32 + s.recLen * 7 + 48); + payload += "{"; + payload += "\"code\":["; + for (int i = 0; i < s.recLen; ++i) { + payload += String((int)s.recBuf[i]); + if (i + 1 < s.recLen) + payload += ","; + } + payload += "]"; + // 附带录制起止时间(毫秒) + payload += ",\"startTime\":"; + payload += String(s.recStartMs); + payload += ",\"endTime\":"; + payload += String(s.recEndMs); + if (s.fitReady) { + payload += ",\"fit\":{\"a\":"; + payload += String(s.fitA, 6); + payload += ",\"b\":"; + payload += String(s.fitB, 6); + payload += "}"; + } + payload += "}"; + printf("上传数据:%s\n", payload.c_str()); + HTTPClient http; + http.begin(esp32::init::getBackendUrl()); + http.addHeader("Content-Type", "application/json"); + http.setTimeout(10000); + int code = http.POST(payload); + String resp = http.getString(); + http.end(); + + if (code > 0) { + // 尝试解析 { success: true, id, timestamp } + JsonDocument doc; + DeserializationError err = deserializeJson(doc, resp); + bool ok = false; + String idStr = ""; + String tsStr = ""; + if (!err) { + ok = doc["success"].as(); + idStr = doc["id"].as(); + tsStr = doc["timestamp"].as(); + } else { + ok = resp.indexOf("\"success\":true") >= 0; // 兜底 + } + if (ok) { + s.statusColor = GREEN; + if (idStr.length()) + snprintf(s.statusMsg, sizeof(s.statusMsg), "上传成功\nID:%s", + idStr.c_str()); + else + snprintf(s.statusMsg, sizeof(s.statusMsg), "上传成功"); + s.statusUntilMs = millis() + 3000; + } else { + s.statusColor = RED; + snprintf(s.statusMsg, sizeof(s.statusMsg), "上传失败(%d)", code); + s.statusUntilMs = millis() + 3000; + } + } else { + s.statusColor = RED; + snprintf(s.statusMsg, sizeof(s.statusMsg), "HTTP错误(%d)", code); + s.statusUntilMs = millis() + 3000; + } +} + // 根据状态输出显示列表 static void buildScene(void* ctx, DisplayList& out) { auto* st = static_cast(ctx); @@ -79,44 +215,176 @@ static void buildScene(void* ctx, DisplayList& out) { // 背景整屏清除 out.addFillRect(1, 0, 0, SCREEN_W, SCREEN_H, BLACK); - // 显示当前原始码(居中顶部,去掉电压) - char line[48]; - snprintf(line, sizeof(line), "CH0: %d", st->lastRaw); + // 第一行:显示原始码 并在拟合可用时附加 mN + char line[64]; + if (st->fitReady) { + float currM = st->fitA * (float)st->lastRaw + st->fitB; + snprintf(line, sizeof(line), "%d it %.3f mN", st->lastRaw, currM); + } else { + snprintf(line, sizeof(line), "%d it", st->lastRaw); + } out.addText(2, SCREEN_W / 2, 10, WHITE, line, true); + // 右上角录制提示:红色圆点(空心两层加粗)+ 录制点数 + if (st->recActive) { + int cx = SCREEN_W - 30; + int cy = SCREEN_H - 6; + int r = 4; + out.addCircle(5, cx, cy, r, RED); + out.addCircle(6, cx, cy, r - 1, RED); + char recCnt[16]; + snprintf(recCnt, sizeof(recCnt), "%d", st->recLen); + uint16_t cntColor = esp32::utils::rgbTo565(0xFFCCCC); + out.addText(7, cx + 8, cy - 4, cntColor, recCnt, false); + } + + // 第二行:格式如 "[0*] 3.6 7.2 10.8",[]表示当前选择,*表示该档已记录 + char info[64]; + info[0] = '\0'; + for (int i = 0; i < 4; ++i) { + char valStr[12]; + float tgt = st->target_mN[i]; + if (fabsf(tgt) < 0.05f) { + snprintf(valStr, sizeof(valStr), "0"); + } else { + snprintf(valStr, sizeof(valStr), "%.1f", tgt); + } + char seg[16]; + if (i == st->presetIdx) { + if (st->captured[i]) + snprintf(seg, sizeof(seg), "[%s*]", valStr); + else + snprintf(seg, sizeof(seg), "[%s]", valStr); + } else { + if (st->captured[i]) + snprintf(seg, sizeof(seg), "%s*", valStr); + else + snprintf(seg, sizeof(seg), "%s", valStr); + } + size_t cur = strlen(info); + if (cur + strlen(seg) + 2 < sizeof(info)) { + if (cur > 0) + strcat(info, " "); + strcat(info, seg); + } + } + uint16_t infoColor = esp32::utils::rgbTo565(0xFFD060); + out.addText(3, SCREEN_W / 2, 22, infoColor, info, true); // 图形区域:最大化剩余高度,无边框 - const int topY = 24; // 标题高度 - const int graphH = SCREEN_H - topY; // 剩余高度 - const int gw = AppState::GraphW; // 128 + const int topY = 40; // 标题高度(两行信息) + const int graphH = SCREEN_H - topY - 4; // 剩余高度 + const int gw = AppState::GraphW; // 128 const int gx = 0; - if (st->sampleCount == 0) return; // 尚无数据 + if (st->sampleCount == 0) + return; // 尚无数据 - // 动态范围(min-200, max+200) - int16_t minV = st->samples[0]; - int16_t maxV = st->samples[0]; - for (int i = 1; i < st->sampleCount; ++i) { - int16_t v = st->samples[i]; - if (v < minV) minV = v; - if (v > maxV) maxV = v; + // 动态范围(原始码模式:min-200/max+200;拟合后:mN 加适度留白) + bool useFit = st->fitReady; + float fMin, fMax, fSpan, fPad; + if (!useFit) { + int16_t minV = st->samples[0]; + int16_t maxV = st->samples[0]; + for (int i = 1; i < st->sampleCount; ++i) { + int16_t v = st->samples[i]; + if (v < minV) + minV = v; + if (v > maxV) + maxV = v; + } + int32_t rangeMin = (int32_t)minV - 200; + int32_t rangeMax = (int32_t)maxV + 200; + if (rangeMin < -32768) + rangeMin = -32768; + if (rangeMax > 32767) + rangeMax = 32767; + fMin = (float)rangeMin; + fMax = (float)rangeMax; + fSpan = fMax - fMin; + if (fSpan <= 0.0f) + fSpan = 1.0f; + } else { + // 将样本转换为 mN 后求范围 + float minM = st->fitA * (float)st->samples[0] + st->fitB; + float maxM = minM; + for (int i = 1; i < st->sampleCount; ++i) { + float m = st->fitA * (float)st->samples[i] + st->fitB; + if (m < minM) + minM = m; + if (m > maxM) + maxM = m; + } + // mN 下给一个小的固定留白(0.2mN) + fMin = minM - 0.2f; + fMax = maxM + 0.2f; + if (fMin > fMax) { + float t = fMin; + fMin = fMax; + fMax = t; + } + fSpan = fMax - fMin; + if (fSpan <= 1e-6f) + fSpan = 1.0f; // 避免除0 } - int32_t rangeMin = (int32_t)minV - 200; - int32_t rangeMax = (int32_t)maxV + 200; - if (rangeMin < -32768) rangeMin = -32768; - if (rangeMax > 32767) rangeMax = 32767; - int32_t span = rangeMax - rangeMin; - if (span <= 0) span = 1; auto mapY = [&](int16_t code) -> int { - float rel = (float)((int32_t)code - rangeMin) / (float)span; // 0..1 - if (rel < 0.0f) rel = 0.0f; if (rel > 1.0f) rel = 1.0f; - float y = (1.0f - rel) * (graphH - 1); // 倒置 + float val = useFit ? (st->fitA * (float)code + st->fitB) : (float)code; + float rel = (val - fMin) / fSpan; // 0..1 + if (rel < 0.0f) + rel = 0.0f; + if (rel > 1.0f) + rel = 1.0f; + float y = (1.0f - rel) * (graphH - 1); // 倒置 int iy = topY + (int)(y + 0.5f); - if (iy < topY) iy = topY; - if (iy > topY + graphH - 1) iy = topY + graphH - 1; + if (iy < topY) + iy = topY; + if (iy > topY + graphH - 1) + iy = topY + graphH - 1; return iy; }; + // 绘制 Y 轴刻度与网格 + uint16_t gridColor = esp32::utils::rgbTo565(0x303030); + uint16_t tickColor = esp32::utils::rgbTo565(0x606060); + const int ticks = 4; // 分成4段,5条刻度线 + for (int i = 0; i <= ticks; ++i) { + float val = fMin + (fSpan * i) / ticks; + // 为了绘制刻度线,构造一个虚拟 code 值逆映射并用 mapY 仅为获取 y 像素 + int yPix; + if (useFit) { + // 近似:在当前 y 下对应的 val 直接用反推像素到值,再直接求 y 像素 + float rel = (val - fMin) / fSpan; + if (rel < 0) + rel = 0; + if (rel > 1) + rel = 1; + float y = (1.0f - rel) * (graphH - 1); + yPix = topY + (int)(y + 0.5f); + } else { + // 同上,直接由范围转 y 像素 + float rel = (val - fMin) / fSpan; + if (rel < 0) + rel = 0; + if (rel > 1) + rel = 1; + float y = (1.0f - rel) * (graphH - 1); + yPix = topY + (int)(y + 0.5f); + } + // 网格横线 + out.addLine(20 + i, 0, yPix, SCREEN_W - 1, yPix, gridColor); + // 左侧短刻度 + out.addLine(40 + i, 0, yPix, 3, yPix, tickColor); + + // 标签 + char lab[20]; + if (useFit) { + snprintf(lab, sizeof(lab), "%.1f", val); + } else { + snprintf(lab, sizeof(lab), "%d", (int)val); + } + out.addText(60 + i, 6, yPix - 6, tickColor, lab, false); + } + int prevY = mapY(st->samples[(st->writeIdx) % gw]); for (int x = 1; x < gw; ++x) { int idx = (st->writeIdx + x) % gw; @@ -124,6 +392,13 @@ static void buildScene(void* ctx, DisplayList& out) { out.addLine(100 + x, gx + x - 1, prevY, gx + x, y, GREEN); prevY = y; } + + // 底部上传状态提示(临时显示) + uint32_t nowMs = millis(); + if (st->statusUntilMs > nowMs && st->statusMsg[0] != '\0') { + out.addText(400, SCREEN_W / 2, SCREEN_H - 10, st->statusColor, + st->statusMsg, true); + } } void setup() { @@ -144,8 +419,8 @@ void setup() { // ★ 关键:把 u8g2 挂到 tft 上 u8g2.begin(tft); // 连接到 Adafruit_GFX :contentReference[oaicite:2]{index=2} - u8g2.setFontMode(1); // 透明背景(默认也是 1) - u8g2.setFontDirection(0); // 正常从左到右 + u8g2.setFontMode(1); // 透明背景(默认也是 1) + u8g2.setFontDirection(0); // 正常从左到右 u8g2.setForegroundColor(WHITE); // 文字颜色 // 使用更小的中文字体 @@ -171,10 +446,31 @@ void setup() { // 初始化 AD7606(与 TFT 共用 SPI) ad7606::init(); + // 创建采样队列与任务(将采样放到另一核,降低 UI 阻塞影响) + g_sampleQ = xQueueCreate(2048, sizeof(int16_t)); + // 在 Core 0 上跑采样任务,优先级略高于默认 + xTaskCreatePinnedToCore(samplerTask, "sampler", 4096, nullptr, 3, nullptr, 0); + // 清空波形缓存并初始化计时 - for (int i = 0; i < AppState::GraphW; ++i) app.samples[i] = 0; + for (int i = 0; i < AppState::GraphW; ++i) + app.samples[i] = 0; app.sampleCount = 0; app.lastSampleMs = millis(); + + // 初始化标定目标 + app.target_mN[0] = 0.0f; + app.target_mN[1] = 3.6f; + app.target_mN[2] = 7.2f; + app.target_mN[3] = 10.8f; + for (int i = 0; i < 4; ++i) { + app.captured[i] = false; + app.capturedCode[i] = 0.0f; + } + app.presetIdx = 0; + app.capturedCount = 0; + app.fitReady = false; + app.fitA = 0.0f; + app.fitB = 0.0f; } void loop() { @@ -182,14 +478,81 @@ void loop() { bool rightPressed = digitalRead(BTN_RIGHT) == HIGH; bool upPressed = digitalRead(BTN_UP) == HIGH; - // 读取 AD7606 CH0 原始码并按速率写入缓冲 + // 从采样队列取尽样本: + // - 显示:按时间节流到 128 点/3秒(pushSample 内已限速) + // - 录制:不节流,全部写入 recBuf uint32_t now = millis(); - int16_t ch0 = ad7606::readCH0(); - pushSample(app, ch0, now); + int drained = 0; + int16_t ch0; + while (g_sampleQ && xQueueReceive(g_sampleQ, &ch0, 0) == pdTRUE) { + drained++; + app.lastRaw = ch0; + // 显示按时间节流 + pushSample(app, ch0, now); + now = millis(); + // 录制不节流 + if (app.recActive && app.recLen < AppState::RecCap) { + app.recBuf[app.recLen++] = ch0; + } + } + + // BTN_RIGHT:记录当前电压到当前档位,并循环到下一档 + if (rightPressed && !prevRight) { + float c = (float)app.lastRaw; + int idx = app.presetIdx; + app.capturedCode[idx] = c; + if (!app.captured[idx]) { + app.captured[idx] = true; + app.capturedCount++; + } + + // 计算线性拟合(使用已记录点的最小二乘) + if (app.capturedCount >= 2) { + double sx = 0, sy = 0, sxx = 0, sxy = 0; + int n = 0; + for (int i = 0; i < 4; ++i) { + if (!app.captured[i]) + continue; + double x = app.capturedCode[i]; + double y = app.target_mN[i]; + sx += x; + sy += y; + sxx += x * x; + sxy += x * y; + n++; + } + double denom = (n * sxx - sx * sx); + if (n >= 2 && fabs(denom) > 1e-9) { + app.fitA = (float)((n * sxy - sx * sy) / denom); + app.fitB = (float)((sy - app.fitA * sx) / n); + app.fitReady = true; + } + } + + // 下一个档位 + app.presetIdx = (app.presetIdx + 1) % 4; + } + + // 左键:开始/停止录制,停止后上传 + if (leftPressed && !prevLeft) { + if (!app.recActive) { + app.recActive = true; + app.recLen = 0; + app.recStartMs = millis(); + app.recEndMs = 0; + } else { + app.recActive = false; + app.recEndMs = millis(); + postRecording(app); + } + } + // printf("l:%s, r:%s, u:%s\n", leftPressed ? "Y" : "N", rightPressed ? "Y" + // : "N", upPressed ? "Y" : "N"); 驱动 UI 帧刷新 - // 驱动 UI 帧刷新 appUI.tick(millis()); - - delay(16); // 简单节流 ~60FPS + prevLeft = leftPressed; + prevRight = rightPressed; + prevUp = upPressed; + // delay(16); // 简单节流 ~60FPS }