DIY 物联网电源监测器
使用 CT 传感器监测交流负载
引言
通常,有些交流负载会根据某些条件自动运行。例如空间加热器、熔炉、热水器、水泵或其他机械设备。
这些设备通常不提供任何关于其能耗或运行时间表的信息。我们常常希望或有必要监控这些负载,以确保它们在参数范围内正常工作,或者判断它们是否可能出现故障、损坏、卡在开启或关闭状态,或在其他方面超出预期公差范围运行。
在此应用中,我们将使用电流互感器(CT)传感器来检测为交流负载供电的电路中的电流流动情况。具体做法是将 CT 连接到为负载供电的一根“火线”上。CT 与负载的交流电压是电气隔离的,但可以感应流过“火线”的电流。这是因为流过火线的电流会产生磁场。电流互感器使我们能够监控这个磁场的强度,从而指示流过“火线”的电流量。
从外观上看,CT 传感器像一个有点笨拙的“夹子”,上面连着一根电线。我们使用的这个型号是 SCT013,标注为“100A/50mA”。这意味着该 CT 可以感应高达 100 安培的电流。在 100 安培时,CT 会产生 50 毫安(50mA)的传感器电流。这意味着对于 0 到 100 安培的“火线”电流,其输出电流将在 0 到 50mA 之间。它是一个近似传感器。
CT 有一根引出线,用于输出其传感器电流。通过在这些线上连接一个“镇流电阻”,我们可以将 0 到 50mA 的电流转换成可以测量的电压。我们将这个电阻称为 Rb。然后我们可以计算 CT 的输出电压:
Vout = Rb * Ic (其中 Ic 是 CT 提供的电流 - 0 到 50mA)
由于 ESP32 测量的是 Vout,我们可以重新整理这个公式,以展示在测量 Vout 时如何计算 Ic:
Ic = Vout/Rb
我们知道 (0-100A) 与 (0-50mA) 的关系,可以进一步计算“火线”电流。所以“火线”电流 I(hot) 是:
I(hot) = (Ic)*(100a/50ma) = (Ic)*2000
如果我们代入上面通过 Vout 计算 Ic 的公式,我们测得:
I(hot) = (Vout/Rb)*2000
要将 CT 连接到“火线”上,我们可以看到它像这张照片中显示的那样可以“分开”:
我们可以打开 CT,将其夹在“火线”周围,就像这样:
你可以看到,“火线”和 CT 之间没有电气连接。CT 只是感应“火线”中电流产生的磁场。
注意:我是在亚马逊上购买的这款 CT——有多种型号可供选择。如果你知道想要测量的电流水平,有更灵敏的型号可用于测量较低的“火线”电流。100A 是一个很大的电流,普通家庭中的大多数负载最高为 50A——例如,电灶、电烘干机,或者可能是电炉或电热水器。
如果你只需要测量 10 或 20A,那么最好选择一个更灵敏的 CT,因为它会比使用 100A 传感器测量 10 或 20A 提供更准确的结果。
软件架构
软件将只有几个功能——使用 CT 测量电流,并将其记录到板载闪存文件系统中的事件日志文件中。然后,它会定期将事件日志文件上传到远程 FTP 服务器,以便存储和分析数据。
此外,我们可能需要将其安装在一些不一定有电源的偏远地区,所以我们希望软件能在电池供电(即极低功耗)模式下运行。这意味着开发板大部分时间将处于低功耗的“睡眠”模式,完全不工作。它会定期“唤醒”,进行一次功率测量,并可能记录一个事件。然后再次进入睡眠状态。
在唤醒期间,软件会在更稀疏的时间间隔内打开 WiFi 无线模块,并尝试上传任何已保存的事件。远程计算机(如 PC、MAC 甚至树莓派)上的 FTP 服务将运行并接收来自物联网设备的数据。我们只偶尔这样做,因为 WiFi 无线模块消耗大量电力,所以我们只偶尔激活它以延长电池寿命。
因此,软件将包含测量部分、日志记录和通信部分。这些部分都经过精心设计,以实现尽可能低的功耗。
测量软件
测量软件将负责从 CT 传感器获取数据,并将其转换为“火线”中流动的电流量。ESP32 内置了一个模数转换器(ADC),可以测量外部电压并将其转换为数字读数。
对于 ESP32,ADC 测量某个外部电压,并将其转换为 0 到 4095(12位)之间的数字,这代表了标称 0 到 3.3V 的电压。ADC 具有一些预分压功能,也允许测量更小的电压范围。对于本应用,我们将使用 3.3V 的范围。这意味着 ADC 可以分辨小至 3.3V/4096 或约 805.66 微伏的电压差。
所以我们可以通过以下方式将 ADC 读数(0 到 4095)转换为电压:
电压 = ADC读数 * (3.3/4096)
或
电压 = ADC读数 * 0.0008056666
多年来,我学到了一件事,那就是在现实世界中测量某物,你会得到你想要测量的东西,外加一些你并不真正想要或需要的东西——也就是噪声和非线性。
此外,我还了解到被测量的信号值在不断变化,因为它是一个交流波形。下面的样本显示了纯交流波形(顶部)、加上噪声的波形(中部),以及加上噪声和非线性的波形(底部)。
有效值(RMS)测量
我们测量的是交流电压,这意味着我们需要知道其“直流等效”电压。例如,你家里的电源插座可能是 100VAC 或 120VAC。如果我们观察这个波形,会发现它以正弦波的形式每秒变化 50 或 60 次(取决于你住在世界哪个地方)。那么,如果电压在不断变化,我们怎么能说它是 120VAC 呢?
答案是我们试图将变化的电压转换成一个从功率角度来看表示其平均值的等效值。在这种情况下,VAC 指的是你家插座输出的正弦波的 RMS(均方根)值。
事实证明,RMS 是一个明确定义的数学概念(关于数学细节,请参阅维基百科上的 RMS)。RMS 在交流电测量中的应用也在这里的维基百科页面中有定义。
为了我们的目的,为了测量电流,我们需要测量来自 CT/镇流电阻电路的交流电压波形的 RMS 值。然后我们可以应用上面的公式来得到“火线”的电流值。
我们还必须处理我们正在测量的来自 CT 信号上的噪声分量。为了同时解决 RMS 和噪声问题,我们将在精确的时间间隔内测量交流波形,并应用 RMS 计算。这将计算出一个“平均值”,所以如果我们有大量的捕获值,我们就能“平均掉”一部分噪声。
使用 ESP32,我们将在精确的时间间隔内测量交流波形,以便每个周期获得整数个样本——例如,每个交流波形周期 16 或 32 个样本。我们还将捕获多个周期,以增加我们可以平均的数据点数量,并帮助减少测量的噪声分量。
因此,我们需要对 ESP32 进行编程,以获取交流波形的所有这些测量样本,然后将它们处理成一个以伏特为单位的 RMS 读数。为此,我们将使用一个 ESP32 定时器,在我们可以通过设置定时器选择的精确间隔内产生中断。在每个中断间隔,我们将使用 ADC 进行一次采样,并将这些样本存储在为其保留的内存区域中。
当所有样本都收集完毕后,ESP32 就可以计算 RMS 值了。
这里还有一个额外的复杂问题:CT 产生的交流波形会呈现正负电压值。不幸的是,ESP32 只能测量 0 到 3.3V 的电压,所以它无法测量波形的负电压部分。事实上,如果我们将这些负电压输入到 ADC 输入引脚,我们可能会损坏 ESP32 硬件——显然,它对负电压相当敏感,可能会损坏一些内部电路。
为了克服这个困难,我们需要在将电压发送到 ESP32 之前给它加上一个值,这样 ESP32 ADC 引脚就永远不会看到低于 0 的电压。
下面是使用的 CT 接口电路。
- 在左侧,我们有一个分压器,它从 GPIO 17 获取 3.3V 电压,并将其在中间分压为 1.66V。我们使用一个 GPIO 来驱动它,这样当我们进入深度睡眠时,我们可以将 GPIO17 设置为 0V,从而在睡眠周期内不消耗任何流经分压器的电流。
- 在中间,有三个并联的元件——CT 传感器、一个 33.3 欧姆的负载电阻和一个滤波电容。滤波电容有助于消除或滤除部分噪声分量。
- 在左侧,我们有三个连接到 ESP32 板的引脚:一个是 GND(地线),一个是 GPIO35(中间),它被设置为 ADC 输入引脚(从 ESP32 的角度看是 ADC 7),还有一个被设置为电压源(顶部)——GPIO17,我们将它设置为输出模式并将其值设为‘1’,这样它就会向分压网络提供 3.3V 电压。
- 在 ADC 处,我们会看到一个交流波形(当然带有噪声和非线性),它以 1.66V 为中心上下波动,但绝不会超过 3.3V,也绝不会低于 0V。这使得 ESP32 ADC 能够将电压数字化。
测量来自 CT 信号的 RMS 值的代码
首先,我们需要初始化一些硬件。我们想要将进入 ADC A7 线路的交流波形数字化。采样点需要均匀间隔,并且间隔要使得每个交流信号周期能采集到整数个样本。在这个应用中,我们每个周期采样 16 次(我们称之为“过采样率”或 OSR=16)。由于波形是 60 Hz(在某些国家可能是 50 Hz)——这意味着每秒有 60 个完整的周期。所以每个周期需要 1/60 秒,即 0.016667 秒(16.667 毫秒)。因为我们想在每个周期内采样 16 次,所以我们需要在 16.667/16 = 0.001041667 秒或 1041.667 微秒时进行一次 ADC 采样。
为了准确地做到这一点,我们需要使用一些硬件,不能单靠软件来完成。所使用的技术是设置一个定时器,每 1041.667 微秒产生一个周期性中断。在中断服务程序(ISR)内部,我们将用 ADC 进行一次采样,并将其存储在一个样本缓冲区中。这确保了样本以正确的间隔被采集。
这是设置定时器中断的代码。当 ESP32 正在运行时发生中断,它会立即保存它正在做的一切,并调用一个名为中断服务程序(ISR)的特殊函数。这个 ISR 将负责获取一个样本并将其存储在样本缓冲区中。
首先,我们分配一个定时器——ESP32 有三个(0、1和2),我们避开定时器 0,因为它已经被 ESP32 基础软件使用了。所以我们使用定时器 1。
首先,我们必须设置定时器 1 在正确的时间产生中断——这涉及到一些灵活(复杂)的硬件,包括计数器、时钟频率和预分频器需要设置——如果你在网上搜索 ESP32 定时器,可以找到大量相关信息。
其次,我们设置定时器来调用我们的 ISR——在下面称为“`onTimer`”——这意味着当定时器中断发生时,`onTimer()` 函数将被调用。你不能向 ISR 传递任何参数,所以它只是要调用的函数的名称。当然,在 ISR 中,我们总是希望做最少的工作量,并且不进行诊断性打印等操作。
最后,我们计算一个“计数”,它代表中断之间的时间量——并且是时钟频率、时钟频率预分频和代表 1041.67 微秒的时钟计数的函数。
#define CLOCKRATE 80000000 /* Hz */
#define TIMERDIVIDER 4
//----------------------------------------------------------------------
void setupMeasurement()
{
// initialize ADC - no setup initialization is required - we're using
// default settings
// zero out the buffer
//for (int i = 0; i < NSAMPLES; i++) samples[i] = 0;
// initialize timer for interrupt every 1041.6666 is as close as we can get
// (1000000us/(16*60) us
// timer 1 - set up to generate periodic interrupts for reading the ADC
My_timer = timerBegin(1, // Timer 1
TIMERDIVIDER, // prescaler, 80MHz/4 = 20 MHz tick rate
true); // true means count up
timerAttachInterrupt(My_timer, &onTimer, true); // attach the ISR to read analog samples
// we're trying to measure a sine wave (noisy one, but kind of a sine wave)
// of a voltage coming off of the current transformer sensor
//
// We measure at a rate to get exactly 16 samples for every sine wave
//
float measIntervalSec = 1.0/(60.0*OSR); // for 16x osr, 1041.67 us
int count = (int)(measIntervalSec*CLOCKRATE/TIMERDIVIDER + 0.5); // round to nearest integer
timerAlarmWrite(My_timer, count, true); // timer for interrupts
timerAlarmEnable(My_timer); // and finally, Enable the dang interrupt
}
下一个代码段是 ISR 本身。这里有很多注释,但实际上只有两行代码!(保持 ISR 尽可能简单,但不能更简单)。内存中有一个名为 `samples[]` 的缓冲区,它足够大,可以存储我们收集的最大样本数(`NSAMPLES`)。
在每次中断时,通过调用 `adcRead()` 函数进行一次采样。结果是一个 0 到 4095 之间的 12 位数字,它被存储在 `samples[]` 缓冲区的下一个可用空间中。当缓冲区被填满时(`sampleCount >= NSAMPLES`),ISR 就直接返回,不再进行额外的采样。
所以,经过一系列定时器中断,每次都调用 ISR,我们最终得到一个装满了 ADC 输入引脚数字化电压的缓冲区 (`samples[]`)。一旦这个缓冲区完全填满,我们就可以(在主代码中,而不是 ISR 中)继续进行必要的计算,以计算出 CT 输出电压的 RMS 值,从而如上式所示计算出“火线”中的电流。
hw_timer_t *My_timer = NULL; // handling a timer on the ESP32 chip with interrupt
//---------------------------------------------------------------------
// Timer Interrupt Service Routine (ISR)
//
// Software is using a timer with ISR
// to read the ADC and store samples in a buffer.
// Then in mainline code, we will do the RMS calculation after all the
// samples are available.
void IRAM_ATTR onTimer()
{
// The ISR is always active, but only runs the ADC when a sampling
// is triggered.
// To trigger the sampling interval, mainline code sets sampleCount=0
// Then on the next ISR execution, it will start collecting samples
// and storing them in the samples[] buffer. When NSAMPLES have been
// stored, the ISR stops reading the ADC until mainline code again
// sets sampleCount=0
//
if ((sampleCount>=0) && (sampleCount < NSAMPLES))
{
samples[sampleCount++] = analogRead(ADCPIN);
}
}
接下来,我们需要实际进行测量——这意味着采集所有的 ADC 样本并将它们存储在缓冲区中。然后,我们需要进行一系列计算,将所有这些样本转换成一个单一的 RMS 电压测量值!
在下面的代码中,你会看到几个函数:
- `readAnalogSamples()` - 通过设置 `sampleCount = 0` 来触发读取一个缓冲区大小的样本,然后等待 ISR 填满缓冲区。当缓冲区满时,我们会看到 `sampleCount` 将等于 `NSAMPLES`。
- `measureRms()` - 这个函数负责将所有样本处理成一个单一的 RMS 电压测量值。
- 首先,我们计算所有样本的平均值或均值。记住,所有样本都是 0 到 4095 之间的整数,代表 0 到 3.3V 的电压。因为波形被偏移了 1.66 伏,所以所有的 ADC 读数都会大约是 12 位计数的一半——即大约 4096/2 或 2048——加上或减去来自 CT 的值。所以我们计算均值,然后从每个样本中减去它,以得到实际的 CT 输出电压,这将是正负值(samples[i] - mean)。
- 接下来,我们计算减去均值后的 CT 电压读数的平方和。这是变量 sum,一个 32 位整数。
- 现在要计算 RMS,我们将 sum 除以样本数,然后取该值的平方根。这是以 ADC 计数(0..4095)为单位的 RMS 值。然后我们将其乘以 3.3/4096,这将 RMS 值转换为实际的伏特!
- `makeMeasurement()` - 同样有很多注释,但只有几行代码 - 调用 `readAnalogSamples()` 来获取充满样本的缓冲区,然后调用 `measureRms()` 将所有这些样本转换为来自 CT 的单个 RMS 电压读数。最后,我们通过调用 `cvtRmsToAmps()` 将 RMS 电压转换为“火线”安培数。最终,我们返回以安培为单位的“火线”中流动的电流量。
//--------------------------------------------------------------------
// Initiate a read of analog samples from the ADC
void readAnalogSamples()
{
int dly=17*CYCLES;
sampleCount = 0; // this triggers the ISR to start reading the samples
// This should cause the ISR to read samples for next CYCLES of 60Hz (16.67 ms per cycle)
delay(dly); // we're delaying for 17 cycles, by then the ISR should be finished reading the samples
if (sampleCount!=NSAMPLES)
{
print("ADC processing is not working");
}
timerWrite(My_timer,0); // disable timer, we're done with interrupts
}
//--------------------------------------------------------------------
// Measure the RMS value of the samples recorded
float measureRms(int* samples, int nsamples)
{
// this is tricky because of noise and because of the cyclic nature of the signal...
//
// first calculate the mean of the samples, or use something fixed
// this is because the ADC measures positive voltages only (0 to +3.3 v)
// so if we have a signal like a sine wave, we "bias" it to 3.3/2 volts
// using an RC divider so that we can measure +- 1.65 volts in a 0 to 3.3v
// scale.
int32_t sum=0; // 32 bit sum
for (int i = 0; i < nsamples; i++) sum += samples[i];
int mean = (int)(sum/(int32_t)(nsamples));
// RMS is root-mean-square
// so compute sum of squares, divide by nsamples, take square root
// now compute sum of (x-mean)^2
sum=0;
for (int i = 0; i < nsamples; i++)
{
int32_t y = (samples[i] - mean);
sum += y*y;
}
float ym = (float)sum/(float)nsamples;
float rms = sqrt(ym);
rms = rms * 3.3/4096.0; // scale to volts, 3.3v (MAX) is a count of 4095
#ifdef WANTSERIAL
//sprintf(msgbuf,"mean=%d",mean); print(msgbuf);
//sprintf(msgbuf,"meansq=%ld",sum); print(msgbuf);
//sprintf(msgbuf,"rmssamples=%f",rms); print(msgbuf);
//sprintf(msgbuf,"rmsvolts=%f",rms); print(msgbuf);
#endif
//for (;;) ; // temp - hang here forever
return rms;
}
//--------------------------------------------------------------------
// convert measured vrms to amps using some primitive calibration data
// and linear interpolation
float cvtRmsToAmps(float vrms)
{
// This converts the RMS reading (in volts) to an amperage reading (in amps)
//
// The nature of real life is (signal + noise + non-linearity)=measured value
// We have tried to take out some of the noise component by averaging over
// a few dozen cycles of the sine wave.
// This routine will attempt to address the non-linearity portion by applying
// a calibration. We did a calibration by reading the RMS voltage with some
// known loads with a "good" meter. So we know the actual measured rms, the
// actual watts, and we can compute a factor to convert between the two.
//
// calibration data from WattsUp watt meter, assuming 115Vrms
// 0W -> 0.00A -> measured 0.010 noise
// 60W -> 0.52A -> measured 0.018 vrms 0.52/(0.018-0.01) = 65
// 1070W -> 9.30A -> measured 0.122 vrms 9.30/0.122 = 76.2
// 1590W -> 13.82A -> measured 0.198 vrms 13.82/0.198 = 69.8
// expected with 50ma=100A primary current, and 33.3 ohm burden resistor (100/0.05)/33.3 = 60.06
// so the ideal, linear, spherical constant is 60.06
// We measured things like 65, 76.2, and 69.8 using known loads
// So we're in the right ball-park.
//
// We'll construct a piece-wise linear interpolation curve to take the actual
// measured RMS of an unknown load and convert it to some calibrated current
// value.
//
if (vrms < 0.01) return 0.0;
if (vrms < .122) return vrms*(65.0+((vrms-0.01)/.122)*(76.2-65.0));
if (vrms < .198) return vrms*(76.2+(vrms/.198)*(69.8-76.2));
return vrms*(60.0+(vrms/1.67)*(60-69.8));
}
//----------------------------------------------------------------------
float makeMeasurement()
{
// measure the current using the current transformer sensor
// It generates some voltage that get's fed into the ESP32's Analog->Digital converter
// Then we do some math to make that into current (amps)
//
// remember: measured signal = (real signal + noise + non-linearity)
//
float rms;
float amps = -1.0;
readAnalogSamples(); // read many cycles of the sine wave at like 16 samples/cycle
if (sampleCount==NSAMPLES)
{
rms = measureRms((int*)samples, NSAMPLES); // convert samples to an RMS voltage
amps = cvtRmsToAmps(rms); // convert RMS voltage to amps
#ifdef WANTSERIAL
//sprintf(msgbuf,"Measured=%f volts, Amps %f",rms, amps); print(msgbuf);
#endif
}
return amps;
}
此时,我们得到了“火线”中的电流安培数,需要决定如何处理它。
在我的应用中,CT 将监控为水井泵供电的“火线”电流。当水泵“`ON`”(开启)时,它会消耗 8-9 安培的电流。关闭时,当然是 0 安培。
配置文件中有一个条目给出了一个“`AMPSON`”电流值,比如 2 或 3 安培。如果测量的电流超过这个阈值,代码就将水泵标记为 `ON`(开启),否则就标记为“`OFF`”(关闭)。
代码对安培测量值的处理是判断水泵是“`ON`”(开启)还是“`OFF`”(关闭)。当它检测到水泵状态发生转变(从“`OFF`”到“`ON`”或从“`ON`”到“`OFF`”)时,它会将新状态连同一个日期/时间戳记录到板载的日志文件中。因此,我们最终会得到一个带有时间戳的 `ON` 或 `OFF` 事件的日志文件。
然后,每天几次(目前代码设置为每 6 小时一次)——在唤醒期间,代码会打开 WiFi,连接到一个 AP(接入点),并尝试将日志文件上传到配置文件中指定的 FTP 服务器。如果上传成功,板载的日志文件将被删除,我们从一个干净的日志文件开始。如果上传不成功,日志文件将保持不变,并继续向其中添加数据。
这意味着,如果物联网设备在合适的 WiFi 信号范围内,它将上传数据;否则,它将继续在板载上累积数据,以便在稍后的尝试中上传。
这是一个记录并上传到 FTP 服务器的日志条目示例。`ON` 和 `OFF` 事件显示了 ESP32 测得的电流(安培)。
2024/01/27,10:04:21,ON,8.919955
2024/01/27,11:56:32,OFF,0.000000
低功耗处理
为了使该应用能够仅靠电池运行相当长的时间,有几件事情是必要的。
首先,我们需要选择一个支持电池操作的 ESP32 板。这里使用的板允许插入一个小电池。当 ESP32 板通过 USB 连接供电时,该板会为电池充电。当 USB 拔掉时,ESP32 由电池供电。
我从 Maker Focus 找到了这些容量为 1000 毫安时的电池。在这个应用中,我发现它们可以为物联网系统供电约 20 天,无需充电。我将一个带 USB 输出的小型太阳能电池板连接到板子的 USB 端口,以便在有阳光时为电池“补电”。通过这种方式,它几乎可以永久运行——除非你住在一个连续 20 天没有阳光的地方!(对此表示遗憾)。
选择了开发板和电池后,我们需要确保所使用的电路功耗尽可能低。在这种情况下,我们通过 GPIO 17 引脚来驱动分压网络,而不是直接从 ESP32 板上的 3.3V 电源获取。这意味着当设备处于深度睡眠模式且 GPIO 17 设置为 0 伏时,分压网络不会消耗任何电力。
最后,我们需要设计 ESP32 上的软件,使其真正具有功耗意识。这里可以做几件事:
- 尽可能多地使用“深度睡眠”模式
- 保持非睡眠时间尽可能短
- 如果可能,以较慢的时钟频率运行 CPU
- 尽可能保持 WiFi 无线电关闭。
为了实现这一点,软件中设计了以下内容:
- ESP32 大部分时间将处于深度睡眠模式,偶尔唤醒以读取 CT。
- 一个位于板载闪存文件系统中的日志文件将存储结果。
- ESP32 将从正常的 240MHz 降频到 80MHz。
- WiFi 无线模块每天只开启几次,以尝试上传测量数据。
通过使用这些技术,我部署的 ESP32 系统可以在一块 1000mah 的电池上运行 15-20 天。如果连接了太阳能电池,该系统几乎可以无限期运行。
深度睡眠模式
ESP32 平台包含多种“模式”,它们在运行时消耗不同程度的功率。正常或活动模式操作意味着 ESP32 持续运行其主 CPU。这是消耗功率最多的模式,因为芯片的大部分都在运行——CPU、内存、闪存、定时器、GPIO/端口,甚至可能还有无线电(WiFi 或蓝牙)。
为了节省电力,可以采用一些低功耗模式——基本上,这意味着芯片的某些部分被断电,一些功能和特性无法使用。
在活动模式下,可以通过在不需要无线电功能时关闭它来实现一些节能。此外,CPU 时钟可以“调低”或以较慢的速率运行。这会导致芯片执行速度变慢,但会减少在活动模式下使用的功率。
除了活动模式,还有大约五种不同的省电模式——太多了,无法在这里一一描述。芯片制造商在这里有一个很好的这些模式的描述。
对于这个应用,我们选择使用功耗(几乎)最低的模式——大约 10 微安(仅芯片本身)——称为深度睡眠模式。(注意:我测得整个板在深度睡眠模式下由 5V 电源供电时为 20 微安)。在这种模式下,几乎所有芯片的功能都被断电了。唯一仍然活动的部分是:
- RTC 控制器
- ULP 协处理器
- RTC 快速内存
- RTC 慢速内存
这意味着 CPU、RAM 以及几乎所有的外设都被断电了。在这种深度睡眠模式下,功耗降低到微瓦级别。
在此模式下,唯一“醒着”的东西是一些少量特殊的 RAM 内存,以及用于记录当前时间和芯片进入深度睡眠时间的电路部分——还有一个用于外部信号的 GPIO。
要从这种深度睡眠模式中恢复,我们需要“唤醒”CPU——这意味着要回到活动模式。然而,在深度睡眠模式下,CPU 和内存系统是断电的——所以没有指令被执行,也没有 RAM 内存内容被保留。当然,存储程序本身的闪存内容是保留的,尽管它在深度睡眠模式下也是断电的。
“唤醒”意味着基本上是从完全断电模式重启 CPU 的过程。在这个启动过程中,代码需要知道它是在执行所谓的冷启动——即系统刚刚上电并首次启动,还是因为低功耗模式(如深度睡眠)的唤醒事件而启动。
幸运的是,制造商提供了特殊的 IO 寄存器,可以读取这些寄存器来确定正在发生哪种类型的启动。然后,代码可以根据从这个启动原因寄存器中读取到的信息来不同地处理启动过程。
下面,你将看到在 `setup()` 函数开头的代码,它在每次启动或唤醒周期时执行。这里有一个对 `esp_sleep_get_wakeup_cause()` 函数的调用,以获取一个描述正在进行的唤醒类型的代码。稍后在 `setup` 函数中,我们将使用这个代码来确定要进行哪种类型的唤醒处理。
void setup()
{
// --- deep sleep mode note ---
// Since this application puts the ESP32 into deep sleep mode most of
// the time, waking from deep sleep means the contents of memory are
// unknown, so the entire program is read back into memory from flash
// and we execute the setup() routine. All the application logic
// happens here, then we go back to sleep.
//
// So the normal loop() function is never actually executed!
//
char buf[32];
esp_sleep_wakeup_cause_t wakeup_reason;
#ifdef WANTSERIAL
Serial.begin(115200);
#endif
print(SIGNON);
// see why we woke up
wakeup_reason = esp_sleep_get_wakeup_cause(); // see why we woke up - cold boot or deep sleep
setCpuFrequencyMhz(80); // take it easy on CPU speed to reduce power consumption
switch (wakeup_reason)
{
case ESP_SLEEP_WAKEUP_EXT0 : print("Wakeup caused by external signal using RTC_IO"); break;
case ESP_SLEEP_WAKEUP_EXT1 : print("Wakeup caused by external signal using RTC_CNTL"); break;
case ESP_SLEEP_WAKEUP_TIMER : print("Wakeup caused by timer"); break;
case ESP_SLEEP_WAKEUP_TOUCHPAD : print("Wakeup caused by touchpad"); break;
case ESP_SLEEP_WAKEUP_ULP : print("Wakeup caused by ULP program"); break;
default : print("Wakeup was not caused by deep sleep"); break;
}
// Initialize SPIFFS (file system)
if(!SPIFFS.begin(true))
{
print("An Error has occurred while mounting SPIFFS");
//return; what to do here? We can't do much without the file system
}
pinMode(LED,OUTPUT);
pinMode(VPPPIN,OUTPUT);
digitalWrite(LED,HIGH); // use this as 3.3v src for measurement voltage divider
digitalWrite(VPPPIN,HIGH); // power the current tx sensor for a little bit
位于 `setup()` 函数底部的代码是让 ESP32 重新进入深度睡眠模式的部分。它通过从总睡眠时间中减去 ESP32 在本周期内保持唤醒的时间来计算睡眠时间(`tts`)。这个时间被传递给 `esp_sleep_enable_timer_wakeup(tts)` 调用,以设置在正确的时间唤醒 ESP32。然后 `esp_deep_sleep_start()` 函数会关闭所有功能,直到下一个唤醒周期。
// and go back to sleep here
++bootCount; // count this boot-up sequencerm
digitalWrite(LED,LOW); // turn of comfort LED
digitalWrite(VPPPIN,LOW); // set voltage divider output pin to 0v
sprintf(buf,"Awake for %d ms",millis());
print(buf);
long tts = (timeToSleepMs - millis()) * ms_TO_uS_FACTOR;
esp_sleep_enable_timer_wakeup(tts);
esp_deep_sleep_start();
// and, the ESP32 never executes any code past the deep_sleep_start!
通信
该软件支持两种通信功能——一种是通过 WiFi 连接从 NTP 服务器获取当前日期/时间(以便知道正确的日期和时间)。第二种功能是 FTP 上传功能,它读取任何日志条目并使用 FTP 将这些条目追加到 FTP 服务器上的一个日志文件中。
如果 ESP32 通过 WiFi 连接到互联网,它可以访问一个特殊的服务器来获取当前的时间和日期。这些服务器被称为网络时间协议(NTP)服务器。该软件使用 NTP 客户端来获取日期/时间并将其设置在 ESP32 中,以便 ESP32 能够持续跟踪日期和时间。
ESP32 还会尝试使用 FTP 客户端模块将其收集的物联网数据(`ON` 和 `OFF` 事件)上传到远程计算机。这使得 ESP32 能够自动将其数据上传到远程计算机系统,以进行进一步的归档和处理。FTP 上传使用 `APPEND` 模式将数据追加到远程服务器上一个已存在的文件中。远程服务器上的路径和文件名在启动时读取的配置文件中指定。
在 `setup()` 函数中,您会看到进行测量并记录的代码,以及定期(每6小时)尝试连接 WiFi 并上传数据的代码。
if (wakeup_reason == ESP_SLEEP_WAKEUP_TIMER)
{
// most of the time, the app wakes up from deep sleep and makes
// a current measurement. If the current is above some threshold
// value (specified in config.ini file) then we consider the load (pump)
// is ON, otherwise it's OFF.
// If it changed state (OFF->ON) or (ON->OFF) we log that in the flash
// file system
// Probably the pump will only go one once per day, so most of the time
// not much happens and we just go back to sleep.
//
// However, every so often (currently 6 hr intervals) we turn on WiFi and
// try to upload any data to a remote server.
//
float amps;
String ttag = rtc.getTime("---Time=%Y/%m/%d,%H:%M:%S");
print(ttag.c_str());
// ------- do wakeup tasks here
setupMeasurement();
amps = makeMeasurement();
sprintf(buf,"Amps=%f",amps);
print(buf);
logMeasurement(amps);
// ------- do hourly tasks here
int hr = rtc.getHour();
if (lastHr == -1)
{
lastHr = hr;
}
else if (lastHr != hr)
{
lastHr = hr;
if ((hr % 6) == 0) // every 6 hours
{
// hourly tasks
connectToWiFi(); // first connect this ESP32 to the WIFI network
pushDataToServer();
getNtpTime(); // resync time
turnOffWiFi(); // turn off when done
}
}
}
特别感谢 Rui Santos (Random Nerd Tutorials) 提供了 NTP 和 FTP 服务器功能。Rui 的网站上有大量关于使用 ESP32 的许多不同功能以及连接传感器和执行器的示例——非常值得一看。
冷启动处理
在 `setup()` 函数中,软件实现了一组仅在冷启动时执行的任务。这些任务主要是读取配置文件、连接 WiFi 以及从 NTP 获取当前日期/时间。软件将配置数据存储在一个特殊的内存(RTC 内存)中,这是从深度睡眠模式唤醒后唯一保留的内存。这样,ESP32 就不必每次唤醒都读取配置文件——尽管它也可以这样做,但这会在每次唤醒时消耗数百万个 CPU 周期。因此,为了节省电力,我们只在冷启动时读取一次此信息,而不是在每次从深度睡眠模式唤醒时都读取。
// Stuff to do on an initial boot from power on or reset (not done on wakeup)
if (bootCount == 0)
{
// read needed data from the config file - stored in RTC memory
// so it survives deep sleep
readKey(CONFIGFN,"SSID=",ssid,127);
readKey(CONFIGFN,"PASSWORD=",password,127);
readKey(CONFIGFN,"FTPHOST=",ftpHost,127);
readKey(CONFIGFN,"FTPUSER=",ftpUsername,63);
readKey(CONFIGFN,"FTPPASSWORD=",ftpPassword,63);
readKey(CONFIGFN,"FTPFILE=",ftpFile,127);
readKey(CONFIGFN,"FTPPATH=",ftpPath,127);
readKey(CONFIGFN,"AMPSON=",buf,31);
ampsForOnMeasurement = atof(buf);
readKey(CONFIGFN,"MEASUREMENTINTERVALMS=",buf,31);
timeToSleepMs = atol(buf);
Serial.print("Measurement Interval (ms) ");
Serial.println(timeToSleepMs);
if (timeToSleepMs < 2000) timeToSleepMs = 2000;
if (timeToSleepMs > 120*1000) timeToSleepMs = 120*1000;
// initialize the RTC
rtc.setTime(0,0,0,1,1,2024); // default time 00:00:00 1/1/2024
if (connectToWiFi() == 1)
{
needNtp = true;
getNtpTime(); // get NTP time if possible
}
turnOffWiFi(); // now we can turn off the WiFi modem to save power
}
关注点
学习电流互感器(CT)传感器以及如何测量 RMS 真的是很有趣的练习。
学习一些线性分段校准来处理传感器的积分非线性问题也同样——嗯——引人入胜。这是一个简单的概念,但实现起来需要一些思考。未来可以将其做得更通用。
源代码可在 GITHUB 上获取。
历史
- 版本 1.0,2024年1月