# espPH-loger **Repository Path**: genvex/esp-ph-loger ## Basic Information - **Project Name**: espPH-loger - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-08-06 - **Last Updated**: 2026-09-03 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # pH Meter Logger 基于 M5Stack Core2 的 pH 电极在线监测与记录仪——两点校准(极性自动适配)、eFuse 校准电压、温度补偿、SD 卡自动记录、触摸屏操作、串口调试,一套完整的野外长期采样方案。 [![Gitee](https://gitee.com/genvex/esp-ph-loger/badge/star.svg?theme=dark)](https://gitee.com/genvex/esp-ph-loger) --- 这篇文章讲它的完整运转流程和几个关键节点的实现方式。所有的代码片段都直接取自项目源码,不是伪码。 --- ## 模块架构 整台设备在 ESP32 双核上分成 7 个模块,职责很干净: - **PhSensor** — pH 电极采样,独立 FreeRTOS task,**50ms** 周期,中值滤波 + 滑动窗 - **RTCManager** — NTP 校时 + 系统时钟,开机同步一次 - **SdLogger** — SD 卡 CSV 记录 - **DisplayManager** — 主屏 4 个 sprite 局部刷新(状态条 / 测量区 / 弹窗区 / 控制区),另有校准页 3 个区域 sprite - **TouchHandler** — 电容触摸去抖:START / STOP / CAL 进校准页,校准页采点 + 确认弹窗 - **SerialCmd** — 注册式命令表(11 条) - **main** — setup 初始化(含开机音乐),loop 轮询(1s 更新显示,30min 写日志) 核心关系是:**采样 task 固定跑在 Core 0,主线程 loop 跑在 Core 1,两者通过一把互斥锁交换数据**。 ![](docs/operation-architecture.png) --- ## 开机流程(setup) `setup()` 按一条直线走完,没分支、没重试: ```cpp void setup() { Serial.begin(SERIAL_BAUD); // 1. 串口 delay(100); setupWdt(); // 2. WDT 60s 启用 display.init(); M5.Speaker.setVolume(SPEAKER_VOLUME); // 音量须在任何发声前设置 playBootJingle(); // 3. 开机音乐(一次性 task) display.drawSplash(); phSensor.init(); // 4. 读校准参数(含电压域版本校验) phSensor.startTask(); // 5. 启动采样 task rtcManager.init(); // 6. RTC init if (!sdLogger.init()) // 7. SD 卡挂载,失败弹红字不中断 display.showMessage("SD Card Failed!", TFT_RED); display.drawMainScreen(); // 8. 主屏绘制 serialCmd.init(); // 9. 注册命令表 // registerCommand: cal7 / cal9 / read / show / start / stop / // time / config / sync / temp / stab 共 11 条 // 10. WiFi 凭证:Preferences 里存过的优先,否则用编译期默认; // 后台 task 连 WiFi 做 NTP,不阻塞启动 String ssid = String(WIFI_SSID); String password = String(WIFI_PASSWORD); // ... 从 Preferences 读 savedSsid/savedPass 覆盖默认值 ... if (!ssid.isEmpty()) { rtcManager.syncFromNtpAsync(ssid.c_str(), password.c_str()); } else { Serial.println("No WiFi credentials; skipping NTP (offline mode)."); } } ``` 两个关键点值得说: **NTP 在后台 task 里完成,主循环零等待。** 状态栏同步期间显示 `(SYNC)`、未同步显示 `(RTC)`;同步 task 成功后 `WiFi.disconnect(true)` 关掉 WiFi 省功耗,主线程再把时间写回 BM8563 板载 RTC(I2C 与触摸共享总线,写回统一放主线程),之后不再重连: ```cpp ntpSynced_ = true; uptimeBase_ = millis(); WiFi.disconnect(true); WiFi.mode(WIFI_OFF); ``` **没有存 WiFi 凭证就优雅跳过,进离线模式。** 未同步时 `getTimestamp()` 回退为 `boot+HH:MM:SS`(基于 `millis()`)作为时间戳,测量和校准不受影响。 --- ## 核心运转 loop `loop()` 是非阻塞轮询,靠 `millis()` 做时间判断,不 `delay()`: ```cpp void loop() { esp_task_wdt_reset(); // 喂狗,防 WDT panic M5.update(); // M5Unified 触摸/按键状态每轮刷新 serialCmd.process(); // 处理串口命令 auto action = touchHandler.getAction(currentScreen); // 检查触摸动作 if (action != TouchHandler::Action::NONE) playActionSound(action); // 按键按下即发声反馈(钢琴式) switch (action) { case START_LOGGING: ... // START: 启动记录 case STOP_LOGGING: ... // STOP: 停止记录 case OPEN_CALIBRATION: ... // CAL: 进校准页 case CONFIRM_YES: ... // 确认采点: 请求异步采点 case CAL_CURRENT: ... // 采点前弹确认框(防误触) case SWITCH_POINT: ... // 切换 pH7/pH9 位点 case CAL_BACK: ... // 返回主屏 default: break; } // 异步校准结果回收: 采样 task 完成后立即反映到 UI if (phSensor.calibrationResultAvailable()) { ... } // 校准请求/确认弹窗超时兜底: 自动取消, 避免 UI 永久停在 Capturing static unsigned long lastReadTime = 0; unsigned long now = millis(); if (now - lastReadTime >= READ_INTERVAL_MS) { // 界面刷新 1s;采样数据 50ms 已更新到池 lastReadTime = now; PhSample sample = phSensor.readSample(); // 跨线程读取 // 按当前屏幕路由: 校准页刷新校准读数区, 主屏刷新 4 个 sprite 区域 display.updateStatusBar(...); display.updateMeasurementArea(...); display.updateControlArea(...); display.updatePopupSprite(); if (loggingActive && sdLogger.isReady()) { if (now - lastLogTime >= LOG_INTERVAL_MS) { // 30min sdLogger.writeLog(ts, sample.adcMv, smoothedVoltage, phToLog, temperatureC); } } } vTaskDelay(pdMS_TO_TICKS(5)); // 让出 5ms } ``` 三个节奏: - **每 5ms**:喂狗 + M5.update + 处理串口 + 检查触摸(按下即发声反馈)+ 校准结果/超时回收 - **每 1s**:读采样结果(加锁跨线程安全),按当前屏幕刷新 sprite 区域(池内数据是 50ms 更新一次的,所以界面拿到的一直是最新采样) - **每 30min**:写一条 CSV 日志到 SD 卡 没有重连 WiFi 的逻辑——NTP 同步成功后时间写入 BM8563 板载 RTC,之后直接走系统时钟,不再联网。 --- ## 关键节点实现细节 ### a. PhSensor 数据池 + 跨线程安全设计 这是整个项目最关键的工程点。采样 task 固定跑在 **Core 0**,主线程 `loop`(Arduino 默认 Core 1),二者通过一把互斥锁交换数据。 架构是**数据池模型**:采集 task 每周期只干一件事——把 1 个代表值(5 次 eFuse 校准读数取中值,单位 mV)覆盖进环形池的最旧槽;**滤波、换算、校准、存储全部由消费方从池子聚合**,与采集完全解耦。 **生产端**——`runOneSample()` 只更新池中一个槽,临界区极短: ```cpp void PhSensor::runOneSample() { int32_t mv = adcReadMedianMv(PH_PIN); // 5 次 eFuse 校准读数取中值(mV) if (sampleMutex_ && xSemaphoreTake(sampleMutex_, portMAX_DELAY) == pdTRUE) { pool_[poolHead_] = mv; // 覆盖最旧槽(槽内已是 mV) poolHead_ = (poolHead_ + 1) % POOL_SIZE; if (poolCount_ < POOL_SIZE) poolCount_++; hasNewSample_ = true; // 异步校准点:在锁内基于池聚合值评估,Flash 写入留给锁外 CalibrationPoint p = pendingCalPoint_; if (p != CalibrationPoint::NONE) { pendingCalPoint_ = CalibrationPoint::NONE; CalibResult r = (p == PH7) ? applyCalibrationPh7(meanOfLastUnlocked(PH_ADC_WINDOW_SIZE)) : applyCalibrationPh9(meanOfLastUnlocked(PH_ADC_WINDOW_SIZE)); calNeedsSave_ = r.needsSave; calResult_ = r; calResultReady_ = true; } xSemaphoreGive(sampleMutex_); } if (calNeedsSave_) { // Flash 写入放锁外,避免长时间占锁阻塞消费方 calNeedsSave_ = false; saveCalibration(); } vTaskDelay(PH_TASK_DELAY_US / 1000 / portTICK_PERIOD_MS); } ``` **消费端**——按需从池子聚合,绝不在 `loop` 里直读共享成员: ```cpp float PhSensor::getSmoothingVoltage() const { if (sampleMutex_ && xSemaphoreTake(sampleMutex_, 0) == pdTRUE) { float v = meanOfLastUnlocked(PH_ADC_WINDOW_SIZE); // 池最近 N 槽均值(mV) xSemaphoreGive(sampleMutex_); return v; } return NAN; } PhSample PhSensor::readSample() { if (sampleMutex_ && xSemaphoreTake(sampleMutex_, 10 / portTICK_PERIOD_MS) == pdTRUE) { PhSample s; s.adcMv = newestMvUnlocked(); // 池最新槽(mV) s.voltageMv = poolCount_ ? (float)s.adcMv : NAN; // 同源,无需再换算 s.ph = voltageToPhUnlocked(s.voltageMv, temperatureC_); hasNewSample_ = false; xSemaphoreGive(sampleMutex_); return s; } PhSample empty; // 拿不到锁返回空样本,绝不无锁拷贝共享结构 empty.adcMv = 0; empty.voltageMv = NAN; empty.ph = NAN; return empty; } ``` `readSample()` 锁用 `10ms` 超时避免阻塞显示节奏;`getSmoothingVoltage()` 用非阻塞 `0` 超时退 NAN。采样与消费互不阻塞:Core 0 持续喂池,Core 1 决定何时聚合。 **校准也走异步请求,不直读成员、不阻塞主线程**——主线程只登记请求,采样 task 下一周期在锁内基于池聚合值完成采点评估,主线程轮询回收结果: ```cpp phSensor.requestCalibrationPh7(); // 登记请求,立即返回 // ... 采样 task 在锁内 applyCalibrationPh7(meanOfLastUnlocked(PH_ADC_WINDOW_SIZE)) ... if (phSensor.calibrationResultAvailable()) { CalibResult r = phSensor.consumeCalibrationResult(); } ``` 读写双方的协同关系: ![](docs/operation-threads.png) ### b. pH 两点换算:带符号 slope 自适应极性 能斯特方程 $E = E° - S \cdot pH$(25°C 理论斜率 59.16 mV/pH)。早期版本做过"斜率方向强校验"——正斜率模型下要求 pH 9.18 电压必大于 pH 7.00,否则判"极性反了"并拒绝校准。**但能斯特方程本身意味着标准电极 V9 < V7(随 pH 升高电压下降),而部分反相模块 V9 > V7,两种都是合法的**,强校验把一半的电极组合误判为接反。 现在的做法是**去掉方向判断,slope 带符号,方向在换算时自动适配**。两点校准只校验电压差是否足够大(防两点几乎重合导致斜率爆炸): ```cpp // 两点校验: 仅要求电压差足够大,极性方向由带符号 slope 自动适配 // (能斯特 E=E°-S·pH 下 V9V7,二者皆合法) float diff = voltageAtPh9_ - voltageAtPh7_; float slope = diff / (PH_BUFFER_9 - PH_BUFFER_7); Serial.printf("Effective slope: %.2f mV/pH\n", slope); ``` 换算时带符号 slope 直接代回,标准电极和反相模块都算对: ```cpp float measuredSlope = (voltageAtPh9_ - voltageAtPh7_) / (PH_BUFFER_9 - PH_BUFFER_7); float slope = measuredSlope * (temperatureC + 273.15f) / 298.15f; // 温度补偿 return PH_BUFFER_7 + (voltageMv - voltageAtPh7_) / slope; ``` `MIN_CALIBRATION_DIFF_MV` 差值下限同时保证了无除零风险。输出的 **有效斜率绝对值** `|V9 - V7| / (9.18 - 7.00)` 是判断电极健康的核心指标——新鲜电极接近 59 mV/pH,衰减后明显偏小,用户能一眼看出要不要换电极。 **校准值带电压域版本号**。校准值是以"某套 ADC 换算口径"为基准的电压,一旦口径升级(见下节 c),旧值与新读数不可比,直接沿用会产生"假的方向校验误报"。因此校准值在 Flash 里带 `CALIBRATION_DOMAIN_VERSION`,加载时校验版本号,不一致就作废重来: ```cpp int ver = local.getInt(PREF_KEY_CAL_VER, 0); voltageAtPh7_ = local.getFloat(PREF_KEY_CAL_PH7, NAN); voltageAtPh9_ = local.getFloat(PREF_KEY_CAL_PH9, NAN); if (ver != CALIBRATION_DOMAIN_VERSION) { voltageAtPh7_ = NAN; // 跨域残留值作废,重新校准 voltageAtPh9_ = NAN; } ``` ### c. eFuse 校准电压 + 全链路同源 这是两个容易踩的坑,项目里都专门修过。 **坑一:raw 与 mV 不同源。** 早期的写法是 `raw` 来自 N 次中值滤波,`voltageMv` 另调一次 `analogReadMilliVolts()`——**两个不同的采样时刻**,日志里的 raw 和 mV 对不上。 **坑二:线性换算 `raw × 3300/4095` 不准。** ESP32 的 ADC 参考电压个体有偏差,衰减通道还有非线性,直接按 12 位满量程 3300 mV 线性换算,每颗芯片的读数都带一个系统偏差。 现在的做法:采集端直接用 `analogReadMilliVolts()`——它内部走 ESP32 **eFuse 出厂校准**(`adc_cal_characterize`),修正 Vref 偏差与衰减曲线非线性,比线性换算更接近真实电压;**数据池只存这个校准后的 mV,全链路不再出现 raw**: ```cpp int32_t PhSensor::adcReadMedianMv(int pin) { // analogReadMilliVolts:经 ESP32 eFuse 出厂校准换算(修正 Vref 偏差 // 与衰减曲线非线性),比 raw × 3300/4095 线性换算更接近真实电压 int32_t samples[PH_ADC_READ_PER_SAMPLE]; for (int i = 0; i < PH_ADC_READ_PER_SAMPLE; i++) { samples[i] = static_cast(analogReadMilliVolts(pin)); delayMicroseconds(PH_ADC_READ_DELAY_US); } std::sort(samples, samples + PH_ADC_READ_PER_SAMPLE); return samples[PH_ADC_READ_PER_SAMPLE / 2]; // 5 次取中值,抗偶发毛刺 } ``` `readSample()` 里 `adcMv` 与 `voltageMv` 直接同源(mV 取整 vs 浮点形式),SD 日志的 `adc_mv` 和 `voltage_mV` 两列永远能互相推导;校准值、换算、日志共用同一个电压域——这也是为什么 ADC 换算口径一旦变更,旧校准值必须靠 b 节的电压域版本号作废重来。 ### d. 4 sprite 局部刷新 M5Stack Core2 屏幕 320×240。全屏重绘既耗电又会有残影,所以用 4 个 sprite 只重绘变化的区域: | sprite | 尺寸 | 位置 | 内容 | |---|---|---|---| | statusBarCanvas_ | 320×28 | (0,0) | 日期/时间/录制状态 | | measurementCanvas_ | 320×55 | (0,88) | pH / ADC(mV) / mV | | popupCanvas_ | 280×40 | 居中 (20,150) | 弹窗,2s 自动消失 | | controlCanvas_ | 320×55 | (0,198) | START / STOP / CAL 按钮 | 每个 sprite 先 `clear(TFT_BLACK)`,自己画完后 `pushSprite` 推到对应屏幕位置: ```cpp void DisplayManager::updateMeasurementArea(float ph, int adcMv, float voltageMv, bool calibrated) { drawMeasurementSprite(ph, adcMv, voltageMv, calibrated); measurementCanvas_.pushSprite(&M5.Display, 0, 88); } ``` **弹窗机制**——`showMessage()` 写入 `popupCanvas_` 并记下时间,`updatePopupSprite()` 每个显示周期检查过期: ```cpp void DisplayManager::showMessage(const String &msg, uint16_t color) { popupMsg_ = msg; popupColor_ = color; popupStartTime_ = millis(); updatePopupSprite(); } void DisplayManager::updatePopupSprite() { bool shouldShow = (popupStartTime_ != 0 && (millis() - popupStartTime_) < POPUP_DURATION_MS); if (shouldShow) { // 画带边框的圆角弹窗并 pushSprite popupCanvas_.pushSprite(&M5.Display, POPUP_X, POPUP_Y); } else if (popupStartTime_ != 0) { // 过期: 清除区域恢复黑色背景 popupStartTime_ = 0; M5.Display.fillRect(POPUP_X, POPUP_Y, POPUP_W, POPUP_H, TFT_BLACK); } } ``` ### e. FreeRTOS task 与主线程的协同 采样 task 入口非常简单,就是一个无限循环: ```cpp void PhSensor::taskEntry(void *arg) { PhSensor *sensor = static_cast(arg); while (true) { sensor->runOneSample(); vTaskDelay(PH_SAMPLE_PERIOD_MS / portTICK_PERIOD_MS); // 50ms } } ``` 每周期 5 次 ADC 中值 → 覆盖池中最旧槽,**不占用任务时间的聚合逻辑**。主线程 `loop` 只通过 `readSample()` 和 getter 从池子聚合数据,不直读成员,互斥锁是双方唯一的屏障: ```cpp void PhSensor::init() { analogSetAttenuation(ADC_11db); // ADC 衰减档须在任何 analogRead 前设置 sampleMutex_ = xSemaphoreCreateMutex(); loadCalibration(); // 读校准值(含电压域版本校验,见 b 节) } ``` 这一套下来,两个核心互不阻塞:Core 0 持续喂池,Core 1 专注于显示和命令,靠一把锁同步共享数据。 --- ## 收尾 硬件上花小钱,代码里花心思。一个 pH 记录仪的价值不在屏幕亮不亮,在三年后你还能读到 2023 年某天的那组数据。