Skip to content

Modules M4/M5 - Air Exchange & CO2

Files: packages/grow_air.yaml (this module), packages/grow_actuators.yaml (switch.grow_actuator_exhaust, switch.grow_actuator_intake_fan, switch.grow_actuator_circulation, switch.grow_actuator_co2_valve - the devices this module drives), packages/grow_core.yaml (sensor.grow_co2_current and the Intake Fan mapping field, grow_map_intake_fan_io) and packages/grow_climate.yaml (the day/night Temperature/RH setpoints this module reuses for its Climate override, and binary_sensor.grow_light_top_active via packages/grow_light.yaml for the CO2 light-window rule).

Note on phase numbering

Per the Phase 0 plan’s own table this is Phase 7 (“Luftaustausch & CO2”), covering Module M4 (Air Exchange) and M5 (CO2). Built now at the user’s direct request (“lets start with the next phase air exchange and co2”), ahead of Phase 5 (Substratüberwachung & Crop-Steering-Basis) - the same pattern as Irrigation and Light both being built ahead of Phase 5 earlier in this project.

What’s implemented

Two independent automations in one package, because the CO2 automation’s decision genuinely depends on the exhaust decision made the same cycle (the safety interlock below) - splitting them risks a race where CO2 reads a stale exhaust state.

Exhaust fan (M4)

Requirements clarified directly with the user before building: the tent runs in exhaust (negative-pressure) mode, not sealed, so the exhaust fan should run on both a routine duty cycle for fresh-air exchange, and get pushed on early if Climate drifts out of range. Added in v2, per a direct follow-up request, a 3-way mode selector (input_select.grow_exhaust_mode) so it isn’t locked to the duty cycle:

  • Off - never auto-commanded on for a normal reason (duty cycle and Climate override are both ignored). The CO2 safety override below still applies even in Off - see “Sealed-room / CO2-supplementation mode” further down for why that’s a deliberate, user-confirmed exception. Still manually toggleable from the Devices/Air Devices tiles, same as any actuator.
  • Duty Cycle (default - v1’s original behavior) - input_number.grow_exhaust_duty_cycle_minutes (total cycle length) and grow_exhaust_duty_on_minutes (how much of that cycle is “on”) - e.g. the defaults (60 / 10) mean 10 minutes on, 50 off, every hour. Computed from minutes-since-midnight modulo the cycle length - simple and deterministic, same style as the Light module’s window math. Plus the Climate override: reuses the existing day/night RH and Temperature setpoints + tolerances from packages/grow_climate.yaml (no second, competing set of setpoints) - if current RH or Temperature drifts above its setpoint+tolerance, the exhaust fan turns on regardless of where the duty cycle currently sits, and stays on until back in range.
  • Always On - runs continuously; the duty cycle and Climate override are both skipped (moot, since it’s already on). Requested directly: “i miss the simple option to run… the exhaust fan” permanently.
  • CO2 safety override (see below) can force it on regardless of mode, taking priority over everything else.

duty_on_minutes <= 0 means “never on via duty cycle” (Climate override can still force it, in Duty Cycle mode); duty_on_minutes >= duty_cycle_minutes means “always on via duty cycle” - both handled explicitly, same edge-case pattern as the Light module’s 0h/24h duration handling.

Intake fan (M4, optional, added in v2)

A second, entirely optional actuator (grow_map_intake_fan_io on Dashboard 1, switch.grow_actuator_intake_fan) for negative-pressure setups that also run a matched intake fan alongside the exhaust fan. Requested directly: “an intake fan that runs either in sync with the exhaust fan, or is disabled when there is no intake fan.” No separate configuration exists for it - every cycle, it’s simply commanded to match whatever the exhaust fan’s own on/off decision was that cycle (exhaust_should_be_on, not the exhaust fan’s raw entity state - mirroring the decision avoids a feedback loop and any read-after-write race). If left unmapped, its proxy switch is unavailable and this automation’s intake_available gate skips it entirely, so it’s simply inert - exactly like every other optional actuator in this project.

Circulation fan (M4)

Its own independent duty cycle (grow_circulation_duty_cycle_minutes / grow_circulation_duty_on_minutes, defaults 30 / 10) - no Climate or CO2 coupling at all, just air movement inside the tent. Runs as a separate automation (automation.grow_air_circulation) so it’s never blocked by or entangled in the exhaust/CO2 interlock logic. Also gained a 3-way mode selector in v2 (input_select.grow_circulation_mode - Off / Duty Cycle / Always On, same shape as Exhaust’s, added for symmetry): Duty Cycle is v1’s original behavior; Always On runs it continuously (requested directly, alongside the exhaust fan’s Always On); Off fully disables it.

Sealed-room / CO2-supplementation mode

Described directly by the user: “when we run the room in sealed environment… to supplement co2, we have to disable the fans, but still want circulation and co2 injection.” No new logic was needed for this - it falls out of the mode selector plus the existing interlock:

  1. Set Exhaust mode to Off (Intake follows automatically, since it mirrors Exhaust’s decision - both stay off).
  2. Leave Circulation on Duty Cycle or Always On - it has no coupling to exhaust or CO2, so it’s unaffected.
  3. Turn on CO2 dosing as normal. co2_should_be_on’s existing not exhaust_should_be_on rule (safety interlock #1 below) now naturally lets CO2 inject continuously (subject to the Light on-window and setpoint/tolerance) instead of being blocked by exhaust venting - nothing else changes.
  4. The hard CO2 safety ppm ceiling (interlock #2 below) still force-vents regardless of Exhaust mode - confirmed directly with the user as a deliberate exception: Off means “don’t run for a normal reason,” not “never run even during a safety condition.” If you need the exhaust fan fully incapable of running (e.g. it’s physically disconnected for the sealed run), un-map it on Dashboard 1 instead.

CO2 (M5) - built fully optional, dormant until hardware exists

The user has no CO2 sensor or injection valve yet - this module’s CO2 logic simply does nothing (no command, no fault check) while either sensor.grow_co2_current or switch.grow_actuator_co2_valve is unmapped, exactly like every other optional actuator in this project (compare packages/grow_climate.yaml’s dehumidifier/humidifier/AC handling for the same “empty mapping = skip silently” pattern). Once a sensor and valve are mapped on Dashboard 1:

  • input_boolean.grow_co2_control_enabled gates dosing (in addition to the module master switch, grow_air_control_enabled - both must be on).
  • input_number.grow_co2_setpoint_ppm / grow_co2_tolerance_ppm control when the valve turns on (below setpoint - tolerance) and off (at or above setpoint).

CO2-to-exhaust safety interlock

The Phase 0 plan itself flags this as a health-risk item (“CO2-Ventil zwingend an Abluft-Status gekoppelt” - CO2 valve mandatorily coupled to exhaust status). Three rules, all specified directly by the user:

  1. No CO2 injection while the exhaust fan is on or being forced on. Injecting into actively-vented air wastes gas and defeats the point of building up ppm - co2_should_be_on is unconditionally false whenever exhaust_should_be_on is true, checked every cycle before the valve is ever commanded.
  2. A hard ppm safety ceiling forces the exhaust fan on and the valve off, independent of the target setpoint - input_number.grow_co2_safety_max_ppm (default 1500 ppm). This check runs whenever a CO2 sensor is mapped and grow_air_control_enabled is on, regardless of whether grow_co2_control_enabled (dosing) is on - a dangerous ppm reading should force ventilation even if this module isn’t the one dosing CO2 (a real sensor could pick up buildup from another source). Raises input_boolean.grow_co2_safety_active and a persistent_notification distinct from the regular fault flags - this one means the device responded fine, but there’s a real safety condition active.
  3. CO2 only ever injects during the Light module’s on-window - binary_sensor.grow_light_top_active (packages/grow_light.yaml, Phase 6) must be on. No point supplementing CO2 when photosynthesis isn’t happening. Reuses the same binary_sensor Climate’s day/night sync (packages/grow_climate.yaml) already depends on.

Fault detection & notification

Every switch.turn_on/turn_off call to the exhaust fan, circulation fan, or CO2 valve is followed by a wait_template confirming it actually reached the commanded state within 15 seconds - same wait-and-verify pattern as Climate and Light, and built on the corrected packages/grow_actuators.yaml proxies (see that file’s own changelog entry - the generic homeassistant.turn_on/turn_off used to silently fail on at least one real device type, fixed the same day this module was built).

This module also applies the stale-fault lesson learned the hard way with Light earlier the same day: needs_on/needs_off are precomputed once per cycle, and every choose: block has an explicit default: that reconciles (clears) any stale fault whenever nothing needed commanding that cycle - whether because the device already matched its desired state, or because it isn’t mapped at all. A fault that resolves itself (device recovers, or you fix the mapping) no longer sits there forever.

Dashboard (Air & CO2 view, Dashboard 2)

A status banner (control state, CO2 safety banner, fault flags including Intake, last decision), a Master Control entities card, an Exhaust card (mode selector + duty cycle fields) with an explainer of its modes, an Intake Fan card (state only - no config, just shows whether it’s mapped and following Exhaust), a Circulation card (mode selector + duty cycle fields), a CO2 card (live ppm + setpoint/tolerance/safety-max, with a banner telling you plainly whether a sensor is mapped yet, and a sealed-room-mode banner when applicable), a 4-tile grid for manual override of all four devices, and a simple beam timeline (native history-graph, same style as the Light view) showing when each device was actually on.

Rollout checklist

  1. Deploy packages/grow_air.yaml, and the updated packages/grow_core.yaml (adds sensor.grow_co2_current and the Intake Fan mapping field) and packages/grow_actuators.yaml (adds switch.grow_actuator_intake_fan)
    • check config, restart (new helper entities and input_selects need one).
  2. Re-import dashboards/dashboard_1_configuration.yaml (adds the Intake Fan mapping row), dashboards/dashboard_2_control_panel.yaml (Air & CO2 view updates) and dashboards/dashboard_3_log.yaml (updated logbook card).
  3. If you haven’t already, confirm Exhaust Fan and Circulation Fan are mapped on Dashboard 1 to your real devices (CO2 Sensor, CO2 Valve, and Intake Fan can all stay unmapped - the module simply won’t do anything with them until they exist).
  4. With grow_air_control_enabled still off, set Exhaust and Circulation modes (default Duty Cycle for both) and your duty-cycle values.
  5. Test manually via the Air & CO2 view’s tile grid (or the Devices view) to confirm the mapped fans actually respond.
  6. Turn grow_air_control_enabled on. Watch Dashboard 3’s “Air & CO2 Configuration & Automation Status” and “Device Activity” logbook cards, plus the Air Timeline chart, for the first day or two to confirm the exhaust fan follows its mode and reacts to Climate drift as expected (in Duty Cycle mode), and that no fault notification appears.
  7. When CO2 hardware arrives: map the sensor and valve on Dashboard 1, set grow_co2_setpoint_ppm / grow_co2_tolerance_ppm / grow_co2_safety_max_ppm to sensible values for your setup, then turn on grow_co2_control_enabled. Watch the CO2 card and Dashboard 3 to confirm dosing only happens during the Light on-window and never while the exhaust fan is running/forced on, and that the safety ceiling genuinely forces exhaust on if you test it (e.g. by temporarily lowering grow_co2_safety_max_ppm below current ppm) - including with Exhaust set to Off, to confirm the safety exception actually works.
  8. For a sealed-room run: set Exhaust mode to Off, leave Circulation on Duty Cycle/Always On, and turn grow_co2_control_enabled on - see “Sealed-room / CO2-supplementation mode” above.