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) andgrow_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 frompackages/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:
- Set Exhaust mode to Off (Intake follows automatically, since it mirrors Exhaust’s decision - both stay off).
- Leave Circulation on Duty Cycle or Always On - it has no coupling to exhaust or CO2, so it’s unaffected.
- Turn on CO2 dosing as normal.
co2_should_be_on’s existingnot exhaust_should_be_onrule (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. - 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_enabledgates 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_ppmcontrol 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:
- 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_onis unconditionally false wheneverexhaust_should_be_onis true, checked every cycle before the valve is ever commanded. - 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 andgrow_air_control_enabledis on, regardless of whethergrow_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). Raisesinput_boolean.grow_co2_safety_activeand apersistent_notificationdistinct from the regular fault flags - this one means the device responded fine, but there’s a real safety condition active. - 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
- Deploy
packages/grow_air.yaml, and the updatedpackages/grow_core.yaml(addssensor.grow_co2_currentand the Intake Fan mapping field) andpackages/grow_actuators.yaml(addsswitch.grow_actuator_intake_fan)- check config, restart (new helper entities and input_selects need one).
- Re-import
dashboards/dashboard_1_configuration.yaml(adds the Intake Fan mapping row),dashboards/dashboard_2_control_panel.yaml(Air & CO2 view updates) anddashboards/dashboard_3_log.yaml(updated logbook card). - 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).
- With
grow_air_control_enabledstill off, set Exhaust and Circulation modes (default Duty Cycle for both) and your duty-cycle values. - Test manually via the Air & CO2 view’s tile grid (or the Devices view) to confirm the mapped fans actually respond.
- Turn
grow_air_control_enabledon. 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. - 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_ppmto sensible values for your setup, then turn ongrow_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 loweringgrow_co2_safety_max_ppmbelow current ppm) - including with Exhaust set to Off, to confirm the safety exception actually works. - For a sealed-room run: set Exhaust mode to Off, leave Circulation
on Duty Cycle/Always On, and turn
grow_co2_control_enabledon - see “Sealed-room / CO2-supplementation mode” above.