关于ESP32+BH1750+HW-390+DHT22+水泵实现的金华佛手自动浇水装置之超级无敌简陋版

注:以下内容疑似为ai生成
ai太厉害了
以下为零件说明:
(注:需轻微焊接)
ESP32开发板(Type-C口)1个
DHT22温湿度传感器(3针模块)1个
HW-390 电容式土壤湿度传感器(v2.0版) 1个
BH1750 光照传感器 1个(购买回来可能需要自己把引脚焊接)
0.96 寸 OLED 显示屏(4 针 I2C) 1个
5V 单路光耦隔离继电器模块(高电平触发)1个
DC 3W 潜水泵(3~6V,150~370mA, 内径0.5mm)1个 + 内径0.6mm硅胶管(购买回来水泵可能只有两根电线,需要自己把一根杜邦线去皮焊接)

5V 2A 电源适配器(5.5mm 大头)1个 + dc母头转杜线带针
面包板 1个
杜邦线(公对公,母对母,公对母)若干
1kΩ电阻 (只用了一个)
4.7kΩ电阻 (只用了一个)
二极管 (只用了一个)
第一步
使用软件:
vscode+platformIO
1.在vscode的拓展里安装platformIO

2.点开platformIO里的界面open处

3.点击+New Project新建项目

输入Name:输入一个项目名称
Board:选择esp32开发板
Framework:Arduino
Location:文件路径,取消Use default location默认路径,之后自己选择一个地方
之后点击右下角蓝色Finish
以上操作会在Location所选的地址下自动创建一个以项目名称为文件名字的文件夹

之后等待一会(第一次应该会等待很长时间 )就创建成功了


第二步
接线(AI给出):
注意:所有GND(负极)都要连接在一起,以给正极提供相同的0V参考线
1.DHT22接线
**解释**:DHT22 是 3 针模块,三根脚分别是 VCC(+)、DATA(数据)、GND(−)。它的 DATA 脚上电后不断输出一串脉冲,ESP32 通过 GPIO4 读这串脉冲,解析出温度和湿度。3 针模块板上已带 4.7k 上拉电阻,所以不用外接。
**操作**:
| DHT22 引脚 | 接 ESP32 | 杜邦线类型 |
|---|---|---|
| VCC(+) | 3V3(3.3V) | 母对母 |
| DATA(out) | GPIO4 | 母对母 |
| GND(−) | GND | 母对母 |
如果模块上印的是 `+ / out / −`,就按同样的对应关系接。
**接反会怎样**:VCC 和 GND 接反 → 传感器发热甚至烧坏;DATA 接 5V → 可能损坏 GPIO。所以先看清丝印再插。
```
DHT22 ESP32
┌──────┐
│ VCC ├─────────────→ 3V3
│ DATA ├─────────────→ GPIO4
│ GND ├─────────────→ GND
└──────┘
```
2.HW-390 土壤湿度传感器接线
**解释**:HW-390 是电容式传感器,利用土壤含水量改变电容来测量,不怕腐蚀(比电阻式寿命长)。板上一般有 4 个针:VCC、GND、AO(模拟输出)、DO(数字输出)。本系统只用 AO。
**操作**:
| HW-390 引脚 | 接 ESP32 | 说明 |
|---|---|---|
| VCC | 3V3 | ⚠️ 用 3.3V,不要用 5V |
| GND | GND | 共地 |
| AO | GPIO34 | 模拟输出 → ADC 引脚 |
| DO | 不接 | 数字输出用不到 |
**为什么用 3.3V 供电**:AO 输出电压和供电电压有关。ESP32 的 ADC 最高只能承受约 3.3V,5V 供电的 AO 可能超过 3.3V,会损坏 ADC 引脚。用 3.3V 供电就绝对安全。
**为什么用 GPIO34**:GPIO34 属于 ADC1,而且只能当输入(不会误输出),是模拟输入的理想选择。GPIO32~39 都是 ADC1,避开 ADC2(与 WiFi 冲突)。
**插土注意事项**:
- 探针竖直插进土里,插到金属探针完全没入土壤即可,别插到盆底。
- 探针别碰到花盆金属边、石头、根系粗壮部分,会读数不准。
- 插稳后不要频繁拔插,位置固定读数才稳定。
```
HW-390 ESP32
┌────────┐
│ VCC ├──────────→ 3V3
│ GND ├──────────→ GND
│ AO ├──────────→ GPIO34
│ DO ├──── 不接
└────────┘
```
3.BH1750 光照传感器接线
**解释**:BH1750 用 I2C 通信。ESP32 的 I2C 引脚可以软件指定,我们统一用默认的 GPIO21(SDA)和 GPIO22(SCL)。模块板上带 I2C 上拉电阻,不用外接。
**操作**:
| BH1750 引脚 | 接 ESP32 |
|---|---|
| VCC | 3V3 |
| GND | GND |
| SDA | GPIO21 |
| SCL | GPIO22 |
**插反会怎样**:SDA 和 SCL 接反 → 读数失败(库初始化失败),不会烧硬件,换回来即可。VCC/GND 接反 → 可能烧模块。
```
BH1750 ESP32
┌────────┐
│ VCC ├──────────→ 3V3
│ GND ├──────────→ GND
│ SDA ├──────────→ GPIO21
│ SCL ├──────────→ GPIO22
│ ADDR ├──── 悬空(默认地址)
└────────┘
```
4.OLED 显示屏接线
**解释**: OLED 是 0.96 寸、SSD1306 驱动、4 针版本,4 根针分别是 VCC、GND、SDA、SCL,属于 I2C 接口。它和 BH1750 **共用 GPIO21/22 两根线**——这就是 I2C 总线的特点:一根线上可以挂多个设备,只要地址不同就不会打架(BH1750 地址 0x23,OLED 地址 0x3C)。屏幕上显示中文用的是 u8g2 库自带的中文字库。
**操作**:
| OLED 引脚 | 接 ESP32 | 说明 |
|---|---|---|
| VCC | 3V3 | 3.3V 供电 |
| GND | GND | 共地 |
| SDA | GPIO21 | 和 BH1750 的 SDA 接同一路 |
| SCL | GPIO22 | 和 BH1750 的 SCL 接同一路 |
接线方法:在面包板上把 BH1750 的 SDA 和 OLED 的 SDA 都插到 GPIO21 那一列,SCL 同理都插到 GPIO22 那一列;没有面包板就用杜邦线把两个模块的 SDA 并联。
**插反会怎样**:VCC/GND 接反可能烧屏;SDA/SCL 接反屏幕不亮(不会烧),换回来即可。
```
OLED ESP32
┌────────────┐
│ VCC ├────────────→ 3V3
│ GND ├────────────→ GND
│ SDA ├────────────→ GPIO21(和 BH1750 共用)
│ SCL ├────────────→ GPIO22(和 BH1750 共用)
└────────────┘
```
5.继电器模块接线
**操作**:
| 继电器引脚 | 接哪里 |
|---|---|
| VCC | 5V(电源适配器 5V+) |
| GND | 共地(适配器 GND,同时连 ESP32 GND,也就是要共地) |
| IN | ESP32 GPIO16 |
**重要:IN 加 4.7kΩ 下拉电阻**:
- 把 4.7kΩ 电阻一端插在 GPIO16 这一路的杜邦线上(或面包板对应孔),另一端插到 GND。
- 作用:ESP32 上电的一瞬间,GPIO 还没被代码控制,处于"悬空"状态,继电器可能乱吸合、水泵乱转。下拉电阻让悬空时 IN 稳定在低电平,泵不会自己转。
**跳线帽**:保持出厂默认位置(插在 VCC-JD 之间)即可,新手不用动。
```
继电器模块 ESP32 / 电源
┌────────────┐
│ VCC ├──→ 适配器 5V+
│ GND ├──→ 适配器 GND ──┼── ESP32 GND(共地)
│ IN ├──→ GPIO16
│ │ 4.7kΩ 电阻:GPIO16 ──电阻── GND
│ COM ├──→ 适配器 5V+
│ NO ├──→ 水泵 +
└────────────┘
```
6.水泵与供电
| 连接 | 说明 |
|---|---|
| 适配器 5V+ → 端子排 → 继电器 VCC | 给继电器供电 |
| 适配器 5V+ → 端子排 → 继电器 COM | 给水泵供电的"总入口" |
| 继电器 NO → 水泵 + | 继电器吸合时,5V 送到水泵 |
| 适配器 GND → 水泵 − | 水泵负极 |
| 适配器 GND → ESP32 GND | 共地(关键!) |
**水泵并联二极管(推荐)**:
把二极管并在水泵两根线上:**二极管负极(有横杠那头)接水泵 +,正极接水泵 −**。
水泵是电机,断电瞬间会产生反向电压(反电动势),二极管把它泄放掉,保护继电器触点。你已有二极管,顺手接上。
```
电源适配器 5V 2A(5.5mm 公头)
│
├─ DC母头转杜邦线 ─→ 端子排
│ ├─ 5V+ ──→ 继电器 VCC
│ ├─ 5V+ ──→ 继电器 COM
│ ├─ GND ──→ 继电器 GND
│ ├─ GND ──→ 水泵 −
│ └─ GND ──→ ESP32 GND(共地)
│
└─(方案B才接)5V+ ──→ ESP32 5V/VIN
继电器 NO ──→ 水泵 +
水泵 + ──二极管──→ 水泵 −(二极管负极朝 +)
```
第三步
代码:
1.下载依赖库
点开platform的libraries处

搜索dht22,作者为Adafruit


点进去后,点击Add to Project来添加到自己的项目

选择后添加之后点击Add添加

之后重复再添加
BH1750 by claws

U8g2 by oliver

添加好后platformio.ini这个文件的lib_deps处会有这三个库

2.之后在src下创建文件夹和文件(自己创建)

代码为ai给出
路径src/main.cpp
#include <Arduino.h>
#include "esp_task_wdt.h" // 看门狗:程序卡死时自动重启(安全保护)
#include "config.h"
#include "calibration_store.h"
#include "sensors/dht_sensor.h"
#include "sensors/hw_soil_moisture_sensor.h"
#include "sensors/light_sensor.h"
#include "actuators/relay_pump.h"
#include "controllers/watering_controller.h"
#include "display/oled_display.h"
// ================================================================
// 金华佛手(香橼)自动浇水系统 —— 主程序
// 结构:main 只负责"组装零件",具体逻辑在各模块里
// ================================================================
// ===== 全局对象创建(按依赖顺序)=====
CalibrationStore cal_store; // 校准值存储(NVS)
DHTSensor dht_sensor(PIN_DHT22, DHTModel::DHT22); // DHT22
LightSensor light_sensor(PIN_I2C_SDA, PIN_I2C_SCL); // BH1750
SoilMoistureSensor soil_sensor(PIN_SOIL_ADC, // HW-390
SOIL_DEFAULT_DRY,
SOIL_DEFAULT_WET,
16, // 采样次数
10); // 采样间隔 ms
RelayPump relay_pump(PIN_RELAY_IN); // 继电器+水泵
WateringController controller(relay_pump); // 自动浇水控制器
OledDisplay oled(PIN_I2C_SDA, PIN_I2C_SCL); // 0.96 寸 OLED 显示屏
// ===== 串口命令帮助 =====
void print_help()
{
Serial.println();
Serial.println("════════ 串口命令帮助 ════════");
Serial.println("h → 显示本帮助");
Serial.println("d → 把当前土壤读数记为【干燥】(传感器放空气中/干土时发)");
Serial.println("w → 把当前土壤读数记为【湿润】(传感器插浇透的土时发)");
Serial.println("c → 查看当前参数和校准值");
Serial.println("t → 测试水泵 3 秒");
Serial.println("o → 急停(立即关泵)");
Serial.println("r → 恢复默认校准值");
Serial.println("══════════════════════════════");
Serial.println();
}
// ===== 打印参数 =====
void print_params()
{
Serial.println("──── 当前参数 ────");
Serial.printf("校准: 干燥 %d / 湿润 %d\n",
soil_sensor.get_dry_value(), soil_sensor.get_wet_value());
Serial.printf("浇水阈值: 启动 %d%% / 停止 %d%% / 极干 %d%%\n",
WATER_START_PCT, WATER_STOP_PCT, WATER_EMERGENCY_PCT);
Serial.printf("连续 %lu 秒 / 渗水 %lu 秒 / 单次累计 %lu 秒\n",
(unsigned long)(PUMP_BURST_MS / 1000),
(unsigned long)(PUMP_SOAK_MS / 1000),
(unsigned long)(SESSION_MAX_ON_MS / 1000));
Serial.printf("最小间隔 %lu 小时 / 每日上限 %lu 分钟\n",
MIN_INTERVAL_MS / 3600000UL, DAILY_LIMIT_MS / 60000UL);
Serial.println("──────────────────");
}
// ===== 串口命令处理 =====
void handle_serial_command()
{
if (!Serial.available())
return;
String cmd = Serial.readStringUntil('\n');
cmd.trim();
cmd.toLowerCase();
if (cmd == "h")
{
print_help();
}
else if (cmd == "d")
{
// 把"当前土壤读数"记为干燥值并保存
int raw = soil_sensor.get_last_raw();
soil_sensor.set_dry_value(raw);
cal_store.save_dry(raw);
Serial.printf("✅ 已把当前读数 %d 记为【干燥】并保存\n", raw);
}
else if (cmd == "w")
{
int raw = soil_sensor.get_last_raw();
soil_sensor.set_wet_value(raw);
cal_store.save_wet(raw);
Serial.printf("✅ 已把当前读数 %d 记为【湿润】并保存\n", raw);
}
else if (cmd == "c")
{
print_params();
}
else if (cmd == "t")
{
controller.test_pump(3000); // 测试水泵 3 秒
}
else if (cmd == "o")
{
controller.emergency_stop();
}
else if (cmd == "r")
{
cal_store.clear();
soil_sensor.set_calibration(SOIL_DEFAULT_DRY, SOIL_DEFAULT_WET);
Serial.println("✅ 已恢复默认校准值(干燥3310 / 湿润1080)");
}
else if (cmd.length() > 0)
{
Serial.printf("未知命令: %s(输入 h 看帮助)\n", cmd.c_str());
}
}
// ===== 看门狗初始化(兼容旧版/新版 Arduino-ESP32 内核)=====
static void setup_watchdog()
{
#if ESP_ARDUINO_VERSION_MAJOR >= 3
esp_task_wdt_config_t cfg;
cfg.timeout_ms = 30000; // 30 秒没喂狗就重启
cfg.idle_core_mask = 0;
cfg.trigger_panic = true;
esp_task_wdt_init(&cfg);
#else
esp_task_wdt_init(30, true); // 30 秒,panic 模式
#endif
esp_task_wdt_add(NULL); // 把当前主任务加入喂狗名单
Serial.println("✅ 看门狗已开启:程序卡死 30 秒会自动重启");
}
// ===== setup:上电只跑一次 =====
void setup()
{
Serial.begin(SERIAL_BAUD);
delay(300);
Serial.println("════════════════════════════════");
Serial.println(" 金华佛手 自动浇水系统 v1.1 (OLED)");
Serial.println("════════════════════════════════");
// 1. 先读保存的校准值(没有则用默认值)
cal_store.begin();
int dry = cal_store.load_dry(SOIL_DEFAULT_DRY);
int wet = cal_store.load_wet(SOIL_DEFAULT_WET);
soil_sensor.set_calibration(dry, wet);
// 2. 初始化所有模块
dht_sensor.begin();
soil_sensor.begin();
light_sensor.begin();
oled.begin();
relay_pump.begin();
controller.begin();
// 3. 看门狗 + 帮助
setup_watchdog();
print_help();
}
// ===== loop:不断循环 =====
void loop()
{
esp_task_wdt_reset(); // 喂狗:告诉看门狗"我还活着"
int soil_pct = 0;
float humidity = 0.0f, temperature = 0.0f, lux = 0.0f;
// 读取所有传感器(失败时返回 false,控制器会自动保护)
bool dht_ok = dht_sensor.read(humidity, temperature);
bool soil_ok = soil_sensor.read(soil_pct);
bool light_ok = light_sensor.read(lux); // 光照只做提醒,不参与浇水保护
// BH1750 偶尔失败时提示一下(节流,避免刷屏)
if (!light_ok)
{
static unsigned long last_light_warn_ms = 0;
if (millis() - last_light_warn_ms > 30000)
{
last_light_warn_ms = millis();
Serial.println("⚠️ BH1750 读取失败(继续用上次缓存值,请检查接线)");
}
}
// 自动浇水控制
controller.update(soil_pct, temperature, humidity, lux, soil_ok, dht_ok);
// OLED 刷新(每 1 秒一次,减少 I2C 负担)
static unsigned long last_oled_ms = 0;
if (millis() - last_oled_ms >= OLED_REFRESH_MS)
{
last_oled_ms = millis();
oled.update(soil_pct, temperature, humidity, lux,
controller.get_state_name());
}
// 串口命令(校准/测试/急停)
handle_serial_command();
delay(50); // 给串口一点处理时间
}
src下创建的actuators文件夹(用于实现水泵开关)
src/actuators/relay_pump.cpp
#include "relay_pump.h"
#include <Arduino.h>
// ===== 构造函数 =====
RelayPump::RelayPump(uint8_t pin)
: relay_pin(pin),
pump_on(false)
{
}
// ===== 初始化:先断电 =====
void RelayPump::begin()
{
pinMode(relay_pin, OUTPUT);
// 上电默认低电平 → 继电器不吸合 → 水泵不转(安全保护)
digitalWrite(relay_pin, LOW);
Serial.printf("✅ RelayPump: 继电器 IN = GPIO%d,已设为关闭状态\n", relay_pin);
Serial.println(" 接线提醒:继电器 VCC→5V,GND→共地,IN→GPIO16");
Serial.println(" 5V+ → 继电器 COM,继电器 NO → 水泵+,电源 GND → 水泵−");
}
// ===== 开泵 =====
void RelayPump::turn_on()
{
digitalWrite(relay_pin, HIGH);
pump_on = true;
}
// ===== 关泵 =====
void RelayPump::turn_off()
{
digitalWrite(relay_pin, LOW);
pump_on = false;
}
src/actuators/relay_pump.h
#ifndef RELAY_PUMP_H
#define RELAY_PUMP_H
#include <stdint.h>
/**
* @brief 继电器 + 潜水泵执行器类
*
* 继电器是"电控开关":给它一个高电平,它就吸合(内部触点接通),
* 水泵的 5V 供电回路就通了;给低电平就断开。
*
* ⚠️ 本类只管"开/关",不管浇多久——浇水时长和节奏
* 由 WateringController(控制器)负责。
*/
class RelayPump
{
private:
uint8_t relay_pin; // 继电器 IN 引脚
bool pump_on; // 当前泵是否通电
public:
/**
* @brief 构造函数
* @param pin 继电器 IN 引脚(建议 GPIO16)
*/
RelayPump(uint8_t pin);
/**
* @brief 初始化:上电强制断电(安全第一)
*/
void begin();
/**
* @brief 开泵(继电器吸合)
*/
void turn_on();
/**
* @brief 关泵(继电器断开)
*/
void turn_off();
/**
* @brief 查询当前是否开泵
*/
inline bool is_on() const { return pump_on; }
/**
* @brief 获取继电器引脚号
*/
inline uint8_t get_pin() const { return relay_pin; }
};
#endif
src下创建的controllers文件夹
src/controllers/watering_controller.cpp
#include "watering_controller.h"
#include "config.h"
#include <Arduino.h>
// ===== 构造函数 =====
WateringController::WateringController(RelayPump &pump_ref)
: pump(pump_ref),
state(WateringState::MONITOR),
burst_start_ms(0),
session_on_ms(0),
daily_on_ms(0),
last_water_end_ms(0),
last_day_index(0),
last_tick_ms(0),
last_log_ms(0),
low_light_start_ms(0)
{
}
// ===== 初始化 =====
void WateringController::begin()
{
last_tick_ms = millis();
last_day_index = last_tick_ms / DAY_LENGTH_MS;
set_pump(false); // 上电默认不浇水(安全保护)
Serial.println("✅ WateringController: 自动浇水控制就绪(上电默认不浇水)");
}
// ===== 统一开关泵 =====
void WateringController::set_pump(bool on)
{
if (on)
pump.turn_on();
else
pump.turn_off();
}
// ===== 主更新函数 =====
void WateringController::update(int soil_pct, float temp_c, float humidity,
float lux, bool soil_ok, bool dht_ok)
{
unsigned long now = millis();
unsigned long delta = now - last_tick_ms;
last_tick_ms = now;
// 每日上限:按开机后的 24 小时滚动重置
// (ESP32 没有实时时钟,重启会重新计时,见教程说明)
unsigned long day_index = now / DAY_LENGTH_MS;
if (day_index != last_day_index)
{
last_day_index = day_index;
daily_on_ms = 0;
Serial.println("🔄 新的 24 小时周期开始,每日浇水上限已重置");
}
// ===== 安全保护 1:传感器故障 → 立即停泵,禁用自动浇水 =====
if (!soil_ok || !dht_ok)
{
if (state == WateringState::PUMP_ON || state == WateringState::SOAKING)
{
set_pump(false);
Serial.println("🚨 浇水过程中传感器故障!已紧急停泵");
}
if (state != WateringState::FAULT)
{
state = WateringState::FAULT;
Serial.println("🚨 传感器故障:自动浇水已禁用(读数恢复后自动解除)");
}
return;
}
// 故障恢复 → 回到监测
if (state == WateringState::FAULT)
{
state = WateringState::MONITOR;
Serial.println("✅ 传感器恢复正常,自动浇水已重新启用");
}
// 低光照计时:连续低于阈值才提示,避免开关灯瞬间刷屏
if (lux < LIGHT_WARN_LUX)
{
if (low_light_start_ms == 0)
low_light_start_ms = now;
}
else
{
low_light_start_ms = 0;
}
// ===== 温度修正(高温更勤快、低温少浇防冻)=====
int start_pct = WATER_START_PCT;
if (temp_c >= TEMP_HOT)
start_pct = min(WATER_START_PCT + 10, 60);
if (temp_c <= TEMP_COLD)
start_pct = max(WATER_START_PCT - 10, 25);
switch (state)
{
// ---------- 监测状态 ----------
case WateringState::MONITOR:
{
bool interval_ok = (now - last_water_end_ms) >= MIN_INTERVAL_MS;
bool daily_ok = daily_on_ms < DAILY_LIMIT_MS;
bool emergency_dry = soil_pct < WATER_EMERGENCY_PCT;
bool need_water = soil_pct < start_pct;
// 启动条件:土壤偏干 + 距上次浇水够久 + 今日没超上限
if (daily_ok && (emergency_dry || (interval_ok && need_water)))
{
state = WateringState::PUMP_ON;
burst_start_ms = now;
session_on_ms = 0;
set_pump(true);
Serial.printf("💧 开始浇水:土壤 %d%% %s(启动阈值 %d%%)\n",
soil_pct, emergency_dry ? "极干🚨" : "偏干", start_pct);
}
else if (now - last_log_ms >= STATUS_PRINT_MS)
{
last_log_ms = now;
log_status(soil_pct, temp_c, humidity, lux);
}
break;
}
// ---------- 正在浇水 ----------
case WateringState::PUMP_ON:
{
// 土壤达到目标湿度 → 结束本次浇水
if (soil_pct >= WATER_STOP_PCT)
{
finish_session("土壤已达标");
break;
}
// 连续运行达到上限 → 转渗水停顿
if (now - burst_start_ms >= PUMP_BURST_MS)
{
set_pump(false);
state = WateringState::SOAKING;
Serial.printf("⏸️ 连续浇水 %lu 秒,暂停 %lu 秒让水渗入\n",
(unsigned long)(PUMP_BURST_MS / 1000),
(unsigned long)(PUMP_SOAK_MS / 1000));
break;
}
// 单次累计上限 / 每日上限
if (session_on_ms + delta >= SESSION_MAX_ON_MS)
{
finish_session("已达单次浇水上限");
break;
}
if (daily_on_ms + delta >= DAILY_LIMIT_MS)
{
finish_session("已达每日浇水上限");
break;
}
// 正常累计运行时长
session_on_ms += delta;
daily_on_ms += delta;
break;
}
// ---------- 渗水停顿 ----------
case WateringState::SOAKING:
{
// 停顿期间土壤达标 → 结束
if (soil_pct >= WATER_STOP_PCT)
{
finish_session("土壤已达标");
break;
}
// 累计达到上限 → 结束
if (session_on_ms >= SESSION_MAX_ON_MS || daily_on_ms >= DAILY_LIMIT_MS)
{
finish_session("已达浇水上限");
break;
}
// 停顿时间够了 → 继续浇水
if (now - burst_start_ms >= PUMP_BURST_MS + PUMP_SOAK_MS)
{
burst_start_ms = now;
state = WateringState::PUMP_ON;
set_pump(true);
Serial.println("▶️ 渗水结束,继续浇水");
}
break;
}
// ---------- 冷却(等最小间隔)----------
case WateringState::COOLDOWN:
if ((now - last_water_end_ms) >= MIN_INTERVAL_MS)
{
state = WateringState::MONITOR;
}
break;
case WateringState::FAULT:
// 已在上面统一处理
break;
}
}
// ===== 结束本次浇水 =====
void WateringController::finish_session(const char *reason)
{
set_pump(false);
state = WateringState::COOLDOWN;
last_water_end_ms = millis();
Serial.printf("🛑 本次浇水结束(%s)\n", reason);
Serial.printf(" 本次累计运行 %lu 秒 | 今日累计 %lu 秒\n",
session_on_ms / 1000, daily_on_ms / 1000);
}
// ===== 急停 =====
void WateringController::emergency_stop()
{
set_pump(false);
state = WateringState::COOLDOWN;
last_water_end_ms = millis();
Serial.println("🛑 急停已执行:水泵关闭,进入冷却间隔");
}
// ===== 状态中文名(OLED 显示用)=====
const char *WateringController::get_state_name() const
{
switch (state)
{
case WateringState::MONITOR:
return "监测中";
case WateringState::PUMP_ON:
return "浇水中";
case WateringState::SOAKING:
return "渗水中";
case WateringState::COOLDOWN:
return "冷却中";
case WateringState::FAULT:
return "故障!";
}
return "未知";
}
// ===== 测试水泵 =====
void WateringController::test_pump(unsigned long ms)
{
if (state != WateringState::MONITOR)
{
Serial.println("⚠️ 当前不在空闲状态,请先输入 o 急停,再测试水泵");
return;
}
Serial.println("⚠️ 测试模式:水泵将开启数秒!");
Serial.println(" 请确认:泵泡在水里、出水管对着花盆或杯子、人站在旁边");
set_pump(true);
delay(ms);
set_pump(false);
daily_on_ms += ms; // 测试时间也算进每日上限,保持诚实
Serial.println("✅ 测试结束,水泵已关闭");
}
// ===== 打印状态 =====
void WateringController::log_status(int soil_pct, float temp_c, float humidity, float lux)
{
Serial.println("──────────────────────────────");
Serial.printf("🌱 土壤湿度: %d%%\n", soil_pct);
Serial.printf("🌡️ 温度: %.1f℃ 空气湿度: %.1f%%\n", temp_c, humidity);
Serial.printf("☀️ 光照: %.0f lx\n", lux);
Serial.printf("⏱️ 今日已浇水: %lu 秒 / 上限 %lu 秒\n",
daily_on_ms / 1000,
(unsigned long)(DAILY_LIMIT_MS / 1000));
if (low_light_start_ms != 0 &&
millis() - low_light_start_ms >= LIGHT_WARN_MS)
Serial.println("💡 光照已持续偏低 30 分钟,佛手需要充足阳光(建议移到窗边)");
if (temp_c >= TEMP_HOT)
Serial.println("🔥 温度偏高,注意补水");
if (temp_c <= TEMP_COLD)
Serial.println("❄️ 温度偏低,注意防冻,减少浇水");
Serial.println("──────────────────────────────");
}
src/controllers/watering_controller.h
#ifndef WATERING_CONTROLLER_H
#define WATERING_CONTROLLER_H
#include <stdint.h>
#include "actuators/relay_pump.h"
/**
* @brief 浇水状态(自动浇水状态机的五种状态)
*/
enum class WateringState
{
MONITOR, // 监测中,泵关;判断土壤湿度是否该浇水
PUMP_ON, // 正在浇水(泵开)
SOAKING, // 间歇停顿(泵关),让水渗进土壤
COOLDOWN, // 本次浇水结束,等待最小间隔(防频繁浇水)
FAULT // 传感器故障,禁止自动浇水(安全保护)
};
/**
* @brief 浇水状态机(自动浇水的大脑)
*
* 状态说明:
* - MONITOR 监测中,泵关;判断土壤湿度是否该浇水
* - PUMP_ON 正在浇水(泵开)
* - SOAKING 间歇停顿(泵关),让水渗进土壤
* - COOLDOWN 本次浇水结束,等待最小间隔(防频繁浇水)
* - FAULT 传感器故障,禁止自动浇水(安全保护)
*
* 保护机制(全部自动执行):
* 1. 传感器故障 → 立即停泵,禁用自动浇水
* 2. 每次连续浇水最多 10 秒,停 20 秒渗水
* 3. 一次浇水累计最多 60 秒
* 4. 两次浇水最小间隔 4 小时
* 5. 每天累计浇水最多 10 分钟
* 6. 上电默认不浇水
*/
class WateringController
{
private:
RelayPump &pump; // 引用继电器/水泵对象
WateringState state; // 当前状态
unsigned long burst_start_ms; // 本次连续浇水开始时间
unsigned long session_on_ms; // 本次浇水累计泵运行毫秒
unsigned long daily_on_ms; // 今日累计泵运行毫秒
unsigned long last_water_end_ms; // 上次浇水结束时间(最小间隔用)
unsigned long last_day_index; // 24 小时滚动重置每日上限
unsigned long last_tick_ms; // 上一帧时间(累计时长用)
unsigned long last_log_ms; // 状态打印节流
unsigned long low_light_start_ms; // 低光照开始时间(连续 30 分钟才提示)
/**
* @brief 开关泵(内部统一入口,方便以后加日志/保护)
*/
void set_pump(bool on);
/**
* @brief 结束本次浇水:关泵 + 进入冷却 + 打印总结
*/
void finish_session(const char *reason);
/**
* @brief 打印一行状态(每 60 秒一次)
*/
void log_status(int soil_pct, float temp_c, float humidity, float lux);
public:
/**
* @brief 构造函数
* @param pump_ref 继电器/水泵对象(引用,本类只控制它)
*/
explicit WateringController(RelayPump &pump_ref);
/**
* @brief 初始化:上电默认关泵
*/
void begin();
/**
* @brief 每一帧调用一次(主循环里)
* @param soil_pct 土壤湿度百分比(0~100)
* @param temp_c 温度(℃)
* @param humidity 空气湿度(%)
* @param lux 光照(lx)
* @param soil_ok 土壤传感器读数是否有效
* @param dht_ok DHT22 读数是否有效
*/
void update(int soil_pct, float temp_c, float humidity, float lux,
bool soil_ok, bool dht_ok);
/**
* @brief 急停(串口命令 o):立即关泵并进入冷却
*/
void emergency_stop();
/**
* @brief 测试水泵(串口命令 t):开泵若干毫秒后关闭
* @param ms 测试时长(默认 3000)
*/
void test_pump(unsigned long ms);
/**
* @brief 获取当前状态
*/
inline WateringState get_state() const { return state; }
/**
* @brief 获取当前状态的中文名称(供 OLED/串口显示)
* @return 例如 "监测中" / "浇水中" / "渗水中" / "冷却中" / "故障!"
*/
const char *get_state_name() const;
/**
* @brief 获取今日累计浇水毫秒数
*/
inline unsigned long get_daily_on_ms() const { return daily_on_ms; }
};
#endif
src下创建的display文件夹
src/display/oled_display.cpp
#include "oled_display.h"
#include <Arduino.h>
// ===== 构造函数 =====
OledDisplay::OledDisplay(uint8_t sda, uint8_t scl)
: u8g2(U8G2_R0, U8X8_PIN_NONE, scl, sda),
sda_pin(sda),
scl_pin(scl),
ready(false)
{
}
// ===== 初始化 =====
bool OledDisplay::begin()
{
// 1. 开启 I2C 总线
Wire.begin(sda_pin, scl_pin);
// 2. 探测 OLED 地址:多数模块是 0x3C,个别模块是 0x3D
uint8_t found_addr = 0;
Wire.beginTransmission(0x3C);
if (Wire.endTransmission() == 0)
{
found_addr = 0x3C;
}
else
{
Wire.beginTransmission(0x3D);
if (Wire.endTransmission() == 0)
found_addr = 0x3D;
}
if (found_addr == 0)
{
Serial.println("❌ OLED 初始化失败!");
Serial.println(" 请检查:VCC→3.3V,GND→GND,SDA→GPIO21,SCL→GPIO22");
ready = false;
return false;
}
// 3. 如果是 0x3D,告诉 u8g2 改用这个地址(u8g2 内部用 8 位地址)
if (found_addr == 0x3D)
u8g2.setI2CAddress(0x3D << 1);
// 4. 初始化显示,选择 12px 中文字体(覆盖完整 GB2312 字库)
u8g2.begin();
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
u8g2.setFontPosTop();
ready = true;
Serial.printf("✅ OLED 初始化成功(地址 0x%02X,u8g2 库)\n", found_addr);
// 5. 开机画面
u8g2.firstPage();
do
{
u8g2.drawUTF8(0, 24, "佛手系统 启动中");
} while (u8g2.nextPage());
return true;
}
// ===== 刷新一屏数据 =====
void OledDisplay::update(int soil_pct, float temp_c, float humidity,
float lux, const char *state_text)
{
if (!ready)
return;
u8g2.firstPage();
do
{
char line[40];
// 第 1 行:土壤湿度 + 当前状态(12px 字高,每行间隔 14px)
snprintf(line, sizeof(line), "土壤:%3d%% %s", soil_pct, state_text);
u8g2.drawUTF8(0, 0, line);
// 第 2 行:温度
snprintf(line, sizeof(line), "温度:%5.1f C", temp_c);
u8g2.drawUTF8(0, 14, line);
// 第 3 行:空气湿度
snprintf(line, sizeof(line), "空气:%4.0f %%", humidity);
u8g2.drawUTF8(0, 28, line);
// 第 4 行:光照
snprintf(line, sizeof(line), "光照:%5.0f lx", lux);
u8g2.drawUTF8(0, 42, line);
} while (u8g2.nextPage());
}
src/display/oled_display.h
#ifndef OLED_DISPLAY_H
#define OLED_DISPLAY_H
#include <stdint.h>
#include <Wire.h>
#include <U8g2lib.h>
/**
* @brief 0.96 寸 OLED 显示屏模块(SSD1306 驱动,4 针 I2C)
*
* 接线:VCC→3.3V,GND→GND,SDA→GPIO21,SCL→GPIO22
* 它和 BH1750 共用同一组 I2C 引脚:BH1750 地址 0x23,OLED 地址 0x3C,
* 两个设备地址不同,互不干扰,这就是 I2C 总线"一线挂多设备"的好处。
*
* 显示库:u8g2(作者 Oliver Kraus,GitHub: github.com/olikraus/u8g2),
* 自带完整 GB2312 中文字库,所以屏幕能直接显示中文。
*/
class OledDisplay
{
private:
// u8g2 显示对象:128x64 分辨率,硬件 I2C
// 参数顺序:旋转方向、复位脚(无)、SCL、SDA
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2;
uint8_t sda_pin; // SDA 引脚(GPIO21)
uint8_t scl_pin; // SCL 引脚(GPIO22)
bool ready; // 是否初始化成功
public:
/**
* @brief 构造函数
* @param sda SDA 引脚(GPIO21)
* @param scl SCL 引脚(GPIO22)
*/
OledDisplay(uint8_t sda, uint8_t scl);
/**
* @brief 初始化 OLED(探测地址 → 开机画面),必须在 update() 前调用
* @return true 成功,false 失败(先检查接线)
*/
bool begin();
/**
* @brief 刷新一屏数据(土壤 / 温度 / 空气湿度 / 光照 / 状态)
* @param soil_pct 土壤湿度百分比
* @param temp_c 温度(℃)
* @param humidity 空气湿度(%)
* @param lux 光照(lx)
* @param state_text 浇水状态文字(如"监测中""浇水中")
*/
void update(int soil_pct, float temp_c, float humidity, float lux,
const char *state_text);
/**
* @brief 是否初始化成功
*/
inline bool is_ready() const { return ready; }
};
#endif
src下创建的sensors文件夹
src/sensors/dht_sensor.cpp
#include "dht_sensor.h"
#include <Arduino.h> // 用到delay()、isnan()等Arduino API
// ===== 构造函数实现(对应C的 `DHTSensor_Init` 函数逻辑)=====
DHTSensor::DHTSensor(uint8_t pin, DHTModel model)
// 初始化列表:在构造函数体执行前初始化成员变量(对应C的Init函数里的成员赋值)
: dht(
pin,
model == DHTModel::DHT11 ? DHT11 : DHT22 // 转换枚举为Adafruit库需要的宏
),
last_humidity(0.0f), // 初始化湿度缓存为0
last_temperature(0.0f), // 初始化温度缓存为0
data_pin(pin), // 保存引脚编号
model(model) // 保存传感器型号
{
// 构造函数体:无额外逻辑(初始化已在初始化列表完成)
}
// ===== begin()实现 =====
void DHTSensor::begin()
{
dht.begin(); // 启动底层DHT驱动
delay(2500); // 传感器预热:DHT22上电后需要2秒稳定时间
Serial.printf("✅ DHTSensor: DHT22 数据线 = GPIO%d 初始化完成\n", data_pin);
}
// ===== read() =====
bool DHTSensor::read(float &humidity, float &temperature)
{
// 重试3次
for (uint8_t retry = 0; retry < 3; ++retry)
{
// 1. 先读湿度
humidity = dht.readHumidity();
// DHT22转换数据需要时间,等它转换完再读温度
delay(250);
// 2. 再读温度
temperature = dht.readTemperature();
// 3. 判断是否成功(库里如果读到错误会返回 NAN)
if (!isnan(humidity) && !isnan(temperature))
{
last_humidity = humidity;
last_temperature = temperature;
return true;
}
// 失败了就延时一会再重试
delay(200);
}
// 3次都失败,返回旧缓存
humidity = last_humidity;
temperature = last_temperature;
return false;
}
src/sensors/dht_sensor.h
#ifndef DHT_SENSOR_H
#define DHT_SENSOR_H
#include <DHT.h> // 必须包含:类成员使用了DHT类型,编译器需要知道其定义
/**
* @brief DHT传感器型号枚举(对应C的 `typedef enum { ... } DHTModel_t`)
* @note C++的enum class比C的enum更安全,不会和其他枚举值重名
*/
enum class DHTModel
{
DHT11, // DHT11型号
DHT22 // DHT22型号(AM2302)
};
/**
* @brief DHT传感器类(对应C的 `typedef struct { ... } DHTSensor_t`)
*/
class DHTSensor
{
private:
// ===== 私有成员(对应C结构体里的内部成员,仅类内部可访问)=====
DHT dht; // DHT底层驱动对象(对应C的 `void* dht_handle`)
float last_humidity; // 缓存上一次读取的湿度值(%RH)
float last_temperature; // 缓存上一次读取的温度值(℃)
uint8_t data_pin; // DATA引脚编号
DHTModel model; // 传感器型号
public:
// ===== 公有接口(对应C的对外函数声明,所有文件都可调用)=====
/**
* @brief 构造函数(对应C的 `DHTSensor_Init` 函数)
* @param pin DATA引脚编号
* @param model 传感器型号(DHT11/DHT22)
*/
DHTSensor(uint8_t pin, DHTModel model);
/**
* @brief 启动传感器(对应C的 `DHTSensor_Begin` 函数)
* @note 会等待传感器预热,必须在使用read()前调用
*/
void begin();
/**
* @brief 读取温湿度(带3次重试机制,对应C的 `DHTSensor_Read` 函数)
* @param humidity 输出:湿度值(%RH),读取失败时不变
* @param temperature 输出:温度值(℃),读取失败时不变
* @return true 读取成功,false 3次重试后仍失败
*/
bool read(float &humidity, float &temperature);
// ===== 内联getter(对应C的 `static inline` 函数,直接写在.h里)=====
/**
* @brief 获取缓存的湿度值
* @return 上一次成功读取的湿度值(%RH)
*/
inline float get_humidity() const { return last_humidity; }
/**
* @brief 获取缓存的温度值
* @return 上一次成功读取的温度值(℃)
*/
inline float get_temperature() const { return last_temperature; }
};
#endif // PLANT_MONITOR_DHT_SENSOR_H
src/sensors/hw_soil_moisture_sensor.cpp
#include "hw_soil_moisture_sensor.h"
#include <Arduino.h>
// ===== 构造函数实现 =====
SoilMoistureSensor::SoilMoistureSensor(
uint8_t pin,
int dry_value,
int wet_value,
uint8_t sample_count,
uint16_t sample_delay_ms) // sample:样本
// 初始化列表
: adc_pin(pin),
dry_value(dry_value),
wet_value(wet_value),
last_raw(0),
last_percent(0),
sample_count(sample_count > 0 ? sample_count : 1),
sample_delay_ms(sample_delay_ms)
{
// 构造函数体:无额外逻辑
// ⚠️ 注意:begin() 中才能安全调用 analogSetWidth 等,
// 构造函数阶段硬件可能尚未就绪
}
// ===== 检查是否为 ADC1 引脚 =====
bool SoilMoistureSensor::is_adc1_pin(uint8_t pin) const
{
// Arduino-ESP32 中,ADC1 对应 GPIO32~39
// GPIO32=CH4, GPIO33=CH5, GPIO34=CH6, GPIO35=CH7,
// GPIO36=CH0, GPIO37=CH1, GPIO38=CH2, GPIO39=CH3
switch (pin)
{
case 32:
case 33:
case 34:
case 35:
case 36:
case 37:
case 38:
case 39:
return true;
default:
return false;
}
}
// ===== begin():用 Arduino 层 API 配置 ADC =====
void SoilMoistureSensor::begin()
{
// 1. 检查引脚是否安全(必须是 ADC1,避开 ADC2 与 WiFi 冲突)
if (!is_adc1_pin(adc_pin))
{
Serial.printf("❌ SoilMoistureSensor: GPIO%d 不是 ADC1 引脚!\n",
adc_pin);
Serial.println(" ⚠️ ADC2 引脚(GPIO0/2/4/12~15/25~27)与 WiFi 冲突");
Serial.println(" → 请改用 GPIO32~39");
return;
}
// 2. 设置 ADC 位宽:12 位 → 量程 0~4095
analogSetWidth(12);
// 3. 设置该引脚的衰减(Attenuation)
// 11dB → 约 0~3.1V,覆盖 HW-390 全输出范围
analogSetPinAttenuation(adc_pin, ADC_11db);
// 4. 配置引脚为输入
pinMode(adc_pin, INPUT);
Serial.printf("✅ SoilMoistureSensor: GPIO%d 初始化完成\n", adc_pin);
Serial.printf(" 位宽: 12-bit (0~4095) | 衰减: 11dB (~0~3.1V)\n");
Serial.printf(" 校准值 → 干燥:%d 湿润:%d\n",
dry_value, wet_value);
}
// ===== 单次原始读取 =====
int SoilMoistureSensor::read_raw_once()
{
return analogRead(adc_pin);
}
// ===== 读取(带滤波 + 重试)=====
bool SoilMoistureSensor::read(int &humidity_percent)
{
// 校准值合法性检查
if (dry_value == wet_value)
{
Serial.println("❌ SoilMoistureSensor: dry_value == wet_value,无法映射");
humidity_percent = last_percent;
return false;
}
// 重试 3 次(与 DHT 模块保持一致的重试策略)
for (uint8_t retry = 0; retry < 3; ++retry)
{
long sum = 0;
int valid_samples = 0;
// 多点采样滑动平均
for (uint8_t i = 0; i < sample_count; ++i)
{
int val = read_raw_once();
// analogRead 失败时会返回 0 或负值(取决于 core 版本),
// 我们用 >0 做简单有效性判断
if (val > 0)
{
sum += val;
++valid_samples;
}
if (sample_delay_ms > 0)
delay(sample_delay_ms);
}
if (valid_samples == 0)
{
delay(50);
continue; // 全部失败,重试
}
int raw = sum / valid_samples;
// 限制在 [干, 湿] 两端之间(防止越界)
int lo = (wet_value < dry_value) ? wet_value : dry_value;
int hi = (wet_value > dry_value) ? wet_value : dry_value;
int constrained = constrain(raw, lo, hi);
// ⚠️ 方向自适应映射:
// 常见批次:越湿 → ADC 越小 → wet=100%, dry=0%
// 少数批次:越湿 → ADC 越大 → 反向映射
int percent;
if (dry_value > wet_value)
{
percent = map(constrained, wet_value, dry_value, 100, 0);
}
else
{
percent = map(constrained, dry_value, wet_value, 0, 100);
}
percent = constrain(percent, 0, 100);
// 缓存
last_raw = raw;
last_percent = percent;
humidity_percent = percent;
return true;
}
// 3 次都失败,返回旧缓存
humidity_percent = last_percent;
return false;
}
// ===== 更新校准值 =====
void SoilMoistureSensor::set_calibration(int dry, int wet)
{
dry_value = dry;
wet_value = wet;
Serial.printf("✅ 校准值更新 → 干燥:%d 湿润:%d\n", dry_value, wet_value);
}
void SoilMoistureSensor::set_dry_value(int value)
{
dry_value = value;
}
void SoilMoistureSensor::set_wet_value(int value)
{
wet_value = value;
}
// ===== 获取湿度等级 =====
SoilMoistureLevel SoilMoistureSensor::get_level() const
{
if (last_percent <= 30)
return SoilMoistureLevel::SEVERE_DRY;
if (last_percent <= 44)
return SoilMoistureLevel::SLIGHT_DRY;
if (last_percent <= 65)
return SoilMoistureLevel::OPTIMAL;
if (last_percent <= 75)
return SoilMoistureLevel::SLIGHT_WET;
return SoilMoistureLevel::OVER_WET;
}
// ===== 打印佛手浇水建议 =====
void SoilMoistureSensor::print_advice() const
{
Serial.println();
Serial.println("🌿 [佛手养护建议]");
switch (get_level())
{
case SoilMoistureLevel::SEVERE_DRY:
Serial.println(" 🚨 严重干旱! 佛手根系细弱,久旱必落叶落果");
Serial.println(" → 立即浇透水(浇则浇透)");
Serial.println(" → 配合叶面喷水,空气湿度保持 60%~75%");
break;
case SoilMoistureLevel::SLIGHT_DRY:
Serial.println(" 💧 偏干,接近浇水时机");
Serial.println(" → 1~2 天内浇水");
break;
case SoilMoistureLevel::OPTIMAL:
Serial.println(" ✅ 湿度适宜(45%~65%),无需干预");
Serial.println(" → 遵循'不干不浇,浇则浇透'");
break;
case SoilMoistureLevel::SLIGHT_WET:
Serial.println(" ⚠️ 偏湿,暂停浇水");
Serial.println(" → 加强通风,检查盆底排水孔");
Serial.println(" → 佛手怕涝,宁可偏干不可过湿");
break;
case SoilMoistureLevel::OVER_WET:
Serial.println(" 🚨 过湿! 烂根风险!");
Serial.println(" → 立即停止浇水!");
Serial.println(" → 检查土壤透气性(推荐配比:腐殖土60%+河沙30%+珍珠岩10%)");
Serial.println(" → 盆底加陶粒/碎瓦片滤水层");
break;
}
Serial.println();
}
src/sensors/hw_soil_moisture_sensor.h
#ifndef HW_SOIL_MOISTURE_SENSOR_H
#define HW_SOIL_MOISTURE_SENSOR_H
#include <stdint.h> // uint8_t, int, uint16_t
/**
* @brief 土壤湿度等级枚举
* @note 该百分比是 HW-390 经干/湿土两点校准后线性映射的值,
* 并非农业气象意义上的"土壤相对含水量",仅用于本系统阈值判断。
*
* 阈值依据(结合金华佛手栽培特性):
* - 佛手"怕涝忌积水、不耐旱",浅根盆栽烂根风险高
* - 参考《金华佛手》地方标准"不干不浇、浇则浇透"
* - 参考农业墒情 0~20cm 相对含水量 60%~80% 适宜区间,
* 因佛手怕涝,本系统将上限适当下调
*/
enum class SoilMoistureLevel
{
SEVERE_DRY, // ≤30% 严重干旱,必须立即浇水
SLIGHT_DRY, // 31~44% 偏干,建议 1~2 天内浇水
OPTIMAL, // 45~65% ✅ 佛手最适宜区间
SLIGHT_WET, // 66~75% 偏湿,暂停浇水、加强通风
OVER_WET // >75% 过湿,烂根风险,立即停止浇水
};
/**
* @brief 电容式土壤湿度传感器类(HW-390 v2.0)
*
* @details
* HW-390 利用土壤介电常数变化→电容值变化→输出电压变化。
* 常见批次:越湿润 → 电压越低 → ADC 值越小(你的实测:
* 空气 3310、水中 1080,就是这种方向)。
* 少数批次方向相反,本类已做了方向自适应,两种都能用。
*
* 本类封装了:
* - ADC 配置(Arduino 层 API:analogSetWidth / analogSetPinAttenuation)
* - 多点采样滑动平均滤波
* - 干/湿土两点校准 → 百分比映射(支持运行时更新校准值)
* - 佛手栽培适宜区间判断
*
* ⚠️ 引脚必须用 ADC1(GPIO32~39),因 ADC2 与 WiFi 冲突
*/
class SoilMoistureSensor
{
private:
// ===== 私有成员 =====
uint8_t adc_pin; // ADC 引脚编号(推荐 GPIO34 = ADC1_CH6)
int dry_value; // 校准:干燥时(空气中/干土)的 ADC 值
int wet_value; // 校准:湿润时(水中/湿土)的 ADC 值
int last_raw; // 缓存上一次成功读取的原始 ADC 值
int last_percent; // 缓存上一次成功计算的湿度百分比
uint8_t sample_count; // 每次读取的采样次数(滑动平均)
uint16_t sample_delay_ms; // 每次采样间隔(ms),用于降低噪声
// ===== 内部辅助函数 =====
/**
* @brief 检查引脚是否属于 ADC1(GPIO32~39)
* @return true 是 ADC1 引脚,false 不是(ADC2 或与 WiFi 冲突)
*/
bool is_adc1_pin(uint8_t pin) const;
/**
* @brief 单次读取原始 ADC 值(调用 Arduino 的 analogRead)
*/
int read_raw_once();
public:
// ===== 构造函数 =====
/**
* @brief 构造土壤湿度传感器对象
* @param pin ADC 引脚(推荐 GPIO34 = ADC1_CH6)
* @param dry_value 干燥基准 ADC 值(默认 3310,可运行时校准)
* @param wet_value 湿润基准 ADC 值(默认 1080,可运行时校准)
* @param sample_count 采样次数,默认 16
* @param sample_delay_ms 采样间隔 ms,默认 10
*
* @note ⚠️ 引脚必须用 ADC1(GPIO32~39),否则 WiFi 启用后读数异常
*/
SoilMoistureSensor(
uint8_t pin,
int dry_value,
int wet_value,
uint8_t sample_count = 16,
uint16_t sample_delay_ms = 10);
// ===== 公有接口 =====
/**
* @brief 初始化 ADC(配置位宽 + 衰减,全部使用 Arduino 层 API)
* @note 必须在 read() 前调用
*/
void begin();
/**
* @brief 读取土壤湿度(带滤波和重试)
* @param humidity_percent 输出:湿度百分比 0~100%
* @return true 读取并映射成功,false 校准值非法或连续失败
*/
bool read(int &humidity_percent);
/**
* @brief 更新干/湿校准值(串口校准命令 d / w 会调用)
* @param dry 干燥基准 ADC 值
* @param wet 湿润基准 ADC 值
*/
void set_calibration(int dry, int wet);
/**
* @brief 单独更新干燥校准值
*/
void set_dry_value(int value);
/**
* @brief 单独更新湿润校准值
*/
void set_wet_value(int value);
/**
* @brief 获取当前湿度等级(供主程序做浇水决策)
*/
SoilMoistureLevel get_level() const;
/**
* @brief 打印佛手浇水建议到 Serial
*/
void print_advice() const;
// ===== 内联 getter =====
inline int get_last_raw() const { return last_raw; }
inline int get_last_percent() const { return last_percent; }
inline int get_dry_value() const { return dry_value; }
inline int get_wet_value() const { return wet_value; }
};
#endif
src/sensors/light_sensor.cpp
#include "light_sensor.h"
#include <Arduino.h>
// ===== 构造函数 =====
LightSensor::LightSensor(uint8_t sda, uint8_t scl)
: sda_pin(sda),
scl_pin(scl),
last_lux(0.0f),
initialized(false)
{
}
// ===== 初始化 =====
bool LightSensor::begin()
{
// 1. 让 ESP32 的 I2C 硬件使用我们指定的两个引脚
// (claws 库只负责读写 BH1750,不负责初始化 Wire)
Wire.begin(sda_pin, scl_pin);
// 2. 检测 BH1750 是否在线(库内部向地址 0x23 发握手信号)
if (!light_meter.begin())
{
Serial.println("❌ BH1750 初始化失败!");
Serial.println(" 请检查:VCC→3.3V,GND→GND,SDA→GPIO21,SCL→GPIO22");
initialized = false;
return false;
}
// 3. 高分辨率连续模式首次测量需要约 120ms,等它准备好再读
delay(200);
initialized = true;
Serial.println("✅ BH1750 光照传感器初始化成功(claws/BH1750 库)");
return true;
}
// ===== 读取光照 =====
bool LightSensor::read(float &lux)
{
if (!initialized)
{
lux = last_lux;
return false;
}
for (uint8_t retry = 0; retry < 3; ++retry)
{
float value = light_meter.readLightLevel();
// 有效判断:库在 I2C 读失败时返回负值(-1),不是离谱大数。
// BH1750 最大合法读数约 54612 lx(饱和值),用 60000 兜底,
// 模块没接好时可能读到 0xFFFF 转换出的异常值
if (value >= 0.0f && value <= 60000.0f)
{
last_lux = value;
lux = value;
return true;
}
delay(100); // 稍等再重试
}
// 3 次失败,返回旧缓存
lux = last_lux;
return false;
}
src/sensors/light_sensor.h
#ifndef LIGHT_SENSOR_H
#define LIGHT_SENSOR_H
#include <stdint.h>
#include <Wire.h>
#include <BH1750.h>
/**
* @brief BH1750 光照强度传感器类(I2C 接口)
*
* 用途:测量环境光照(单位 lx 勒克斯),在本系统中只做"提醒":
* 光照长期不足时提示你把佛手挪到更亮的地方,不直接控制浇水。
*
* 底层使用 claws/BH1750 库(作者 Christopher Laws,MIT 协议),
* 已在 platformio.ini 的 lib_deps 里声明,第一次编译会自动下载;
* 如果下载失败,把库压缩包手动放进 lib/BH1750/ 文件夹即可(教程 7.3)。
*
* 对应知识点:
* - I2C 总线:只用 SDA(数据)和 SCL(时钟)两根线,
* 就能和一个或多个设备通信;每根线需要接上拉电阻
* (BH1750 模块板上通常已自带)。
* - lx(勒克斯):照度单位。晴天户外可达几万 lx,
* 室内窗边通常几千 lx,普通室内灯光只有几百 lx。
*/
class LightSensor
{
private:
uint8_t sda_pin; // SDA 引脚(默认 GPIO21)
uint8_t scl_pin; // SCL 引脚(默认 GPIO22)
BH1750 light_meter; // BH1750 库对象(默认 I2C 地址 0x23)
float last_lux; // 缓存上次成功读数
bool initialized; // 是否初始化成功
public:
/**
* @brief 构造函数
* @param sda SDA 引脚(GPIO21)
* @param scl SCL 引脚(GPIO22)
*/
LightSensor(uint8_t sda, uint8_t scl);
/**
* @brief 初始化 I2C 与 BH1750,必须在 read() 前调用
* @return true 成功,false 失败(先检查接线)
*/
bool begin();
/**
* @brief 读取光照强度
* @param lux 输出:光照值(lx)
* @return true 成功,false 失败(使用缓存值)
*/
bool read(float &lux);
/**
* @brief 获取缓存的最近一次光照值
*/
inline float get_lux() const { return last_lux; }
/**
* @brief 是否初始化成功
*/
inline bool is_ready() const { return initialized; }
};
#endif
src下的文件
src/calibration_store.cpp
#include "calibration_store.h"
#include <Arduino.h>
// ===== 构造函数:只初始化状态,不操作硬件 =====
CalibrationStore::CalibrationStore()
: ready(false)
{
}
// ===== 打开 NVS 命名空间 =====
void CalibrationStore::begin()
{
// 第一个参数是"命名空间"(最多15个字符),相当于文件柜的抽屉名
// 第二个参数 false 表示"可读可写"
prefs.begin("foshou", false);
ready = true;
Serial.println("✅ CalibrationStore: 校准值存储(NVS)就绪");
}
int CalibrationStore::load_dry(int default_value)
{
if (!ready)
return default_value;
return prefs.getInt("soil_dry", default_value);
}
int CalibrationStore::load_wet(int default_value)
{
if (!ready)
return default_value;
return prefs.getInt("soil_wet", default_value);
}
void CalibrationStore::save_dry(int value)
{
if (ready)
prefs.putInt("soil_dry", value);
}
void CalibrationStore::save_wet(int value)
{
if (ready)
prefs.putInt("soil_wet", value);
}
void CalibrationStore::clear()
{
if (ready)
{
prefs.remove("soil_dry");
prefs.remove("soil_wet");
}
}
src/calibration_store.h
#ifndef CALIBRATION_STORE_H
#define CALIBRATION_STORE_H
#include <Preferences.h>
/**
* @brief 校准值持久化存储(ESP32 内置 NVS 闪存)
*
* 作用:把土壤传感器"干燥/湿润"校准值保存起来,
* 断电重启后不用重新校准。
*
* 对应知识点:NVS(Non-Volatile Storage,非易失性存储)
* 是 ESP32 芯片里的一小块闪存,断电后数据不丢失,
* 类似 U 盘;Preferences 是 Arduino 封装好的读写接口。
*/
class CalibrationStore
{
private:
Preferences prefs; // Preferences 底层对象
bool ready; // 是否初始化成功
public:
CalibrationStore();
/**
* @brief 打开存储(必须在 load/save 之前调用)
*/
void begin();
/**
* @brief 读取保存的干燥值;没有保存过则返回 default_value
*/
int load_dry(int default_value);
/**
* @brief 读取保存的湿润值;没有保存过则返回 default_value
*/
int load_wet(int default_value);
/**
* @brief 保存干燥值
*/
void save_dry(int value);
/**
* @brief 保存湿润值
*/
void save_wet(int value);
/**
* @brief 清除保存的校准值(恢复出厂默认)
*/
void clear();
};
#endif
src/config.h
#ifndef CONFIG_H
#define CONFIG_H
// ================================================================
// 金华佛手自动浇水系统 —— 配置中心
// 所有"以后想改的数值"都集中在这里,新手只改这个文件就够了
// ================================================================
// ---------- 引脚定义(改这里之前先看教程接线图) ----------
#define PIN_DHT22 4 // DHT22 数据线 → GPIO4
#define PIN_I2C_SDA 21 // BH1750 + OLED 的 SDA → GPIO21
#define PIN_I2C_SCL 22 // BH1750 + OLED 的 SCL → GPIO22
#define PIN_SOIL_ADC 34 // HW-390 AO → GPIO34(ADC1_CH6,只能输入)
#define PIN_RELAY_IN 16 // 继电器 IN → GPIO16(高电平触发)
#define PIN_LED_TEST 17 // 测试 LED(点灯测试用,最终系统可不接)
// ---------- 土壤湿度校准默认值(你 2026-7-31 实测) ----------
// 空气(干燥): 3310 水中(湿润): 1080
// 注意:用串口命令 d / w 校准后,新值会保存到 ESP32 内部存储(NVS),
// 以保存值为准;想恢复默认值,串口发 r
#define SOIL_DEFAULT_DRY 3247
#define SOIL_DEFAULT_WET 1070
// ---------- 浇水参数(含义和理由见教程第七节) ----------
#define WATER_START_PCT 45 // 土壤湿度低于此值 → 准备浇水(%)
#define WATER_STOP_PCT 70 // 土壤湿度高于此值 → 停止浇水(%)
#define WATER_EMERGENCY_PCT 20 // 低于此值 = 极干,无视最小间隔
#define PUMP_BURST_MS 10000 // 泵每次连续运行最多 10 秒
#define PUMP_SOAK_MS 20000 // 每次停 20 秒,让水渗入土壤
#define SESSION_MAX_ON_MS 60000 // 一次浇水累计泵运行最多 60 秒
#define MIN_INTERVAL_MS 14400000 // 两次浇水最小间隔 4 小时
#define DAILY_LIMIT_MS 600000 // 每天累计浇水最多 10 分钟
#define DAY_LENGTH_MS 86400000 // 一天 24 小时(毫秒),用于重置每日上限
// ---------- 温度 / 光照 ----------
#define TEMP_HOT 35.0f // 超过此温度 → 浇水阈值调高(更勤快)
#define TEMP_COLD 10.0f // 低于此温度 → 浇水阈值调低(少浇防冻)
#define LIGHT_WARN_LUX 3000 // 光照低于此值 → 提示光照不足
#define LIGHT_WARN_MS 1800000 // 低光照持续 30 分钟才提示一次
// ---------- 串口 ----------
#define SERIAL_BAUD 9600
#define STATUS_PRINT_MS 60000 // 每 60 秒打印一次状态
// ---------- OLED 显示屏(0.96 寸 SSD1306,4 针 I2C)----------
// 和 BH1750 共用 GPIO21(SDA)/GPIO22(SCL),地址不同互不冲突
#define OLED_REFRESH_MS 1000 // 屏幕每 1 秒刷新一次
#endif
第四步:
type-C数据线连接esp32和电脑
烧录(使用别的文件演示):
点击到之前写完代码的main.cpp
之后vscode左下角有一行图标
点击箭头Upload进行烧录



烧录成功之后可以点击
Serial Monitor进行查看数据

第五步
如何使用:
上述完成代码烧录
将上面连接完成的电源适配器插在插板上(小心,我也不知道会不会被电awa
将type-C线连接esp32和一个充电头后,把充电头插在插板上
水泵连接橡胶管进行吸水和浇水(右边较低的口是进水,左边较高的口是出水)

都做好后就可以使用了
更多推荐


所有评论(0)