Timer programs and irrigation modes
Applies to release 3.18.1 · View source at the tag · Markdown
On this page
Bottom line: every zone has a Watering by setting: steering (the engine waters by its
probes, as before), timer (the room's timer programs water it) or off (no shots at all, rescue
shots included). A zone on timer still gets the engine's safety shots: the P3 emergency, the
lights-on watchdog, the minimum-daily floor and a blind zone's copy of a sibling's rescue. Timer
shots go through every safety check a steering shot does: the kill switch, the zone switch, faults,
source water, the daily budget and the pump queue. One planned shot can be skipped from the
timeline. Every program save is checked, previewed, read back and written to the journal.
Getting there
- Dashboard menu: Irrigation › Timer programs (route
#/plan/timers). This page holds the room's programs, the program editor and the Irrigation mode panel (who waters each zone). - Dashboard menu: Irrigation › Timeline (route
#/plan/watering). This shows the planned timer shots and has the Skip this shot button. - With an integration that has no timer programs, the Timer programs page says so and shows no programs. The Timeline still works; only Skip this shot answers "Skipping a shot needs Crop Steering 3.18 or newer."
Irrigation modes (Watering by)

Each zone has one select:
select.crop_steering_<prefix>zone_<n>_irrigation_mode, named Zone N Watering by, with options
steering, timer and off. A new zone starts on steering.
| Mode | What the zone gets |
|---|---|
steering | The engine's shots, as always. Timer programs do not water it, even if it is listed in one. |
timer | The slots of the one active timer program that lists it, plus the engine's safety shots: p3_emergency, watchdog, min_daily and blind_copy_rescue. Every other engine shot is dropped (reason TIMER: the engine's <kind> shot is not given). |
off | Nothing: no engine shots, no timer shots and no rescue shots (reason OFF: this zone is set to no watering). |
Safety-critical points:
offstops rescue shots too. A zone onoffwill not get a P3 emergency or watchdog shot. The page warns: "Off: no shots at all, rescue shots included."- A zone on
timerthat no active program lists gets only the safety shots. The page warns: "No running program covers this zone: it will not be watered." - Steering needs a probe. On the Timer programs page the Steering button is greyed out for a zone with no probe ("Needs a probe in this zone").
- A test shot still runs on any mode,
offincluded. It is the operator's own action. The kill switch and the other checks still apply to it. - The blind fallback shot (
blind_fallback, a steering estimate for a zone with no readable probe) is not kept in timer mode.
Changing a zone's mode
On Irrigation › Timer programs › Irrigation mode, pick the mode, read the sentence that says what will change, then press Switch to … (Steering, Timer or Off) or Keep …. The button uses the zone's display name. The sentences are:
- To timer: "Zone N stops steering and waters only by its timers." If no running program covers the zone, it adds "No running program covers it, so it will not be watered."
- To off: "Zone N gets no shots at all, rescue shots included."
- To steering: "Zone N steers by its probe again; its timers stop watering it."
You can also set the select directly in Home Assistant (select.select_option).
How the controller reads the mode
- The controller reads each zone's select on every pass and remembers the last mode it read in its
state.json. - If the select reads
unknown,unavailableor is missing, the controller uses the last mode it read. A zone staysoffthrough a Home Assistant restart. - If no mode was ever read for a zone (an older integration with no select), the zone runs on
steering, as it always did. - If the integration restores a saved mode it does not recognise, the select reads
unknownuntil someone picks a mode. It does not fall back tosteering.
Mode off compared with the zone switch
Watering by = off | Zone switch switch.crop_steering_<prefix>zone_<n>_enabled off | |
|---|---|---|
| Where it acts | On the decision, before anything opens. The engine's shot and any timer slot are dropped. | A safety check (zone_paused) that holds every shot for the zone. |
| Test shot | Still runs. | Held. |
| A shot already running | Not affected. Only new decisions are dropped. | Stopped early. Alert CS-305, "this zone was set to off". |
| If it cannot be read | The last mode read is used. | Held: an unreadable zone switch counts as off. |
Use the zone switch to pause a zone for work on it. Use mode off when a zone should not be watered
by the engine or a timer, but you still want to be able to give it a test shot.
Mode timer compared with the kill switch
The kill switch (the room's engine switch) holds every shot in the room, timer shots and safety shots included. A timer program never runs while the kill switch is off. Turning the kill switch off during a timer shot stops that shot (CS-305, "the kill switch was set to off").
Timer programs
A room holds any number of timer programs. Each program waters a list of zones for a fixed number of seconds at times set by its type and window.
Program fields
| Field | Required | Meaning |
|---|---|---|
id | No (new) / Yes (change) | Leave it out to add a program. Give it to change an existing one. |
name | No | Shown on the page. Blank becomes Timer. |
type | Yes | recycle, simple or one_off. |
zones | Yes | At least one zone number in this room. Duplicates are removed. |
duration_s | Yes | Seconds each zone runs per shot. Must be greater than 0. |
active | No | true (default) or false. An inactive program never runs. |
window | No | When the program may water. Default {"kind": "all"}. See below. |
interval_min | recycle | Minutes between shots. At least 1. |
sync_time | No (recycle) | HH:MM. The first shot lines up on this time. It must be inside the window. |
frequency_per_day | simple | Shots per day. At least 1. A fraction is cut to a whole number (2.5 becomes 2), not refused. |
start_at | one_off | ISO date and time, for example 2026-10-24T09:00:00+13:00. |
Anything else sent with a program (for example next_at from a list) is not stored. The integration
adds created_at, updated_at and, on delete, deleted_at.
Windows
kind | Window |
|---|---|
all | The whole day, from local midnight. |
lights_on | From the room's lights-on hour to its lights-off hour. |
lights_off | From lights-off to lights-on. |
custom | From from to to, both HH:MM. If to is earlier than from, the window crosses midnight. from and to cannot be the same. |
The lights hours come from the room's number.crop_steering_<prefix>lights_on_hour and
lights_off_hour, or from what setup saved if those cannot be read.
When each type waters
recycle: everyinterval_minminutes from the start of the window, until the window ends. Withsync_time, the first shot is atsync_timeand the next ones follow everyinterval_minminutes until the window ends; no shots fall between the window start andsync_time. If the lights hours later move the window so thatsync_timefalls outside it, the shots start at the start of the window.simple:frequency_per_dayshots spread evenly across the window, the first at its start. For example,frequency_per_day: 4with{"kind": "all"}waters at 00:00, 06:00, 12:00 and 18:00.one_off: one shot for each zone atstart_at.
A wall-clock time that does not exist because of a daylight-saving change is left out.
Example
Two minutes every two hours during lights-on for zones 1 and 2:
{
"name": "Mother room",
"type": "recycle",
"zones": [1, 2],
"duration_s": 120,
"window": {"kind": "lights_on"},
"interval_min": 120,
"active": true
}Rules the integration checks
- A zone can belong to one active program only. A save that would put a zone in two active programs is refused, for example "Zone 3 is already in timer 'Mother room'".
- Every zone must exist in the room ("Zone 9 is not in this room").
- Errors are in plain words, for example "interval must be at least 1 minute", "sync time must be inside the window", "End must be after start".
How a timer shot is sized and checked
- A timer shot asks for
duration_sseconds. The room's maximum shot length still caps it. A capped shot runs, cut, and raises CS-306. - The zone needs a known flow (dripper flow and drippers per plant) and substrate volume. Without them the shot does not run and CS-204 says why.
- The daily budget applies. A shot that would go past the zone's daily maximum is cut to what is left of the budget and runs (logged as "shot clipped Xs -> Ys"). It is held only when less than the minimum shot length is left, and then alert CS-205 (daily cap) is raised. The page words this as "Shots past it are skipped and logged."
- A plan hold does not stop timer shots. Plan holds stop routine steering only.
- Zones wait for the pump only when running them together would go past the pump's capacity
(
max_flow_lph,max_concurrent_zones) or an exclusive group. The preview shows "Waiting for pump" only when the pump runs one zone at a time and the program has more than one zone. - Water given by a timer shot is never used as input by the Auto setpoints learner.
Missed slots
- If a safety shot fires in the same pass as a timer slot, the safety shot goes first. The slot is still owed and is given on a later pass.
- An owed slot is dropped 15 minutes after its time, or at the end of its program window if that is
sooner. It is journaled as
intent.refusedwith guardexpired. - After a controller restart, the first pass only starts the clock. Slots missed while the controller was down are not given late.
- If a program cannot be worked out, the controller logs it once ("timer program '…' not run: …") and runs the room's other programs.
Using the Timer programs page
- Pick a program in the list, or start a new one. With no programs the page says "No timer yet. Most mother rooms use 2 min every 2 h during lights-on."
- Set the zones. The list holds zones set to Timer plus any zone already in the program, whatever its mode (the page's own hint says only Timer zones are listed). If no zone is set to Timer, the page says "No zone is set to Timer yet. Set one under Irrigation mode."
- Set the amount. It reads as a sentence, for example "Run for 2 min every 120 min" (the interval is always in minutes, 10 to 120). A one-off reads "Run for 3 min at 20:00 on Fri 23 Oct". The zones and the window are chosen beside it.
- Check Next 24 hours. It draws the shots this program would give. Shots past a zone's daily maximum are drawn as "Blocked by daily max".
- Check Daily max per zone and Pump capacity. The pump card shows the flow needed against the
pump's
max_flow_lph, or "The pump cannot feed every zone it may run at once." - Press Review & apply. The page shows the differences, saves and reads the program back from the list.
- Delete program removes it. It can be put back with Undo.
The Same safety as steering card is a reminder that Stop all, the source-water pH/EC check and the feed-tank level guard hold timer shots as they hold steering shots.
Skipping one planned shot
On Irrigation › Timeline, a planned shot has a Skip this shot button.
A skip covers one zone at one time:
- A timer slot at exactly that time is not given.
- An engine shot that would fire within 15 minutes from that time is not given, once. This works on a steering zone too.
- These shots are never skipped:
p3_emergency,watchdog,min_dailyandblind_copy_rescue. - The time may be up to 48 hours ahead and no more than 24 hours ago. A skip older than 24 hours is
no longer published in
timers_config; it is pruned from the store at the next save. Skipping the same zone and time again replaces the earlier skip. - Each skipped shot is journaled as
intent.refusedwith guardskippedand the reason "skipped by ", where the id is the Home Assistant user id, else the calling actor, elsetimer_skip. - Once skipped, the shot on the timeline is marked skipped and has no Skip button.
Projection: what the timeline shows as planned
At the end of each pass the controller works out sensor.crop_steering_<prefix>projection_app and
writes it when its state or attributes changed. Its
state is the number of planned shots. Its intents attribute lists the next 24 hours:
- Timer zones: each program slot with
source: "timer",program_id,duration_sandexpected: false. - Steering zones: the engine's next expected shot with
source: "engine",duration_s: nullandexpected: true. This is an estimate based on what the engine is waiting for, not a promise. - Off zones: nothing.
- Each intent has a stable
idandphase, the phase the engine expects the zone to be in then (nullwhere moisture decides).
The controller writes this sensor only once the integration publishes timer programs. If the
projection fails, the error is logged and watering continues. The timeline (timeline_get) draws
planned blocks from this sensor only, so nothing is drawn twice. It shows only intents after now and
marks skipped ones.
Entities
| Entity | State | Attributes |
|---|---|---|
select.crop_steering_<prefix>zone_<n>_irrigation_mode | steering, timer or off | options |
sensor.crop_steering_<prefix>timers_config | The store revision, or unavailable | room_id, revision, programs (active, not deleted), skips (zone, at, until, by), or error |
sensor.crop_steering_<prefix>projection_app | Number of planned shots | room_id, intents |
If the stored programs cannot be read, timers_config reads unavailable, the store is never
saved over and every save is refused. While it is unavailable the controller has no programs to run,
so timer zones get only their safety shots.
Services
room_id takes the form room:<prefix>, for example room:f2_. It is optional for
timer_program_list and required for the other four. The writes take the
expected_revision from timer_program_list. If the store changed since, the write is refused with
stale_revision ("Timer programs changed elsewhere. Reload before saving."). With api: 2 the
answer comes in the API v2 envelope. The writes also take client (mcp or aigrow) and
proposal_token.
| Service | Admin | What it does |
|---|---|---|
crop_steering.timer_program_list | No | The room's programs that are not deleted, each with room_id, next_at (next shot within 8 days), error and zones_in_timer_mode, plus revision. Without room_id: every room's programs, with revisions and errors per room. |
crop_steering.timer_program_preview | No | Checks the full program sent with it (there is no lookup by id, so send a saved program in full) and returns preview (zone, at, duration_s for each shot), shots and total_s. hours from 1 to 48, default 24. Saves nothing. |
crop_steering.timer_program_set | Yes | Adds a program, or changes one when program.id is given. Returns program, the new revision and preview for the next 24 hours. |
crop_steering.timer_program_delete | Yes | Soft delete by id: sets active: false and deleted_at. The program stays in the store for its history and is no longer listed. |
crop_steering.timer_skip | Yes | Skips zone at at (ISO date and time; a time with no offset is taken in Home Assistant's time zone). Returns skip (zone, at, until) and revision. |
"Admin" means a signed-in user calling it must be a Home Assistant administrator. Every save
journals config.changed with store: "timers", the section (programs or skip), the old and
new values and the diff.
Example:
service: crop_steering.timer_program_set
data:
room_id: "room:f2_"
expected_revision: 3
program:
name: Mother room
type: recycle
zones: [1, 2]
duration_s: 120
window: {kind: lights_on}
interval_min: 120Timer programs are also part of presets and copy settings (section timers). The MCP server can
preview and apply a program (preview_timer_program, then apply_proposal) but cannot delete one or
skip a shot.
Code
- Integration store and services:
custom_components/crop_steering/timers_api.py - Program checks and projection:
crop-steering-engine/src/crop_steering_engine/timers.py(copied tocustom_components/crop_steering/generated/timers.pyand the controller'scrop_steering_engine/) - The select:
custom_components/crop_steering/select.py, options inconst.pyIRRIGATION_MODES - Controller:
addons/f2_control/f2_control/controller.pyIRRIGATION_MODES,TIMER_KEEPS,_irrigation_modes,_apply_modes,_publish_projection,_skipped_by - Timeline planned blocks and skips:
custom_components/crop_steering/timeline.py - Pages:
frontend/src/pages/timers.tsx,frontend/src/lib/timers.ts,frontend/src/pages/watering.tsx