Skip to content

Module M3 - Light

Files: packages/grow_light.yaml (this module), packages/grow_actuators.yaml (switch.grow_actuator_top_light_1..4 and switch.grow_actuator_under_canopy_1..4 - the lights this module drives), packages/grow_core.yaml and packages/grow_mapping_tool.yaml (the 8 mapping fields these actuators proxy).

Note on phase numbering

Per the Phase 0 plan’s own table this is Phase 6 (“Light”), not Phase 5 (“Substrate Monitoring & Crop-Steering Basis” - VWC/EC/Temp trend graphs and generative/vegetative preset templates). Built now at the user’s request, ahead of Phase 5, the same way Phase 4 (irrigation) was previously referred to as “Phase 3” in conversation - noted here for anyone following the plan document, not a blocker to building it in this order.

What’s implemented (Phase 6, v5)

Replaces the old Growzelt Zeitschaltuhr - Licht An/Aus, Growzelt - Undercanopy einschalten/ausschalten, and Blüten Trocknen automations with:

  • One start time + duration (hours) on/off window per light group (Top Lights, Under-Canopy) - e.g. Top Light: start 06:00, run 18h; Under Canopy: start 12:00, run 12h.
  • Up to 4 physical lights per group, each with its own In Use toggle - a slot you don’t have wired up is fully excluded from both the on/off commands and the fault watchdog, instead of always targeting all 4 and reporting a fault for whichever ones are unmapped/unavailable.
  • A simple beam timeline of when each light group was actually on, so you can visually confirm the window is doing what you expect.
  • A fault watchdog: every on/off command to a group’s enabled lights is verified within 15 seconds, and a notification fires (with an automation-managed status flag on the dashboard) if any of them doesn’t confirm.
  • A master switch (grow_light_control_enabled, default off) and a separate Under-Canopy Group Enabled toggle (default off) so nothing runs until you’re ready, and the under-canopy group can be turned off as a whole independent of its own window setting.

Version history / why it looks like this

This module went through five shapes the same day, each corrected from direct user feedback:

  1. v1 gave every grow-tracker phase (Seedling/Vegetative/Flowering) its own on/off input_datetime pair per light group, plus a separate Drying profile - 12+ helper entities across 5 dashboard cards. Live testing showed most of that many new entities as “entity not found” (most likely a missed full restart, needed for genuinely new input_* helpers), and it was simply more configuration than was wanted.
  2. v2 descoped to one on/off cycle per light group and switched to a schedule: helper per group (the same pattern already proven for the irrigation pump schedules - a UI-created weekly block calendar, since a YAML-defined schedule: entity has no editor at all).
  3. v3: per further feedback (“I dont want to use the schedule. I just want to say top light: 18 hours start time 06:00, under canopy 12 hours starting from 12:00 - a little bit similar as we do it on the P1 irrigation window definition”), switched again to a plain start time (input_datetime) + duration in hours (input_number) pair per light group - the exact same “window” shape already used for grow_irrigation_p1_window_start/_end in packages/grow_irrigation.yaml, just expressed as start+duration instead of start+end.
  4. v4: per further feedback (“let’s also rework the control chart… a simple beam chart with under canopy and top light underneath each other”), replaced the 5-range apexcharts-card overlay (4 series sharing one chart) with a simple native history-graph timeline - one row per light group.
  5. v5 (current): per further feedback (“extend the controller to 4 lights (top and undercanopy each) and give me the options to disable unused in the config. otherwise i am getting frequent errors of the not available or not used lights”), extended each group from 2 to 4 slots, added packages/grow_actuators.yaml switch.grow_actuator_top_light_3/_4 and switch.grow_actuator_under_canopy_3/_4 (plus the matching Dashboard 1 mapping fields), and gave every one of the 8 slots its own In Use input_boolean toggle. The automation now builds its target entity list dynamically from only the enabled slots each cycle - a disabled slot is never commanded and never checked for a fault, which is what stops the notification spam for lights that aren’t actually wired up. See “How slots and the fault watchdog interact” below.

Per-grow-phase lighting profiles (a different window during Flowering, etc.) from v1 were not carried forward - can be added later as presets layered on top of this simpler design, if actually wanted.

How start time + duration becomes an on/off window

off time = start time + duration hours, wrapping past midnight the same way the irrigation P1 window does. A few worked examples:

StartDurationEffective window
06:0018hon 06:00 - 24:00 (i.e. through end of day), off 00:00 - 06:00
12:0012hon 12:00 - 24:00, off 00:00 - 12:00
08:0012hon 08:00 - 20:00 (doesn’t reach midnight, no wrap)

duration_hours is clamped 0-24 on the dashboard slider/field: 0 means “never on,” 24 means “always on” - both handled explicitly rather than left to fall out of the wrap math, since a full 24h window has no distinct start/end left to compare against. Verified by dry-run test (including the wrap and both edge-case durations) before shipping. This window applies to the group as a whole - all of a group’s enabled slots share one on/off time, there’s no per-light schedule.

How slots and the fault watchdog interact

Each group’s input_boolean.grow_light_<top|undercanopy>_<N>_enabled (N = 1-4) controls whether switch.grow_actuator_<top_light|under_canopy>_<N> participates at all:

  • Disabled slot: never included in the group’s switch.turn_on/ turn_off target list, never checked by the fault watchdog, never contributes to the “Light Timeline” chart’s on/off aggregate. It’s as if that slot doesn’t exist.
  • Enabled slot: included in every on/off command for its group, and its state is part of the wait-and-verify check - if any enabled slot in the group fails to confirm the commanded state within 15s, that group’s fault flag turns on and the notification names exactly which entities were commanded.
  • A group with zero slots enabled is skipped entirely each cycle - no command, no verification, no fault. Turning off the last enabled slot in a group (or leaving new slots 3-4 off, their default) means that group simply does nothing, silently, rather than erroring.

Slots 1-2 in both groups default enabled (the hardware every earlier version of this module assumed); slots 3-4 default disabled until you map them on Dashboard 1 and switch them on via the “Top Light Slots” / “Under Canopy Slots” cards on the Light view. Dry-run tested including the “zero slots enabled” and “mixed availability” cases.

How the automation decides on/off

On top of triggering on every start-time/duration/slot-toggle change (prompt reaction), a 5-minute heartbeat re-checks both groups’ windows and enabled-slot lists from scratch every cycle and self-corrects any drift - protects against a missed HA restart or reload leaving lights wrong for hours. Same fresh-evaluation-every-cycle rationale as the climate regulation automation (Phase 3) and the irrigation P1/P2 clamp (Phase 4); it matters most for Flowering-stage photoperiod integrity, where unexpected light during the dark period (“light leak”) is a real cultivation risk (hermaphroditism stress), not just cosmetic.

The Under-Canopy group only turns on when both its own window is active and grow_light_undercanopy_enabled is on - the toggle lets you disable the whole group without having to turn off every slot individually.

Fault detection & notification

Every switch.turn_on/turn_off call (targeting only a group’s enabled slots) is followed by a wait_template that confirms all of them actually reached the commanded state within 15 seconds. If any doesn’t (device offline, Zigbee drop, a mapping pointing at a stale entity, etc.):

  • input_boolean.grow_light_top_fault / _undercanopy_fault turns on - shown on the Light view’s status line and Master Control card.
  • A persistent_notification fires with a fixed notification_id (so repeated failures update the same notification instead of spamming a new one every 5-minute cycle), naming exactly which entities were commanded and suggesting you disable the slot if you don’t actually use it.
  • The automation does not give up - the next 5-minute heartbeat (or the next window transition) tries the command again automatically.

The fault clears itself and a persistent_notification.dismiss fires the next time all enabled actuators do confirm the commanded state - no manual “clear fault” step needed, same self-recovering shape as the irrigation substrate failsafe (Phase 4), just without a separate hysteresis threshold since this is a binary confirm/fail rather than a continuous sensor reading.

Historical chart (timeline)

Dashboard 2’s Light view has a simple “beam” timeline - Home Assistant’s native history-graph card, one horizontal row per light group, filled while that group is on, so you can see at a glance exactly when each group was actually on. This is a read-only visualization for confirmation; use the Start Time / Duration fields above it to change when lights turn on/off, and the Slots cards to change which lights count.

Two light-weight template binary_sensors collapse each group’s enabled slots into exactly 1 timeline row: binary_sensor. grow_light_top_active (on if any enabled switch.grow_actuator_top_light_1..4 is on) and binary_sensor.grow_light_undercanopy_active (same for the under-canopy slots) - unavailable specifically means “zero slots enabled for this group,” not a device-communication problem.

Bug fix (2026-08-27): these two entities were originally named “Light: Top Light Active” / “Light: Under-Canopy Active” with no default_entity_id pin. Home Assistant generates a template entity’s entity_id from its name on first creation - unique_id alone does NOT control it - so the actual entity_id ended up as binary_sensor.light_top_light_active / binary_sensor.light_under_canopy_active, not the grow_light_top_active / grow_light_undercanopy_active every other file in this project (Climate’s daytime-follows-light automation, Air’s CO2 light-window check, this view’s own Light Timeline card) reads by that exact hardcoded name. This is the root cause of “the Light Timeline shows no chart at all.” Fixed by adding default_entity_id: to both entities. If you already deployed the pre-fix version, the fix alone won’t move an already-registered entity - see the rollout checklist below for the one-time manual rename.

Wattage % + PPFD estimator (2026-08-27)

Two new optional per-group sensors let you see how hard each light group is currently running, and roughly what PPFD it’s putting out at that dim level, without a permanently-mounted PAR meter.

  • Requires the Energy module’s per-device power sub-metering (Dashboard 1, “Energy Sub-Metering - Light Devices”) to be mapped for at least one slot in the group - this feature reuses those power readings rather than adding a second, separate power mapping.
  • input_number.grow_light_top_max_watts / _undercanopy_max_watts - set this to the group’s combined draw at 100% power across whichever slots you currently have enabled. This is a manually-entered reference value, not auto-detected - if you later enable/disable a slot or change a fixture, update it, or the % figure below will be off.
  • sensor.grow_light_top_power_w / _undercanopy_power_w - live sum of the group’s mapped per-device power sensors from grow_core.yaml. Note this is scoped to just Top Light or just Under Canopy - a different grouping than the Energy module’s own “Light” cost bucket, which combines both groups together for billing purposes.
  • sensor.grow_light_top_percent / _undercanopy_percent - power_w / max_watts * 100, guarded against division by zero both in availability and in the template itself.
  • PPFD calibration: with a PAR meter at a fixed 30cm distance straight down at the canopy, dim the group to 20%, note the reading into input_number.grow_light_top_ppfd_20 (or _undercanopy_ppfd_20), then repeat at 50% and 100% into the matching _ppfd_50/_ppfd_100 fields. Three points is enough for a useful estimate without needing a meter permanently wired in.
  • sensor.grow_light_top_ppfd_estimate / _undercanopy_ppfd_estimate - piecewise-linear interpolation between your three calibration points (through the origin at 0%), evaluated live against the current % figure above. Deliberately kept simple per the confirmed scope: fixed 30cm reference only, no adjustment for actual canopy distance/height - if your canopy moves closer to or further from the light than your calibration distance, the estimate will be off by whatever the inverse-square difference is at your setup.

Dashboard (Light view, Dashboard 2)

A status banner (control state, fault flags, last decision), a Master Control entities card (control switch, under-canopy enable, both fault flags, last decision), a “Top Light” and an “Under Canopy” entities card (start time + duration each), “Top Light Slots” / “Under Canopy Slots” entities cards (4 In-Use toggles each), a 4x2 tile grid for the eight light actuators for manual override, and the “Light Timeline” beam chart.

Rollout checklist

  1. If you already deployed this module before 2026-08-27, do this once before anything else: in Home Assistant, go to Settings -> Devices & Services -> Entities, search for “Top Light Active” and “Under-Canopy Active”. Click each, open its settings (gear icon), and change the Entity ID field to grow_light_top_active / grow_light_undercanopy_active respectively (the binary_sensor. prefix is fixed - you’re only editing the part after the dot). This corrects the entity_id Home Assistant already assigned them, which the new default_entity_id: in the YAML can’t retroactively fix on its own (it only takes effect the first time an entity with that unique_id is ever created). Skip this step entirely on a fresh deploy - it isn’t needed there.
  2. Deploy packages/grow_light.yaml, the updated packages/grow_core.yaml (adds the 4 new mapping fields), packages/grow_mapping_tool.yaml (adds them to the search-based mapping picker), and packages/grow_actuators.yaml (adds the 4 new switch.grow_actuator_* proxies) - check config, restart (new helper entities need one). If you deployed an earlier version of this module (v1’s per-phase helpers, or v2’s schedule.* entities), those are now orphaned and will show as unavailable in Settings -> Helpers - safe to delete.
  3. Re-import dashboards/dashboard_1_configuration.yaml (adds the 4 new mapping rows), dashboards/dashboard_2_control_panel.yaml, and dashboards/dashboard_3_log.yaml.
  4. On Dashboard 1, map Top Light 3/4 and Under Canopy 3/4 only if you actually have that hardware - leave them empty otherwise (an unmapped, disabled slot is fine and expected).
  5. On the Light view’s “Top Light Slots” / “Under Canopy Slots” cards, turn on the In-Use toggle for every slot you actually mapped, and make sure any slot you don’t have stays off (1-2 are on by default; 3-4 are off by default).
  6. Disable the old Growzelt Zeitschaltuhr - Licht An/Aus, Growzelt - Undercanopy einschalten/ausschalten, and Blüten Trocknen automations so they don’t fight this module over the same lights.
  7. With grow_light_control_enabled still off, set each group’s Start Time and Duration.
  8. Test manually via the Light view’s tile grid (or the Devices view) to confirm the mapped lights actually respond.
  9. Once you’re confident, turn grow_light_control_enabled on (and grow_light_undercanopy_enabled on too, if you want that group active). Watch Dashboard 3’s “Light Configuration & Automation Status” and “Device Activity” logbook cards, plus the historical chart, for the first day or two to confirm lights switch exactly when expected, and that no fault notification appears. If one does appear for a slot you don’t actually use, that’s the signal to turn its In-Use toggle off.
  10. Optional - Wattage % / PPFD estimator: if you have power monitoring on your lights, map it under Dashboard 1’s “Energy Sub-Metering - Light Devices” section first. Then, per group, set the Max Wattage field to its 100%-power draw, and calibrate PPFD by taking 3 meter readings at 20/50/100% at a fixed 30cm distance and entering them into the PPFD calibration fields - both now live in Dashboard 1’s “Light Output Calibration” section (moved there 2026-08-29, alongside the other one-time setup fields; Dashboard 2’s Light view keeps only the live measured power, running %, and PPFD estimate readouts). Skip this step entirely if you don’t have power monitoring or a PAR meter - everything else in this module works without it.