3. 灌溉控制器定时启停逻辑实现

定时灌溉控制器的核心结构

定时灌溉控制器要解决的核心问题是:在什么时刻、把哪一路电磁阀打开多久,以及在什么情况下必须允许人手动接管。它不依赖土壤湿度反馈,而是以实时时钟(RTC)为时间基准,把“时段表”翻译成继电器动作。因此实现重点落在三件事上:时段数据的组织方式、RTC 时间与时段表的比较逻辑、以及手动覆盖与自动计划之间的优先级仲裁。

下面这张图给出控制器内部的数据与信号关系:RTC 提供当前时间,时段表提供计划,比较器决定是否进入灌溉,继电器驱动电磁阀,而手动按键通过覆盖仲裁层直接干预输出。

实时时钟 RTC DS3231 / 内部RTC 时:分:秒 + 星期 时段表 start_h, start_m duration_min weekday_mask enabled 调度比较器 now ≥ start ? now < start+dur ? weekday 匹配 ? enabled ? 覆盖仲裁 手动开 / 手动关 优先级:手动 > 自动 超时自动释放 继电器驱动 GPIO → 三极管 续流二极管 电磁阀 24VAC / 12VDC 开 / 关 手动按键 短按开/关 长按强制关 当前时间 计划 自动请求 手动请求 通断 仲裁 覆盖

时段表的数据结构与比较逻辑

时段表用固定长度数组存储,每条记录包含起始时、起始分、持续分钟数、星期掩码和使能位。比较逻辑的关键是把“当前时刻”和“时段区间”都换算成当天的分钟数,这样跨零点的时段也能用同一套公式处理。

typedef struct {
    uint8_t start_h;      // 0-23
    uint8_t start_m;      // 0-59
    uint16_t duration_min;// 1-240
    uint8_t weekday_mask; // bit0=周一 ... bit6=周日
    uint8_t enabled;      // 0/1
} IrrigationSlot;

#define SLOT_COUNT 4
static IrrigationSlot slots[SLOT_COUNT] = {
    { 6, 30, 20, 0x7F, 1 },   // 每天 06:30 灌 20 分钟
    { 18, 0, 15, 0x1F, 1 },   // 周一至周五 18:00 灌 15 分钟
    { 12, 0, 10, 0x60, 0 },   // 周末 12:00,默认关闭
    { 22, 45, 30, 0x7F, 1 }   // 每天 22:45 灌 30 分钟
};

判断某时段当前是否应处于灌溉状态,用分钟数比较即可。注意跨零点时段(如 23:50 开始、持续 30 分钟)需要把结束分钟加上 1440 再取模:

static uint16_t now_minutes(const RtcTime *t) {
    return (uint16_t)t->hour * 60 + t->minute;
}

static uint8_t slot_active(const IrrigationSlot *s, const RtcTime *t) {
    if (!s->enabled) return 0;
    if (!(s->weekday_mask & (1u << t->weekday))) return 0;

    uint16_t now   = now_minutes(t);
    uint16_t start = (uint16_t)s->start_h * 60 + s->start_m;
    uint16_t end   = start + s->duration_min;

    if (end <= 1440) {
        return (now >= start && now < end);
    } else {
        // 跨零点:区间为 [start,1440) ∪ [0,end-1440)
        return (now >= start || now < (end - 1440));
    }
}

主循环里遍历所有时段,只要任意一条处于激活状态,就产生自动灌溉请求。这里用“或”而不是“取第一条”,可以避免时段重叠时漏灌。

继电器驱动与手动覆盖

继电器由 GPIO 经三极管驱动,电磁阀线圈两端必须并联续流二极管,否则关断瞬间的反电动势会击穿三极管。驱动函数只负责把“最终决策”落到引脚上,不参与时间判断:

#define VALVE_PIN 5

static void valve_write(uint8_t on) {
    digitalWrite(VALVE_PIN, on ? HIGH : LOW);
}

// 自动请求:任一时段激活即为 1
static uint8_t auto_request(const RtcTime *t) {
    for (uint8_t i = 0; i < SLOT_COUNT; i++) {
        if (slot_active(&slots[i], t)) return 1;
    }
    return 0;
}

手动覆盖采用“覆盖状态 + 超时释放”的方式:短按切换手动开/关,手动状态一旦生效就屏蔽自动请求;为避免忘记关闭导致长时间漫灌,手动开启后设定一个最长保持时间(例如 30 分钟),到时自动回到自动模式。长按则立即强制关闭并清除手动状态。

typedef enum { MODE_AUTO, MODE_MANUAL_ON, MODE_MANUAL_OFF } CtrlMode;

static CtrlMode mode = MODE_AUTO;
static uint32_t manual_start_ms = 0;
#define MANUAL_MAX_MS (30UL * 60UL * 1000UL)

static void on_button_short_press(void) {
    if (mode == MODE_MANUAL_ON) {
        mode = MODE_MANUAL_OFF;
    } else {
        mode = MODE_MANUAL_ON;
        manual_start_ms = millis();
    }
}

static void on_button_long_press(void) {
    mode = MODE_MANUAL_OFF;   // 强制关,且不自动恢复
}

static uint8_t final_valve_state(const RtcTime *t) {
    if (mode == MODE_MANUAL_ON) {
        if (millis() - manual_start_ms >= MANUAL_MAX_MS) {
            mode = MODE_AUTO;          // 超时释放
        } else {
            return 1;
        }
    }
    if (mode == MODE_MANUAL_OFF) return 0;
    return auto_request(t);
}

主循环只需读取 RTC、计算最终状态并写引脚。RTC 读取建议每秒一次,避免频繁 I²C 访问;继电器状态变化时才写 GPIO,减少无谓翻转。

void loop(void) {
    static uint32_t last = 0;
    if (millis() - last < 1000) return;
    last = millis();

    RtcTime now;
    rtc_read(&now);                 // 填充 hour/minute/weekday
    uint8_t state = final_valve_state(&now);
    valve_write(state);
}

验证与判读

把 RTC 临时改为可手动设置的时间,逐项验证以下场景,观察继电器指示灯与电磁阀动作是否与预期一致:

测试场景设置预期现象
正常时段内时间设为 06:35,时段 1 使能继电器吸合,阀开
时段外时间设为 06:55(已超 20 分钟)继电器释放,阀关
星期不匹配周六,时段 2(周一至周五)不动作
跨零点时段23:55,时段 4 持续 30 分钟阀开,至 00:15 关闭
手动开覆盖时段外短按阀开,自动计划被屏蔽
手动超时手动开后等待 30 分钟自动回到 AUTO,按计划执行
长按强制关时段内长按阀立即关,且不再自动恢复
跨零点时段与星期掩码的配合容易出错:若时段从周日 23:50 开始,跨到周一 00:10,按“开始日”的星期掩码判断即可,不要用当前日去匹配,否则周一凌晨会被误判为不激活。
调试时先把 duration_min 临时改小(如 1 分钟),可以在几分钟内走完“开—关”全过程,比等待真实时长高效得多。