无源管道流体脉动采集装置(ESP32‑C5 + SEN0209)

2026-10-102

管道内部流体流动会产生压力脉动,脉动信号包含流量变化、湍流、振动、泵体异常等特征。传统管道流体监测设备体积大、成本高,本项目使用ESP32‑C5搭配SEN0209振动传感器,以800Hz固定采样频率采集管道壁振动信号,通过滑动平均滤波与快速傅里叶变换(FFT)把时域振动波形转换成频谱,再通过内置Web网页实时展示波形,支持最多8组历史工况本地保存对比,实现低成本无源式管道流体脉动在线采集与特征分析。

 

-“无源”含义:本装置只贴附在管道外壁,不需要开孔、不需要侵入管道内部,依靠拾取管道壁传导的流体振动信号完成监测,属于非侵入式采集。

 

创作环境

 

硬件

ESP32‑C5开发板、SEN0209压电振动传感器、USB 5V供电

接线:SEN0209 → VCC接3.3V,GND接GND,AO模拟输出接入ESP32‑C5 GPIO4,传感器紧贴管道外壁,实现非侵入式采集管壁传导的流体振动信号。

无源管道流体脉动采集装置(ESP32‑C5 + SEN0209)_image_1.webp

选择本产品的原因:

柔性PVDF压电薄膜,贴合曲面管道更友好;带数字/模拟双输出、灵敏度调节电位器,内置电荷放大模块,频率响应范围

 

软件

Arduino IDE 2.x,esp32板包≥3.0.0,依赖库:arduinoFFT;通信方式:ESP32内置WebServer,浏览器Chart.js绘图,LocalStorage本地存储最多8组工况。

 

核心参数:采样频率800Hz,单帧256点,频率分辨率3.125Hz,5点滑动平均,汉明窗FFT。

 

主要原理介绍

 

管道内部流体产生的压力脉动会传递到管道外壁,SEN0209压电传感器将管壁机械振动转换成模拟电压信号;ESP32‑C5使用MCPWM硬件定时器产生稳定800Hz定时中断,采集ADC信号,经过滑动平均降噪,再用FFT将时域波形转为频谱,提取流体脉动的主要特征频率。
网页端可以实时看到时域波形与频谱,支持多工况频谱叠加对比,同时增加硬件启停控制,网页点暂停时,ESP32会关闭MCPWM定时器,停止采样,降低主控负载。

 

代码编写

 

第1步:引入头文件、定义基础配置

 

首先导入WiFi、WebServer、FFT库以及MCPWM驱动,定义硬件引脚、采样频率、FFT点数,预留全局数组用来存放原始采样、滤波后、FFT虚部实部数据。

 

#include <WiFi.h>
#include <WebServer.h>
#include <arduinoFFT.h>
#include "driver/mcpwm.h"
#include "freertos/portmacro.h"

// ========= 用户配置区 =========
const char* WIFI_SSID = "你的WiFi名称";
const char* WIFI_PASS = "你的WiFi密码";
#define SEN0209_PIN     4
#define SAMPLING_FREQ   800
#define FFT_N           256
#define FILTER_SIZE     5
// ==============================

WebServer server(80);
arduinoFFT FFT = arduinoFFT();

double vReal[FFT_N];
double vImag[FFT_N];
double rawBuf[FFT_N];
double filterBuf[FFT_N];
float ADC_MID = 2048.0f;
uint32_t frameId = 0;

volatile bool adcSampleRequest = false;
volatile bool frameReady = false;
volatile bool processingReady = false;
int sampleIndex = 0;
bool samplingEnabled = false;

 

要点: volatile  标记中断中会修改的变量,防止编译器优化掉; samplingEnabled  就是后面用来控制硬件启停的总开关。

 

第2步:MCPWM定时中断 + 采样启停函数

 

MCPWM产生稳定800Hz定时,中断里面只置标记,不在中断里面读取ADC,避免中断里面执行耗时操作;新增startSampling / stopSampling,用来启动、关闭MCPWM硬件定时器,同时复位采样状态。

 

void IRAM_ATTR mcpwmIsrHandler(void *arg)
{
  adcSampleRequest = true;
  portYIELD_FROM_ISR(pdFALSE);
}

void initMcpwmTimer(uint32_t sampleFreq)
{
  mcpwm_config_t mcpwmCfg = {
    .frequency = sampleFreq,
    .duty_cycle = 50.0f,
    .counter_mode = MCPWM_UP_COUNTER,
    .carrier_mode = MCPWM_CARRIER_DISABLE
  };
  mcpwm_init(MCPWM_UNIT_0, MCPWM_TIMER_0, &mcpwmCfg);
  mcpwm_isr_register(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, mcpwmIsrHandler, NULL);
  Serial.printf("[MCPWM] Timer config done, sampling freq: %d Hz\n", sampleFreq);
}

void startSampling()
{
  if(!samplingEnabled)
  {
    mcpwm_start(MCPWM_UNIT_0, MCPWM_TIMER_0);
    samplingEnabled = true;
    Serial.println("[Sampling] START");
  }
}

void stopSampling()
{
  if(samplingEnabled)
  {
    mcpwm_stop(MCPWM_UNIT_0, MCPWM_TIMER_0);
    samplingEnabled = false;
    adcSampleRequest = false;
    frameReady = false;
    processingReady = false;
    sampleIndex = 0;
    Serial.println("[Sampling] STOP");
  }
}

 

第3步:信号预处理函数(滑动平均滤波 + ADC零点标定)

 

SEN0209静态时输出一个固定基线电压,ADC零点标定用来自动测出基线,后续采样减去基线,把静态值归零;5点滑动平均用来抑制ADC高频随机噪声。

 

double slidingFilter(double *src, int idx)
{
  double sum = 0;
  int cnt = 0;
  for(int k = idx - ((FILTER_SIZE-1)/2); k <= idx + ((FILTER_SIZE-1)/2); k++)
  {
    if(k >= 0 && k < FFT_N)
    {
      sum += src[k];
      cnt++;
    }
  }
  return sum / cnt;
}

void calibrateADC()
{
  Serial.println("[Calibrate] Start ADC zero calibration, keep SEN0209 static...");
  float sum = 0.0f;
  for(int i = 0; i < 30; i++)
  {
    sum += analogRead(SEN0209_PIN);
    delay(10);
  }
  ADC_MID = sum / 30.0f;
  Serial.printf("[Calibrate] ADC mid value = %.2f\n", ADC_MID);
}

 

第4步:ADC采集与FFT运算

 

hardwareSampleTask  在主循环执行,当MCPWM中断标记到来时读取ADC,填满256点一整帧后标记  frameReady=true ; processFFTFrame  执行滑动平均、汉明窗、FFT正向计算、频谱幅值归一化,完成一帧频域转换。

 

void hardwareSampleTask()
{
  if(!samplingEnabled) return;
  if(adcSampleRequest)
  {
    adcSampleRequest = false;
    if(sampleIndex < FFT_N)
    {
      rawBuf[sampleIndex] = analogRead(SEN0209_PIN) - ADC_MID;
      sampleIndex++;
      if(sampleIndex >= FFT_N)
      {
        frameReady = true;
        sampleIndex = 0;
      }
    }
  }
}

void processFFTFrame()
{
  for(int i = 0; i < FFT_N; i++)
  {
    filterBuf[i] = slidingFilter(rawBuf, i);
    vReal[i] = filterBuf[i];
    vImag[i] = 0;
  }
  FFT.Windowing(vReal, FFT_N, FFT_WIN_TYP_HAMMING, FFT_FORWARD);
  FFT.Compute(vReal, vImag, FFT_N, FFT_FORWARD);
  FFT.ComplexToMagnitude(vReal, vImag, FFT_N);
  for(int i = 0; i < FFT_N/2; i++)
  {
    vReal[i] = vReal[i] / (FFT_N / 2.0);
  }
  frameId++;
  processingReady = true;
}

 

第5步:辅助函数,提取主频、拼接JSON

 

getTop3FreqString  遍历频谱数组,找到幅值最大前3个频率作为管道脉动特征主频; getTimeJSON  /  getFftJSON  将时域、频谱数组转为JSON字符串,用于网页传输。

 

String getTop3FreqString()
{
  double freqRes = (double)SAMPLING_FREQ / FFT_N;
  static double val[FFT_N/2];
  static int idxList[3];
  for(int i = 0; i < FFT_N/2; i++) val[i] = vReal[i];
  for(int n = 0; n < 3; n++)
  {
    double maxV = -1;
    int pos = 0;
    for(int i = 0; i < FFT_N/2; i++)
    {
      if(val[i] > maxV)
      {
        maxV = val[i];
        pos = i;
      }
    }
    idxList[n] = pos;
    val[pos] = -99999;
  }
  char buf[128];
  snprintf(buf, sizeof(buf), "%.2f Hz, %.2f Hz, %.2f Hz",
    idxList[0] * freqRes,
    idxList[1] * freqRes,
    idxList[2] * freqRes
  );
  return String(buf);
}

String getTimeJSON()
{
  String s = "[";
  for(int i = 0; i < FFT_N; i++)
  {
    s += String(filterBuf[i]);
    if(i != FFT_N -1) s += ",";
  }
  s += "]";
  return s;
}

String getFftJSON()
{
  String s = "[";
  for(int i = 0; i < FFT_N/2; i++)
  {
    s += String(vReal[i]);
    if(i != (FFT_N/2)-1) s += ",";
  }
  s += "]";
  return s;
}

 

第6步:Web路由、内嵌前端网页

 

定义Web服务路由:

  •  /  返回完整前端网页,包含Chart.js绘图、工况保存、启停按钮
  • ​/data  返回最新一帧时域、频谱、前3主频JSON数据
  • ​/start 、 /stop  接收前端指令,调用上面  startSampling()  /  stopSampling() ,实现硬件启停同步

 

void handleRoot()
{
  String html = R"HTML(
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>管道流体脉动采集 SEN0209</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body{font-family:system-ui;margin:12px;}
.btn{padding:8px 14px;margin:4px;cursor:pointer}
.line{margin:8px 0;}
</style>
</head>
<body>
<h2>无源管道流体脉动采集装置 ESP32-C5 + SEN0209</h2>
<div class="line"><button class="btn" id="btnToggle" onclick="toggleCollect()">开始采集</button></div>
<div class="line">
  <button class="btn" onclick="saveCurrent()">保存当前工况</button>
  <button class="btn" onclick="clearSaved()">清空已保存工况</button>
</div>
<div class="line">
  <button class="btn" onclick="clearAllCanvas()">清空全部界面曲线</button>
  <button class="btn" onclick="clearUnsaved()">清空未保存曲线</button>
</div>
<div id="topFreq">特征频率:等待采集</div>
<div style="height:320px"><canvas id="timeChart"></canvas></div>
<div style="height:320px"><canvas id="fftChart"></canvas></div>
<script>
const timeCtx = document.getElementById('timeChart').getContext('2d');
const fftCtx = document.getElementById('fftChart').getContext('2d');
const freqRes = 3.1250;
const sampleDt = 0.001250;
const colorList = ['#16a34a','#f59e0b','#8b5cf6','#06b6d4','#f43f5e','#64748b','#84cc16','#ec4899'];
const MAX_CASE = 8;
let lastFrameId = -1;
let timeChart = new Chart(timeCtx, {type:'line',data:{labels:[],datasets:[{label:'振动时域',data:[],borderColor:'#2563eb',fill:false,tension:0.1}]},options:{responsive:true,maintainAspectRatio:false,scales:{x:{title:{display:true,text:'时间 s'}},y:{title:{display:true,text:'ADC偏移值'}}}}});
let fftChart = new Chart(fftCtx, {type:'line',data:{labels:[],datasets:[{label:'实时频谱',data:[],borderColor:'#db2777',fill:false,tension:0.1}]},options:{responsive:true,maintainAspectRatio:false,scales:{x:{title:{display:true,text:'频率 Hz'}},y:{title:{display:true,text:'归一化幅值'}}}}});
function loadSavedCases(){const raw = localStorage.getItem("vibrationSavedCases");if(raw){try{return JSON.parse(raw);}catch(e){localStorage.removeItem("vibrationSavedCases");alert("⚠️ 历史工况损坏,已清空");}}return [];}
function saveCasesToLocalStorage(arr){localStorage.setItem("vibrationSavedCases", JSON.stringify(arr));}
let savedDatasets = loadSavedCases();
for(let k=0; k < savedDatasets.length; k++){fftChart.data.datasets.push({label:'工况'+(k+1),data:savedDatasets[k],borderColor:colorList[k],fill:false,tension:0.1});}fftChart.update();
let isFetching = false;let isRunning = false;let timerId = null;
async function fetchData(){const r = await fetch("/data");const j = await r.json();return j;}
async function espStart(){await fetch("/start");}
async function espStop(){await fetch("/stop");}
async function collectLoop(){if(!isRunning) return;if(isFetching) return;isFetching = true;try{const d = await fetchData();if(d.frameId === lastFrameId){isFetching = false;timerId = setTimeout(collectLoop,350);return;}lastFrameId = d.frameId;const tLabels = d.time.map((v,i)=> (i*sampleDt).toFixed(4));timeChart.data.labels = tLabels;timeChart.data.datasets[0].data = d.time;timeChart.update('none');const fLabels = d.fft.map((v,i)=>(i*freqRes).toFixed(2));fftChart.data.labels = fLabels;fftChart.data.datasets[0].data = d.fft;for(let k=0;k<savedDatasets.length;k++){if(fftChart.data.datasets[k+1]){fftChart.data.datasets[k+1].data = savedDatasets[k];}}fftChart.update('none');document.getElementById('topFreq').innerText = "特征频率前3:"+d.top3;}catch(e){console.error(e);}isFetching = false;timerId = setTimeout(collectLoop,350);}
async function toggleCollect(){const btn = document.getElementById("btnToggle");if(!isRunning){await espStart();isRunning = true;btn.innerText = "暂停采集";collectLoop();}else{await espStop();isRunning = false;clearTimeout(timerId);btn.innerText = "开始采集";}}
function saveCurrent(){if(savedDatasets.length >= MAX_CASE){alert("最多保存8组工况");return;}const currentFftData = fftChart.data.datasets[0].data;if (!currentFftData || currentFftData.length === 0){alert("实时曲线为空,请先采集");return;}const validData = currentFftData.filter(x=>!isNaN(x));if(validData.length < currentFftData.length * 0.8){alert("频谱有效数据过少,无法保存");return;}const copyData = [...validData];savedDatasets.push(copyData);saveCasesToLocalStorage(savedDatasets);const idx = savedDatasets.length - 1;fftChart.data.datasets.push({label: '工况'+(idx+1),data: copyData,borderColor: colorList[idx],fill:false,tension:0.1});fftChart.update();alert("✅ 已保存工况,共"+savedDatasets.length+"/8");}
function clearSaved(){savedDatasets = [];saveCasesToLocalStorage(savedDatasets);fftChart.data.datasets = [ fftChart.data.datasets[0] ];fftChart.update();alert("已清空浏览器内保存工况");}
function clearAllCanvas(){savedDatasets = [];saveCasesToLocalStorage(savedDatasets);timeChart.data.labels = [];timeChart.data.datasets[0].data = [];timeChart.update();fftChart.data.labels = [];fftChart.data.datasets = [ fftChart.data.datasets[0] ];fftChart.data.datasets[0].data = [];fftChart.update();document.getElementById('topFreq').innerText = "特征频率:等待采集";alert("全部曲线清空");}
function clearUnsaved(){timeChart.data.datasets[0].data = [];timeChart.update();if(fftChart.data.datasets.length > 0){fftChart.data.datasets[0].data = [];}fftChart.update();document.getElementById('topFreq').innerText = "特征频率:等待采集";alert("仅清空实时曲线,历史工况保留");}
</script>
</body>
</html>
)HTML";
  server.send(200, "text/html", html);
}

void handleData()
{
  if(processingReady)
  {
    String json = "{";
    json += "\"frameId\":" + String(frameId) + ",";
    json += "\"time\":" + getTimeJSON() + ",";
    json += "\"fft\":" + getFftJSON() + ",";
    json += "\"top3\":\"" + getTop3FreqString() + "\"";
    json += "}";
    server.send(200, "application/json", json);
    processingReady = false;
  }
  else
  {
    server.send(200, "application/json", "{\"frameId\":0,\"time\":[],\"fft\":[],\"top3\":\"等待计算\"}");
  }
}

void handleStart()
{
  startSampling();
  server.send(200, "text/plain", "ok");
}

void handleStop()
{
  stopSampling();
  server.send(200, "text/plain", "ok");
}

 

第7步:setup初始化 + loop主循环

 

setup里面依次执行串口初始化、ADC配置、WiFi连接、ADC零点标定、MCPWM定时器初始化、注册全部Web路由,启动WebServer;loop循环里面持续处理HTTP请求,执行ADC采集任务,当一帧采集满后执行FFT运算。

 

void setup()
{
  Serial.begin(115200);
  analogSetAttenuation(ADC_11db);
  pinMode(SEN0209_PIN, INPUT);

  Serial.printf("Connecting WiFi: %s\n", WIFI_SSID);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while(WiFi.status() != WL_CONNECTED)
  {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");
  Serial.print("ESP32-C5 IP: ");
  Serial.println(WiFi.localIP());

  calibrateADC();
  initMcpwmTimer(SAMPLING_FREQ);

  server.on("/", handleRoot);
  server.on("/data", handleData);
  server.on("/start", handleStart);
  server.on("/stop", handleStop);
  server.begin();
  Serial.println("Web server started");
}

void loop()
{
  server.handleClient();
  hardwareSampleTask();
  if(frameReady)
  {
    processFFTFrame();
    frameReady = false;
  }
}

 

完整代码

 

#include <WiFi.h>
#include <WebServer.h>
#include <arduinoFFT.h>
#include "driver/mcpwm.h"
#include "freertos/portmacro.h"

// ========= 用户配置区 =========
const char* WIFI_SSID = "你的WiFi名称";
const char* WIFI_PASS = "你的WiFi密码";
#define SEN0209_PIN     4
#define SAMPLING_FREQ   800
#define FFT_N           256
#define FILTER_SIZE     5
// ==============================

WebServer server(80);
arduinoFFT FFT = arduinoFFT();

double vReal[FFT_N];
double vImag[FFT_N];
double rawBuf[FFT_N];
double filterBuf[FFT_N];
float ADC_MID = 2048.0f;
uint32_t frameId = 0;

volatile bool adcSampleRequest = false;
volatile bool frameReady = false;
volatile bool processingReady = false;
int sampleIndex = 0;
bool samplingEnabled = false;

void IRAM_ATTR mcpwmIsrHandler(void *arg)
{
  adcSampleRequest = true;
  portYIELD_FROM_ISR(pdFALSE);
}

void initMcpwmTimer(uint32_t sampleFreq)
{
  mcpwm_config_t mcpwmCfg = {
    .frequency = sampleFreq,
    .duty_cycle = 50.0f,
    .counter_mode = MCPWM_UP_COUNTER,
    .carrier_mode = MCPWM_CARRIER_DISABLE
  };
  mcpwm_init(MCPWM_UNIT_0, MCPWM_TIMER_0, &mcpwmCfg);
  mcpwm_isr_register(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, mcpwmIsrHandler, NULL);
  Serial.printf("[MCPWM] Timer config done, sampling freq: %d Hz\n", sampleFreq);
}

void startSampling()
{
  if(!samplingEnabled)
  {
    mcpwm_start(MCPWM_UNIT_0, MCPWM_TIMER_0);
    samplingEnabled = true;
    Serial.println("[Sampling] START");
  }
}

void stopSampling()
{
  if(samplingEnabled)
  {
    mcpwm_stop(MCPWM_UNIT_0, MCPWM_TIMER_0);
    samplingEnabled = false;
    adcSampleRequest = false;
    frameReady = false;
    processingReady = false;
    sampleIndex = 0;
    Serial.println("[Sampling] STOP");
  }
}

double slidingFilter(double *src, int idx)
{
  double sum = 0;
  int cnt = 0;
  for(int k = idx - ((FILTER_SIZE-1)/2); k <= idx + ((FILTER_SIZE-1)/2); k++)
  {
    if(k >= 0 && k < FFT_N)
    {
      sum += src[k];
      cnt++;
    }
  }
  return sum / cnt;
}

void calibrateADC()
{
  Serial.println("[Calibrate] Start ADC zero calibration, keep SEN0209 static...");
  float sum = 0.0f;
  for(int i = 0; i < 30; i++)
  {
    sum += analogRead(SEN0209_PIN);
    delay(10);
  }
  ADC_MID = sum / 30.0f;
  Serial.printf("[Calibrate] ADC mid value = %.2f\n", ADC_MID);
}

void hardwareSampleTask()
{
  if(!samplingEnabled) return;
  if(adcSampleRequest)
  {
    adcSampleRequest = false;
    if(sampleIndex < FFT_N)
    {
      rawBuf[sampleIndex] = analogRead(SEN0209_PIN) - ADC_MID;
      sampleIndex++;
      if(sampleIndex >= FFT_N)
      {
        frameReady = true;
        sampleIndex = 0;
      }
    }
  }
}

void processFFTFrame()
{
  for(int i = 0; i < FFT_N; i++)
  {
    filterBuf[i] = slidingFilter(rawBuf, i);
    vReal[i] = filterBuf[i];
    vImag[i] = 0;
  }
  FFT.Windowing(vReal, FFT_N, FFT_WIN_TYP_HAMMING, FFT_FORWARD);
  FFT.Compute(vReal, vImag, FFT_N, FFT_FORWARD);
  FFT.ComplexToMagnitude(vReal, vImag, FFT_N);
  for(int i = 0; i < FFT_N/2; i++)
  {
    vReal[i] = vReal[i] / (FFT_N / 2.0);
  }
  frameId++;
  processingReady = true;
}

String getTop3FreqString()
{
  double freqRes = (double)SAMPLING_FREQ / FFT_N;
  static double val[FFT_N/2];
  static int idxList[3];
  for(int i = 0; i < FFT_N/2; i++) val[i] = vReal[i];
  for(int n = 0; n < 3; n++)
  {
    double maxV = -1;
    int pos = 0;
    for(int i = 0; i < FFT_N/2; i++)
    {
      if(val[i] > maxV)
      {
        maxV = val[i];
        pos = i;
      }
    }
    idxList[n] = pos;
    val[pos] = -99999;
  }
  char buf[128];
  snprintf(buf, sizeof(buf), "%.2f Hz, %.2f Hz, %.2f Hz",
    idxList[0] * freqRes,
    idxList[1] * freqRes,
    idxList[2] * freqRes
  );
  return String(buf);
}

String getTimeJSON()
{
  String s = "[";
  for(int i = 0; i < FFT_N; i++)
  {
    s += String(filterBuf[i]);
    if(i != FFT_N -1) s += ",";
  }
  s += "]";
  return s;
}

String getFftJSON()
{
  String s = "[";
  for(int i = 0; i < FFT_N/2; i++)
  {
    s += String(vReal[i]);
    if(i != (FFT_N/2)-1) s += ",";
  }
  s += "]";
  return s;
}

void handleRoot()
{
  String html = R"HTML(
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>管道流体脉动采集 SEN0209</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body{font-family:system-ui;margin:12px;}
.btn{padding:8px 14px;margin:4px;cursor:pointer}
.line{margin:8px 0;}
</style>
</head>
<body>
<h2>无源管道流体脉动采集装置 ESP32-C5 + SEN0209</h2>
<div class="line"><button class="btn" id="btnToggle" onclick="toggleCollect()">开始采集</button></div>
<div class="line">
  <button class="btn" onclick="saveCurrent()">保存当前工况</button>
  <button class="btn" onclick="clearSaved()">清空已保存工况</button>
</div>
<div class="line">
  <button class="btn" onclick="clearAllCanvas()">清空全部界面曲线</button>
  <button class="btn" onclick="clearUnsaved()">清空未保存曲线</button>
</div>
<div id="topFreq">特征频率:等待采集</div>
<div style="height:320px"><canvas id="timeChart"></canvas></div>
<div style="height:320px"><canvas id="fftChart"></canvas></div>
<script>
const timeCtx = document.getElementById('timeChart').getContext('2d');
const fftCtx = document.getElementById('fftChart').getContext('2d');
const freqRes = 3.1250;
const sampleDt = 0.001250;
const colorList = ['#16a34a','#f59e0b','#8b5cf6','#06b6d4','#f43f5e','#64748b','#84cc16','#ec4899'];
const MAX_CASE = 8;
let lastFrameId = -1;
let timeChart = new Chart(timeCtx, {type:'line',data:{labels:[],datasets:[{label:'振动时域',data:[],borderColor:'#2563eb',fill:false,tension:0.1}]},options:{responsive:true,maintainAspectRatio:false,scales:{x:{title:{display:true,text:'时间 s'}},y:{title:{display:true,text:'ADC偏移值'}}}}});
let fftChart = new Chart(fftCtx, {type:'line',data:{labels:[],datasets:[{label:'实时频谱',data:[],borderColor:'#db2777',fill:false,tension:0.1}]},options:{responsive:true,maintainAspectRatio:false,scales:{x:{title:{display:true,text:'频率 Hz'}},y:{title:{display:true,text:'归一化幅值'}}}}});
function loadSavedCases(){const raw = localStorage.getItem("vibrationSavedCases");if(raw){try{return JSON.parse(raw);}catch(e){localStorage.removeItem("vibrationSavedCases");alert("⚠️ 历史工况损坏,已清空");}}return [];}
function saveCasesToLocalStorage(arr){localStorage.setItem("vibrationSavedCases", JSON.stringify(arr));}
let savedDatasets = loadSavedCases();
for(let k=0; k < savedDatasets.length; k++){fftChart.data.datasets.push({label:'工况'+(k+1),data:savedDatasets[k],borderColor:colorList[k],fill:false,tension:0.1});}fftChart.update();
let isFetching = false;let isRunning = false;let timerId = null;
async function fetchData(){const r = await fetch("/data");const j = await r.json();return j;}
async function espStart(){await fetch("/start");}
async function espStop(){await fetch("/stop");}
async function collectLoop(){if(!isRunning) return;if(isFetching) return;isFetching = true;try{const d = await fetchData();if(d.frameId === lastFrameId){isFetching = false;timerId = setTimeout(collectLoop,350);return;}lastFrameId = d.frameId;const tLabels = d.time.map((v,i)=> (i*sampleDt).toFixed(4));timeChart.data.labels = tLabels;timeChart.data.datasets[0].data = d.time;timeChart.update('none');const fLabels = d.fft.map((v,i)=>(i*freqRes).toFixed(2));fftChart.data.labels = fLabels;fftChart.data.datasets[0].data = d.fft;for(let k=0;k<savedDatasets.length;k++){if(fftChart.data.datasets[k+1]){fftChart.data.datasets[k+1].data = savedDatasets[k];}}fftChart.update('none');document.getElementById('topFreq').innerText = "特征频率前3:"+d.top3;}catch(e){console.error(e);}isFetching = false;timerId = setTimeout(collectLoop,350);}
async function toggleCollect(){const btn = document.getElementById("btnToggle");if(!isRunning){await espStart();isRunning = true;btn.innerText = "暂停采集";collectLoop();}else{await espStop();isRunning = false;clearTimeout(timerId);btn.innerText = "开始采集";}}
function saveCurrent(){if(savedDatasets.length >= MAX_CASE){alert("最多保存8组工况");return;}const currentFftData = fftChart.data.datasets[0].data;if (!currentFftData || currentFftData.length === 0){alert("实时曲线为空,请先采集");return;}const validData = currentFftData.filter(x=>!isNaN(x));if(validData.length < currentFftData.length * 0.8){alert("频谱有效数据过少,无法保存");return;}const copyData = [...validData];savedDatasets.push(copyData);saveCasesToLocalStorage(savedDatasets);const idx = savedDatasets.length - 1;fftChart.data.datasets.push({label: '工况'+(idx+1),data: copyData,borderColor: colorList[idx],fill:false,tension:0.1});fftChart.update();alert("✅ 已保存工况,共"+savedDatasets.length+"/8");}
function clearSaved(){savedDatasets = [];saveCasesToLocalStorage(savedDatasets);fftChart.data.datasets = [ fftChart.data.datasets[0] ];fftChart.update();alert("已清空浏览器内保存工况");}
function clearAllCanvas(){savedDatasets = [];saveCasesToLocalStorage(savedDatasets);timeChart.data.labels = [];timeChart.data.datasets[0].data = [];timeChart.update();fftChart.data.labels = [];fftChart.data.datasets = [ fftChart.data.datasets[0] ];fftChart.data.datasets[0].data = [];fftChart.update();document.getElementById('topFreq').innerText = "特征频率:等待采集";alert("全部曲线清空");}
function clearUnsaved(){timeChart.data.datasets[0].data = [];timeChart.update();if(fftChart.data.datasets.length > 0){fftChart.data.datasets[0].data = [];}fftChart.update();document.getElementById('topFreq').innerText = "特征频率:等待采集";alert("仅清空实时曲线,历史工况保留");}
</script>
</body>
</html>
)HTML";
  server.send(200, "text/html", html);
}

void handleData()
{
  if(processingReady)
  {
    String json = "{";
    json += "\"frameId\":" + String(frameId) + ",";
    json += "\"time\":" + getTimeJSON() + ",";
    json += "\"fft\":" + getFftJSON() + ",";
    json += "\"top3\":\"" + getTop3FreqString() + "\"";
    json += "}";
    server.send(200, "application/json", json);
    processingReady = false;
  }
  else
  {
    server.send(200, "application/json", "{\"frameId\":0,\"time\":[],\"fft\":[],\"top3\":\"等待计算\"}");
  }
}

void handleStart()
{
  startSampling();
  server.send(200, "text/plain", "ok");
}

void handleStop()
{
  stopSampling();
  server.send(200, "text/plain", "ok");
}

void setup()
{
  Serial.begin(115200);
  analogSetAttenuation(ADC_11db);
  pinMode(SEN0209_PIN, INPUT);

  Serial.printf("Connecting WiFi: %s\n", WIFI_SSID);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while(WiFi.status() != WL_CONNECTED)
  {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");
  Serial.print("ESP32-C5 IP: ");
  Serial.println(WiFi.localIP());

  calibrateADC();
  initMcpwmTimer(SAMPLING_FREQ);

  server.on("/", handleRoot);
  server.on("/data", handleData);
  server.on("/start", handleStart);
  server.on("/stop", handleStop);
  server.begin();
  Serial.println("Web server started");
}

void loop()
{
  server.handleClient();
  hardwareSampleTask();
  if(frameReady)
  {
    processFFTFrame();
    frameReady = false;
  }
}

 

用户操作流程

  1. 硬件接线:SEN0209接ESP32‑C5 3.3V、GND、GPIO4,传感器紧贴待测管道外壁
  2. ​修改代码WiFi参数:填入你的2.4G WiFi名称与密码,选择ESP32C5开发板编译上传
  3. ​上电标定:上电保持传感器静止,等待ADC零点标定,串口监视器记录ESP32局域网IP
  4. ​网页访问:手机/电脑连同一局域网,浏览器输入ESP32‑C5 IP打开项目页面
  5. ​开始采集:点击【开始采集】,ESP32启动MCPWM硬件采样,页面展示时域波形、FFT频谱与前3个特征频率
  6. ​工况对比:频谱稳定时点击【保存当前工况】,最多8组历史频谱叠加,对比不同管道工况差异
  7. ​曲线管理:按需选择清空已保存工况 / 清空全部曲线 / 仅清空实时曲线
  8. ​暂停实验:点击【暂停采集】,ESP32关闭MCPWM定时器,停止ADC采集和FFT运算,降低主控负载,再次点击恢复采集

 

展示与技巧

点击开始时开始采集数据

无源管道流体脉动采集装置(ESP32‑C5 + SEN0209)_image_2.webp

可以随时点击暂停查看数据

保存工况后可通过点击图标来隐藏多余曲线

无源管道流体脉动采集装置(ESP32‑C5 + SEN0209)_image_3.webp

无源管道流体脉动采集装置(ESP32‑C5 + SEN0209)_image_4.webp

还可以通过第三行清空按钮来进行数据清理

无源管道流体脉动采集装置(ESP32‑C5 + SEN0209)_image_5.webp

 

装置布置

无源管道流体脉动采集装置(ESP32‑C5 + SEN0209)_image_6.webp

 

本套装置主要适用范围

例:

教学科创实验:课堂/科创比赛,演示流体脉动、管壁振动、FFT频谱分析原理

 

工业现场做初步筛查、对比试验,但不建议直接当作工业计量、安全联锁的正式测量设备。

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -