Skip to content

Updating from 3.17 or earlier? Read the upgrade guide first.

Crop Steering
Browse the documents

Batch-tank dosing

Applies to release 3.18.1 · View source at the tag · Markdown

On this page

Crop Steering can make a batch of nutrient solution and dose single pumps into a room's batch tank: fill the tank, mix it, dose each nutrient in order, mix again and stamp the fill. Each room has its own dosing page (Equipment › Feed & dosing) for its own tank, so a site with two tanks sees two separate pages, one per room.

It drives dosing pumps whose firmware times each dose itself: Home Assistant sets a volume, presses start, and the pump's own board switches the motor off. Crop Steering never switches a dosing motor on and trusts itself to switch it off; a browser never switches anything.

This document is the contract between the three layers. Everything below is generic: no entity id, pump name or recipe is assumed.

Layers

LayerOwnsNever does
IntegrationEach room's dosing configuration and its one pending request, in a revisioned store; the services to read, save and request; sensor.crop_steering_<prefix>dosing_config for the controllerSwitch, press or set any hardware
Controller appEvery action: doses, batches, stops, the irrigation hold during a batch and while dosing hardware does not read off, recovery after a restart, the mix check after a batch, alerts CS-801 to CS-809; sensor.crop_steering_<prefix>dosing with live statusAct on a dose or batch asked for more than 120 s before, or on a room whose configuration it cannot read; switch a dosing pump's power on, except for a prime or a calibration run (A timed run, below)
DashboardEquipment › Feed & dosing: pumps, a dose form, the batch, the recipe, history, and the dosing setupWrite any hardware entity; everything goes through dosing_request

Configuration (integration store crop_steering.dosing.<entry_id>)

{
  "revision": 4,                       // bumped on every save; dosing_save requires expected_revision
  "pumps": [                           // 0..8, in the order the page shows them
    {
      "id": "balance",                 // [a-z0-9_]{1,24}, unique in the room
      "name": "Balance",               // 1..40 chars
      "volume_entity": "number.x",     // number.* | input_number.*   the firmware's dose volume (mL or L)
      "start_entity": "button.x",      // button.* | input_button.* | script.*   starts the on-device dose
      "dosing_entity": "sensor.x",     // binary_sensor.* (on = dosing) | sensor.* (state starts with dosing_prefix)
      "dosing_prefix": "Dosing",       // sensor type only; default "Dosing"
      "power_entity": "switch.x",      // switch.*   turning it off stops the motor (the stop and the overrun cut)
      "flow_entity": "number.x",       // number.* | input_number.* | sensor.*   calibrated flow, mL/s
                                       // (or mL/min, L/h: Units and hardware, below). It wins over
                                       // calibrated_ml_min; a pump with no flow_entity and a calibrated_ml_min
                                       // is dosed at calibrated_ml_min / 60 mL/s (pump_calibration_commit).
                                       // Optional once the pump has a calibrated_ml_min; a pump with
                                       // neither is refused at save
      "max_ml": 2000,                  // 1..5000; a request above it is refused
      "restore_volume": true,          // put the volume number back after a single dose (firmware that
                                       // keeps its batch recipe in that number); default true
      "stock_tank": "balance",         // the id of a stock tank in this room's stock store, or null
                                       // (Stock tanks, below)
      "ph_role": "ph_up"               // only when set: "ph_up" | "ph_down", the pump pH auto-correct
                                       // doses with (pH auto-correct, below); at most one pump per role
    }
  ],
  "batch": {
    "fill_valve": "switch.x",          // switch.* | null   null = the batch starts with the tank already filled
    "full_entity": "binary_sensor.x",  // binary_sensor.* | sensor.* | null   required when fill_valve is set
    "full_state": "on",                // the full_entity state that means full; default "on"
    "fill_timeout_min": 20,            // 1..60
    "mix_pump": "switch.x",            // switch.* | null
    "mix_valves": ["switch.x"],        // 0..4 switch.*, opened before the mix pump starts
    "mix_power_sensor": "sensor.x",    // sensor.* (W, kW or mW) | null
    "mix_min_w": 200,                  // 0 = no power check; else the mix pump must draw this within 20 s
                                       // (needs mix_power_sensor)
    "premix_min": 2,                   // 0..30
    "postmix_min": 5,                  // 0..60
    "close_entities": ["switch.x"],    // 0..16 switch.*, switched off before filling (another room's feed path)
    "hold_entity": "input_boolean.x",  // input_boolean.* | null   on for the whole batch, for other automations
    "filled_at_entity": "input_datetime.x", // input_datetime.* | null   stamped with the end time of a finished batch
    "part_ml": 12.5,                   // 0.1..5000, only when set: "1 part (mL)", the mL one part is per batch
    "name": "Week 3",                  // the recipe's name, 0..40 characters; "" (a setup from before names) = none.
                                       // A save that leaves the key out keeps the stored name
    "level_entity": "sensor.x",        // sensor.* | number.* | input_number.* | null   the tank's level, in %
    "remind_pct": 20,                  // 0..100; 0 = no refill reminder (Refill reminder, below); needs level_entity
    "recipe": [                        // dose order = list order (the operator's); each pump at most once
      {"pump": "balance", "ml": 300, "ml_entity": null}, // ml_entity (number.*|input_number.*|sensor.*) is read
                                                        // at batch start and wins over ml; 0 skips the pump
      {"pump": "bloom", "ml": 19, "ml_entity": null, "parts": 1.5} // parts (0..100), only when set:
                                                        // ml = parts x part_ml, in whole mL
    ]
  },
  "feed": {                            // Feed schedule, below; an empty one changes nothing
    "recipes": [                       // 0..12 named feed recipes
      {"id": "vege", "name": "Vege",   // id [a-z0-9_]{1,24}, unique (made from the name when left out)
       "doses": [{"pump": "balance", "ml": 250, "ml_entity": null}], // as the batch's recipe
       "ec": 2.4}                      // optional: the feed EC it mixes to, 0.1-10 mS/cm, or null
    ],
    "schedule": {"start": "2026-10-05", "weeks": ["vege", "bloom", "bloom"]}, // 0..52 weeks
    "stage": null,                     // the recipe picked by hand, or null
    "held_until": null                 // set by the store: a pick over the schedule holds until then
  },
  "profile": {                         // the room's hardware profile (Hardware profile, below)
    "hold_wait_s": 600, "fill_open_s": 10, "full_blind_s": 15, "mix_power_s": 20,
    "min_flow_ml_s": 0.05, "max_dose_s": 1200
  },
  "request": null,                     // see Requests
  "updated_at": "2026-09-28T12:00:00+13:00"
}

Validation (dosing.py, pure): every entity exists and is in its allowed domain; pump ids unique; recipe pumps exist; a pump appears in the recipe at most once; full_entity set whenever fill_valve is; the recipe's name at most 40 characters; mix_power_sensor set whenever mix_min_w is above 0 (else the power could not be checked); level_entity set whenever remind_pct is above 0; no entity is both a dosing pump's power_entity and batch hardware; a mix_pump, mix_valves or close_entities switch may be a room's irrigation pump or main line (the batch holds that room's watering); a pump's stock_tank is one of the room's stock tanks, and a stock tank is linked to at most one pump; a dose in parts needs part_ml, and its mL is at most the pump's max_ml. Every recipe amount is whole mL, rounded half up (12.5 -> 13, 0.4 -> 0, which skips the pump): a fixed ml, and parts x part_ml, when they are saved, and an ml_entity's reading by the controller when the batch starts. A setup saved without part_ml or parts has neither key, and reads back unchanged. A room with no pumps is valid and does nothing. "Exists" is checked when a setup is saved; a stored setup read back at start-up is kept although a device has not come back yet. A save that does not send level_entity or remind_pct keeps the stored ones, so a page that does not know them never switches the reminder off. Each feed recipe follows the recipe's rules, names are unique, and every week of the schedule names one of the recipes.

Feed schedule

A room can keep named feed recipes and give each week of the grow one of them, from the day its first week starts. Week 1 is the 7 days from start, counted in Home Assistant's days and time zone; after the last week, the last week's recipe carries on. At Home Assistant's midnight that starts a week, the integration writes the dosing config again and the next batch doses the new week's recipe, with nobody touching it. A batch already running keeps the doses it started with.

The recipe in use today: the schedule's this week; a recipe picked by hand (stage) while the schedule runs holds until the schedule's next week starts (picking this week's own recipe ends a hold); without a schedule running, the recipe picked by hand; with none picked, the batch's own recipe, as before. A store saved before the feed schedule loads with an empty one and doses its batch recipe unchanged.

A feed recipe may declare the feed EC it mixes to (ec). The dosing config carries the EC of the recipe in use today as feed.ec, and watering compares pore EC with, in order: a mapped feed EC probe's reading, that declared EC, else none: with the feed EC unknown the controller takes no EC flush and no feed-based high-EC hold, and waters on moisture. A declared value can't catch a mis-mixed tank, so a probe always wins and the source-water gate runs on the probe alone; a recipe's EC never holds water.

dosing_save takes feed as optional: left out (a dashboard from before it), the stored one is kept and checked against the pumps being saved. held_until is set by the store when stage changes, never taken from the caller.

Hardware profile

The times and limits the controller works to are the room's own, so a slower fill valve, a mix pump that takes longer to draw its power or a slow pump that needs longer doses can be set for that room alone. Each key is optional; one left out takes its default, and the defaults are the values every room used before the profile existed, so a setup stored without one (any install before it) runs exactly as before.

KeyDefaultRangeWhat it sets
hold_wait_s60060..3600how long a batch waits at Hold for a shot in flight
fill_open_s102..120how long the fill valve has to read on
full_blind_s155..300how long full_entity may read nothing while the tank fills
mix_power_s205..300how long the mix pump has to draw mix_min_w
min_flow_ml_s0.050.01..100the lowest flow that counts as a calibration
max_dose_s120060..3600the longest expected dose; no dose runs past it + 60 s

The numbers below (10 s, 15 s, 20 s, 0.05 mL/s, 20 and 21 minutes, 10 min) are these defaults. The controller reads the profile from dosing_config; a value it cannot use (missing, not a positive number) takes the default.

Services

ServiceWhoDataResponse
crop_steering.dosing_getany userroom_id{schema_version: 1, room_id, config, feed_now, candidates[], can_edit, error}
crop_steering.dosing_saveadminroom_id, expected_revision, pumps, batch, feed?, profile? (left out: the stored ones are kept)the same as dosing_get, or error
crop_steering.dosing_requestadmin; a stop: any signed-in userroom_id, action ("dose" | "batch" | "stop"), pump?, ml?, run_anyway?{request, error}
  • room_id is "room:<prefix>", as for the stock services.
  • Who: as for every service that changes something, "admin" means a signed-in administrator, and a call with no user (an automation) runs as before. A stop only switches things off, so whoever may look at the room may ask for one: any signed-in user, administrator or not. A user id Home Assistant does not know is refused either way.
  • candidates lists entities of every domain the configuration can use (number, input_number, button, input_button, script, binary_sensor, sensor, switch, input_boolean, input_datetime): {entity_id, name, domain, state, unit, device_class}.
  • can_edit (in dosing_get, and in dosing_save's response): the calling user is an administrator (call.context.user_id → hass.auth.async_get_user(...).is_admin; false when there is no user).
  • A request is pending while request is set AND its id differs from the handled attribute of sensor.crop_steering_<prefix>dosing (read with hass.states.get; Status, below) AND it is less than 120 s old. The stored request is not cleared by the integration; it simply stops being pending (the controller never acts on one older than 120 s).
  • dosing_request stores one request {id, action, pump, ml, at, by} (id a uuid4 hex, by the user's name, null for an automation). A stop always replaces what is pending. A dose or a batch returns error: "busy" while one is pending, and a dose is refused when the pump is unknown or ml is not in 0 < ml ≤ max_ml (error then says which). A request does not move the revision.
  • A test refill run anyway: a batch with run_anyway: true (an administrator's call, as every batch is) is stored with run_anyway: true; without it the key is left out, as before. Only a batch may carry it: on a dose or a stop it is refused ("only a batch (a test refill) can be run anyway"). It is the person's word that they checked the fill, never a way past a safety check: the controller refuses a batch run anyway for every reason it refuses any other (a hold entity on or unreadable, a latched hardware fault, a full sensor that can't be read), and the overfill guard still keeps the fill valve shut.
  • dosing_save returns error: "busy" too (the same word) while a request is pending or while sensor.crop_steering_<prefix>dosing reads dosing or batch (a dose or a batch runs from the setup it started with, which must not change under it), error: "revision" when expected_revision is stale, and an error naming the field when the setup breaks a rule under Validation; a refused save answers with the stored setup, unchanged. A pump saved without an id gets one made from its name.

sensor.crop_steering_<prefix>dosing_config: state = the revision; attributes pumps, batch, profile, request and feed. This is what the controller reads (every 2 s). Its batch.recipe is the recipe in use today (Feed schedule, above), and feed (also feed_now in dosing_get) says which: {recipe, recipe_id, week, weeks, start, held_until, source}, source one of schedule, held, hand or batch (the batch's own recipe; recipe is then null).

Requests and the controller

Dosing runs on the controller's main loop, for all rooms, with no thread of its own: between two passes the loop wakes whenever dosing is due. Every 2 s it reads each room's dosing_config, and a dose or batch runs on to its next wait (a read of the pump, the fill, the mix, a pause) each time the loop wakes for it. While a shot runs, a dose or batch already running goes on with its steps the same way (a batch's open fill valve is still watched for its full sensor); with none running, dosing waits for the shot to end. A request is taken once (its id is remembered, in dosing_state.json so a restart does not take it again, and published as handled). A room whose dosing_config can't be read has no request taken.

  • Taken only when it is less than 120 s old. An older one is published as handled with the result "too old to act on" and does nothing, so a request left while the controller was down never fires later. The one exception is a stop for a room with a dose or batch running, or with dosing hardware that does not read off (Holding, below): it is acted on however old it is.
  • stop: switches off every pump's power_entity in the room (whatever it reads), those of the setup the running dose or batch started with as well as the current setup's (a setup saved since may name other pumps), and ends a dose or a batch (below). A dose or batch looks for a stop before every switch-on and at every step, not only on its 2 s passes. In a room held by hardware that does not read off, a stop sends the offs again, and releases the room once everything reads off. power_entity is the motor's own switch on the firmwares this drives (off = idle), so a stop never switches it back on.
  • dose: one pump, ml mL, into the tank as it is.
  • prime / calibrate: one pump's power on for seconds (A timed run, below).
  • stop_pump: one pump. Its prime or calibration running in the room ends within one tick (1 s); a dose, batch or pH correction running in the room is left alone ("refused: a batch is running, and a pump's stop leaves it alone": the room's stop ends those); otherwise the pump's power_entity is switched off and read back, and one that does not read off is held and raises CS-801 as a timed run's does (A timed run, step 6). Like a stop, it is acted on however old it is while the room is busy or held.
  • batch: the batch sequence. Refused while hold_entity is already on (something else is dosing) or can't be read, or while the room has a latched hardware fault.
  • Dosing does one thing at a time: while a dose or a batch runs, in any room, a dose or a batch is refused (a stop is always acted on), and so is one for a room held by dosing hardware that does not read off ("refused: does not read off yet"). A request refused meanwhile keeps its own handled_result: the end of the running dose or batch never takes its place.
  • A pump whose published setup can't be read (a pump from an older setup with neither a flow entity nor a calibrated flow) is refused on its own ("refused: the setup of pump can't be read"), and the controller's status lists it under unreadable_pumps; the room's other pumps work as before, and a stop or stop_pump still switches its power_entity off. A pump list or batch that can't be read still leaves the whole room's dosing untouched.
  • One room's error never stops the others: each room is read in its own turn, and a room that fails 15 passes in a row (30 s) raises CS-806 ("dosing in this room keeps failing").

handled_result says what came of the request handled names: for a dose "dosing" on start, then the dose's result ("finished", "ended early", "ran past its time", "not confirmed", "stopped", "stopped: an error in the controller"; for a pump linked to a stock tank, "not confirmed; nothing drawn from its stock tank"); for a batch "making a batch", then "finished", "finished; could not be stamped" or "stopped: : "; a refusal "refused: " (for example "refused: Bloom is not calibrated (flow 0)" or "refused: a batch is running"); an old request "too old to act on"; a stop "stopped", or "stopped; does not read off". A prime or a calibration reads "priming" or "calibrating" while it runs, and its answer, refusals included, is the report {id, action, pump, ran_s, result} (A timed run, below).

handled_results keeps the answer to each of the last 10 requests under its request id, so a request handled later never takes an earlier one's away: a dose refused during a prime keeps "refused: a prime is running", and the prime's report still lands under the prime's id. handled and handled_result stay the request handled last, as before; a run that ends after a later request was handled (a stop, a refusal) writes its end only under its own id. The integration reads a prime's or calibration's report by its own request id from handled_results (from handled_result only with a controller that publishes no handled_results).

Units and hardware

Every number the controller reads from a dosing device goes through the one device-unit table (UNITS in crop-steering-engine/src/crop_steering_engine/defaults.py), which irrigation's probes use too: a pump's volume_entity and a recipe's ml_entity in mL (mL or L), a pump's flow_entity in mL/s (mL/s, mL/min or L/h) and the mix_power_sensor in W (W, kW or mW). A volume number is written in its own unit (0.1 for 100 mL in L) and read back in it. A unit the table does not know, or none, is read as mL, mL/s or W, as it always was, and said once in the log; it is never set aside, because a dose is refused all the same for an uncalibrated flow, more than max_ml, more than 20 minutes and a volume that does not read back.

The fill valve, the mix pump, the mix valves, the hold, the valves, main lines and pumps a batch closes, and each pump's power_entity are switched and read back through the controller's actuator table, the one irrigation uses: a switch or input_boolean is sent turn_on / turn_off and reads on / off; a valve is sent open_valve / close_valve and reads open / closed. Any other on/off domain is switched turn_on / turn_off and read back on / off, as before. Below, "on" and "off" mean the domain's own words. The configuration above still offers switch.* for these.

One dose (ml into a pump)

  1. Refuse unless the pump's flow (its flow_entity; with none, its calibrated_ml_min / 60) is a finite number of at least 0.05 mL/s (a firmware that divides by a zero flow runs its motor for ever, and a tiny one would put every time limit hours away: "refused: is not calibrated (flow 0.01)"), ml ≤ max_ml, the dose's expected time ml / flow is at most 20 minutes, dosing_entity reads, and does not read dosing, and power_entity does not read on ("refused: is running by hand": on these firmwares it is the motor's own switch).
  2. Write the dose to /data/dosing_state.json (atomic, file and directory synced) before anything moves, with the volume number it is about to change, so even a restart puts it back.
  3. Remember the volume number, set it to ml, and read it back (within 0.5 mL). A volume that does not read back is never started, and a dose is refused when restore_volume is on and the volume number can't be read (it could not be put back).
  4. Press start_entity (button.press, input_button.press or script.turn_on), noting the time just before. The pump's power is never switched on.
  5. Expected time t = ml / flow. Wait (reading every second) until the pump has been seen dosing and then reads not dosing. Deadline t × 1.25 + 20 s, and never more than 21 minutes.
    • It counts as done only when a read saw it dosing, or Home Assistant's recorder shows it dosing after the press (a dose too short to catch between two reads; the recorder may take a few seconds to have it, and is asked again until t + 15 s). A change of state alone is not enough: unavailable → idle is a pump that restarted.
    • Read unavailable or unknown at any point after the press (by a read, or in the recorder): result "not confirmed", alert CS-802.
    • Never seen dosing by t + 15 s: result "not confirmed", alert CS-802.
    • Done in less than half of t: result "ended early", alert CS-802; a batch stops.
    • Still dosing at the deadline: switch power_entity off, confirm it reads off (re-sent once), result "ran past its time", alert CS-801.
    • An error in the controller: its power off and read back, result "stopped: an error in the controller", alert CS-806.
  6. Put the volume number back when restore_volume is on, once the pump reads idle or its power reads off (never while it may still be running: until then it is kept, and tried again every 30 s), and read it back: when it does not read back, CS-806 says "the volume number of could not be put back to mL".
  7. Clear the dose from dosing_state.json; publish the result. When its power does not read off (after the deadline, a stop or an error), the record is kept instead and the room's watering is held until it does (Holding, below).

A timed run (prime, calibrate)

pump_prime (1 to 30 s, ruling R7) and pump_calibrate (5 to 120 s) leave a prime or calibrate request with seconds. It is the one thing that switches a dosing pump's power on.

  1. Refuse ("refused: ", nothing switched) when the pump is not set up, seconds is not a positive number, the room has a latched hardware fault, a shot is in flight in the room, the pump is not calibrated (a prime only: a calibration is how a pump gets its flow), the batch's hold_entity is on or can't be read, a calibration or prime holds the room (calibrating: every hold but this pump's own prime or calibration, "dosing pump calibration or prime in progress" or "feed probe calibration in progress"), or the room's kill switch is off. The run is at most its seconds, and never longer than 30 s for a prime or 120 s for a calibration.
  2. Write it to dosing_state.json (its pump's power to switch off) before anything moves.
  3. Switch power_entity on and read it back on, waiting at most the run's own seconds (and never more than 6 s), so a short run never runs long waiting for its on. One that does not read on is switched off and read back: "refused: did not read on".
  4. Every second: the app stopping, a stop or stop_pump (read now), the room's kill switch off, a hardware fault latched, a shot in flight in the room. Any of them ends it: "stopped".
  5. At seconds, or on any end (an error in the controller included): switch power_entity off and read it back (re-sent once). The report's ran_s is the seconds from the switch-on to the off (0.1 s); result is "done", "stopped", "refused: " or "stuck".
  6. A power that does not read off: "stuck", ran_s 0 (the pump may still run, so no calibration or prime is taken from it), CS-801, and the room is held as under Holding (below) until it reads off; every dosing request in the room is refused meanwhile.

The way out (SIGTERM) sends the off and keeps the record, as for a dose; the next start reads it back (After a restart) and reports the run "stopped" with ran_s 0. Each run is journaled as dose.completed with data.kind "prime" or "calibrate", its seconds, ran_s and result, and kept in the history with kind "prime" or "calibrate".

The grower's walkthrough (precheck, prime, calibrate, measure, commit) and the integration-side services pump_precheck, pump_prime, pump_stop, pump_calibrate and pump_calibration_commit are in Calibration. While a feed pH/EC calibration session is open, batches and pH auto-correct wait and primes and calibrations are refused; see what waits while a session is open.

A batch

The room's watering is held for the whole batch (_blocked: "making a batch"), and so is every room whose pump, main line or valves are among the batch's mix_pump, mix_valves, fill_valve or close_entities, and every room that shares the batch room's pump or main line (closing them would otherwise cut that room's shot). The hold is set under the lock a shot starts under, so a shot in a held room has either started (and the batch waits for it) or never starts.

Before anything moves (before Hold), the batch is checked. It is refused while hold_entity is on or can't be read, while there is a fill_valve and full_entity can't be read ("refused: the tank's full sensor can't be read"), and when mix_min_w is above 0 with a mix_pump but no mix_power_sensor. Every recipe pump's amount and flow are checked too. A recipe entry whose ml_entity is set but reads nothing usable refuses the batch ("refused: the recipe amount for can't be read"): it is never replaced by the fixed ml. An amount of 0 skips the pump; any other recipe pump whose flow reads below 0.05 mL/s or nothing refuses the batch too ("refused: is not calibrated (flow 0)"), and so do an amount above its max_ml and a dose expected to take more than 20 minutes. The watchdog is set from these doses.

StepWhat happensEnds the batch (with its code) when
Holdhold_entity on, read back; wait for any shot in flight in a held room to finish (10 min at most)the hold does not read on; a shot is still running after 10 min
Closeswitch off each held room's valves, main line and pump, and close_entities; read backa switch does not read off (CS-806)
Fillskipped when full_entity already reads full or there is no fill_valve; else open it, read it back on within 10 s, and wait for full; always close it and read backthe fill valve does not read on (CS-804, "the fill valve did not open"); full_entity reads nothing usable as the fill begins or for 15 s while it fills (CS-804, "the full sensor stopped reporting"); not full within fill_timeout_min (CS-804)
Mixskipped without a mix_pump; open mix_valves and read them back, start mix_pump and read it back on; with mix_min_w and a mix_power_sensor, wait up to 20 s for the powera mix valve does not open (CS-806); the pump does not read on or does not draw its power (CS-805)
Premixpremix_min minutes
Doseeach recipe pump in order, as "One dose", with the amounts read before Hold, in whole mL; 0 skipsa dose ends any other way than finished (CS-801/802)
Postmixpostmix_min minutes
Finishstop mix_pump, close mix_valves, read back; stamp filled_at_entity (input_datetime.set_datetime with the end time as a timestamp); hold_entity off, read backa switch does not read off (CS-806)
  • A stop at any point ends the batch: every dosing pump off, the fill valve closed, the mix pump off, the mix valves closed, and then hold_entity off. The result names the step it stopped in. Every other end (a step that fails, the watchdog, an error in the controller) switches off the same. A stop is looked for before every switch-on and at every step.
  • Every end other than finishing raises CS-806 with the step and the reason, after its own code. When any of that hardware does not read off, hold_entity stays on, and the rooms stay held and the record kept, until it does (Holding, below).
  • A fill time that could not be stamped still finishes the batch, as "finished; could not be stamped", with CS-806 (record the batch by hand for the stock tanks that count by it). A hold_entity that does not read off at the end holds the rooms like any other switch (Holding).
  • The whole batch has a watchdog: the 10-minute wait for a shot + fill timeout + premix + postmix + every dose's deadline (each capped at 21 minutes) + 10 min.
  • Stamping filled_at_entity is what the stock tanks no pump is linked to count batches by, so such a stock tank whose per-batch dose points at the same recipe number is drawn down by exactly what was dosed. A tank linked to a pump is drawn by the pump's runs (Stock tanks, below).

The mix check

Five minutes after a batch finishes (its mix stopped and its hold released), the controller reads the tank with the room's feed EC and pH probes (the add-on's feed_ec_sensor and feed_ph_sensor, or the room's own) and compares each reading with the room's irrigation band: number.crop_steering_<prefix>irrigation_ec_min / _max and irrigation_ph_min / _max, a bound of 0 or less being no bound. Only a probe that is mapped and has a band is checked; a room with neither has no check, and its batches run exactly as before. The probes are read through the device-unit table (Units and hardware): EC in mS/cm, µS/cm or dS/m converts exactly, an unknown unit counts only while plausible, and one with no exact conversion (ppm, ppt) is not read. pH is read as the source-water gate reads it.

  • Every reading inside its band: the batch is marked good (mix.verdict "good").
  • A reading outside its band ("off target"), or a probe that reads nothing usable ("not read"): CS-808 names each reading and its band, the batch is not marked good, and a batch asked for with no user (by null: an automation) is refused, "refused: the last batch's mix check failed (...); make the next batch by hand", until a batch made by hand passes the check. A dose, a stop and a batch made by hand are never refused for it.
  • While the check is still due, an automation's batch is refused too ("the last batch's mix is not checked yet").
  • While a feed probe's calibration may be open, or dosing_config can't be read, the check waits. A dosing_config that can't be read, or is not published, for 30 minutes after the check was due ends it with no verdict (mix.gave_up_at): CS-808 says so once, the batch is never marked good, and an automation's batch stays refused.

The mix check never holds watering, whatever it finds. The source-water gate still holds a zone on its own, as it always did, while the feed probe reads off its band or stays dead past its grace. The verdict is kept in dosing_state.json (mix), so a restart while the tank settles still checks it, and a batch that finishes replaces it. A file from before the check has none and loads unchanged.

Holding: dosing hardware that does not read off

Whatever a dose or a batch switched off, or tried to, and does not read off (a dosing pump's power, the fill valve, the mix pump, a mix valve, a batch's hold_entity) holds watering: the dose's room, or every room the batch held. _blocked then reads "dosing hardware still on: ". The record in dosing_state.json is kept, so a restart holds it again. Every 2 s the controller reads it and sends the off again to what does not read off; unavailable, unknown or missing never counts as off. A batch's hold_entity is switched off only once the rest reads off. The alert (CS-801 for a dose past its time, CS-803 after a restart, CS-806 otherwise) names what does not read off and comes again every 30 minutes; once everything reads off the rooms water again, the record is cleared, and the same notification says so. A stop for the room sends the offs at once, and releases the room if they read off. A dose or a batch in that room is refused meanwhile.

After a restart

If /data/dosing_state.json holds a dose or a batch in progress, the controller at start-up, before its first loop, holds the rooms it held (the batch's, or the dose's own), switches off every pump's power_entity, the fill valve, the mix pump and the mix valves of that room, and then treats it as under Holding: read and sent off again every 2 s until every one reads off (a device that has not come back after a host boot reads unavailable, and Home Assistant answers turning it off all the same, so it is never counted as off). Only then does a batch's hold_entity go off, and the rooms water again and the record is cleared. CS-803 says so once Home Assistant answers: that it was all switched off, or what still does not read off (and again every 30 minutes). The volume number the dose changed is put back. A single dose's record never names a batch's hold_entity, so neither a restart nor the way out ever touches it for a dose. It never resumes a dose or a batch.

A dosing_state.json that can't be read is kept as dosing_state.json.bad, a fresh one is started, and CS-803 says so; then every room with dosing set up has its current dosing hardware (every pump's power, the fill valve, the mix pump, the mix valves; never hold_entity, which may be someone else's) switched off and held the same way, as soon as its setup can be read. Every write of the file is atomic, and the file and its directory are synced.

A dose or batch that stops with an error it does not settle itself is recovered as after a restart (whatever it had recorded is held and switched off; nothing is resumed), with CS-806.

When the app is stopped (SIGTERM: an update, a restart), no dose, batch or dosing switch-on starts any more; then the shot in flight is closed; then everything a dose or batch recorded is switched off, each off with a 2 s timeout and without waiting to read it back. The signal is handled on the main loop, so when it lands while a dosing switch-on is on its way to Home Assistant, the way out waits for that call to land (4 s at most) before it starts, and its offs come after it. The record stays, and a batch's hold_entity stays on, for the next start. The app's timeout: 30 (addons/f2_control/config.yaml) has the Supervisor wait 30 s after SIGTERM, rather than its default 10, before it kills the container.

All of that runs inside a 25 s budget (or F2_STOP_BUDGET_S seconds, when that environment variable is set), so a slow Home Assistant can never make the Supervisor kill the app half way. While a dose or batch has anything recorded, dosing keeps the last 5 s of it (a fifth of a shorter budget). Each room with a shot recorded gets an equal share of the rest, the shot running now first. Inside a room's share a read waits only as long as leaves time for the offs it may prove, each counted as long as Home Assistant's slowest answer so far in the stop, and a read that could not come back in what is left is not made: a slow Home Assistant costs reads, never the offs they prove, and no call is cut shorter than Home Assistant is answering. A read that is not made or not answered never counts as off: while a hold or another valve on the shot's line cannot be read, the main line and pump are left on. The valve of the shot running now is switched off even when there was no time to prove it is the shot's. Whatever does not fit is not sent and stays recorded, as an off that is not confirmed always has, and the state file is always saved.

Status: sensor.crop_steering_<prefix>dosing

State: idle | dosing | batch | unavailable (the room's configuration can't be read).

{
  "pumps": {"balance": {"state": "idle" | "dosing" | "unavailable", "flow_ml_s": 11.06,
                        "target_ml": 25, "started_at": "…", "expected_s": 2.3,
                        "last": {"ml": 25, "given": 25, "at": "…", "result": "finished" | "ended early" | "ran past its time" | "not confirmed" | "stopped"}}},
  "batch": {"step": "idle" | "hold" | "close" | "fill" | "mix" | "premix" | "dose" | "postmix" | "finish",
            "pump": "bloom" | null, "name": "Week 3", "started_at": "…", "step_started_at": "…",
            "steps": [{"step": "fill", "state": "done" | "running" | "skipped" | "failed", "at": "…", "note": "…"}],
            "result": null | "finished" | "stopped: <reason>", "ended_at": null | "…"},
  "handled": "<request id>", "handled_result": "…" | {"id": "…", "action": "prime" | "calibrate",
                                                      "pump": "balance", "ran_s": 9.9, "result": "done"},
  "handled_results": {"<request id>": "…" | {…}},  // the last 10 requests' answers, by id
  "history": [{"kind": "dose" | "batch" | "draw" | "prime" | "calibrate", "at": "…", "ended_at": "…", "result": "…",
               "name": "Week 3",                                  // a batch's recipe name
               "doses": {"balance": 300, "bloom": 1800},           // what was asked
               "dosed": [{"pump": "balance", "name": "Balance", "ml": 300, "given": 300,
                          "result": "finished", "at": "…"}],      // each dose, in order (below)
               "by": "…",
               "mix": "good" | "off target" | "not read"}],  // newest first, 20 kept; mix once checked
  "last_batch": {"kind": "batch", "name": "Week 3", …} | null,     // the newest batch, kept past the 20
  "mix": null | {"batch": "<its started_at>", "due": "…", "verdict": null | "good" | "off target" | "not read",
                 "checked_at": "…", "why": "…", "ec": 2.1, "ph": 5.9},  // the last batch's mix check
  "updated_at": "…"                    // the controller's last publish
}

Exact dose record

Every dose, alone or in a batch, is recorded in the order it went in with what was asked (ml) and what it gave (given, in mL): finished, the amount asked for; ended early, ran past its time or stopped, the seconds from the press to the moment it ended, or its stop or cut was sent, x the pump's calibrated flow, never more than asked (a pump that ran past its time may have given more); not confirmed, not known (null). The stock tanks are drawn down by the same given. A batch is recorded with its recipe's name, and the newest batch is kept apart as last_batch, so single doses never push it out of the history. A batch's record in dosing_state.json carries each dose given so far, so one cut by a restart keeps them, with the dose in flight given: null. A dosing_state.json or a history from before this record loads as it is: no dosed, no given, last_batch null until the next batch. doses (what was asked) is kept as it was.

updated_at is the time of the controller's last publish, refreshed at least every 60 s even when nothing changes, so the page can tell a stale controller. It is published on every change as well; each pump's flow_ml_s and state are read every 30 s, and whenever the setup's revision changes while nothing runs, with a 2 s timeout. While a dose or batch runs, that reading, the stock draws not yet taken, alerts not yet created and volume numbers still to put back wait 30 s between runs, so they never hold up its watch. When one read of the setup fails while a dose or batch runs in the room, the status keeps the setup it started with (dosing or batch, not unavailable). Nothing is published for a room whose integration has no dosing_config (one from before dosing). dosing_state.json keeps handled, each pump's last, the last_batch, when the refill reminder was last said (reminded_at), the history (with a draw entry for a stock draw that was dropped, below), the draws and alerts not yet delivered and the volume numbers still to put back across a restart.

Refill reminder

Nothing refills a batch tank by itself. With remind_pct above 0, the controller reads the room's level_entity once a minute while no dose or batch runs in the room. Three reads in a row at or under remind_pct (one bad echo is not a low tank) raise CS-906, "batch tank down to N%, refill it". It comes again each day the level stays there (the time it was said is kept in dosing_state.json, so a restart does not say it sooner), and the notification is dismissed once the level reads 5 points above remind_pct; the next low tank is then told at once. A level that reads nothing changes nothing. Setting remind_pct to 0 takes a reminder that is up down. Watering is never held by it.

pH auto-correct

Opt-in per room: switch.crop_steering_<prefix>ph_auto_correct, OFF on a new room and on an updated one, so nothing changes until a person turns it on. It corrects the batch tank with the room's dosing machinery and never touches the source-water gate: the gate still holds watering on its own while the feed pH is outside irrigation_ph_min / _max or its probe is dead, exactly as before.

What it needs: a pump with the pH role for the direction (ph_role "ph_up" for a tank under its band, "ph_down" over it; Equipment > Dosing, Dosing setup), the batch's mix_pump (a dose is never made into a tank that is not mixed), the room's feed pH probe (feed_ph_sensor, the probe the mix check and the source-water gate read) and a pH band. Its settings (number.crop_steering_<prefix>ph_correct_<key>):

KeyDefaultRangeWhat it sets
dose_s51..30 sone dose: seconds of the pump's calibrated flow, in whole mL, never above its max_ml
max_tries21..3doses in one correction
mix_min52..30 minmixing after each dose before the tank is read again
daily_max31..10doses in one day, all corrections together
margin0.10..0.5 pHhow far inside the band a correction stops
  • When. Once a minute, while no dose, batch or correction runs anywhere and nothing a dose left reads on: the switch on, the room on and its kill switch on, no hardware fault latched, no other controller on the room, hold_entity off and the mix pump off. The probe must read a number from 3 to 10, no older than 20 minutes, never jumping by more than 1.0 between two reads, and off its band at every read for 5 minutes in a row; any other read starts the 5 minutes again.
  • How. As a batch holds and mixes (the Hold, Close and Mix steps above: the rooms held, hold_entity on, the mix pump read back on and its power checked), then up to max_tries doses as One dose, each followed by mix_min of mixing and a fresh read of the probe. It ends with the mix pump off and its valves closed, read back, then hold_entity off. The status shows it as a batch named "pH correction", and its history entry is kind: "correct" with the pH it read (ph).
  • Stops. At the band (min + margin going up, max - margin going down). Every other end locks it, with CS-809: a dose that moves the pH by less than 0.05 (a probe or pump fault, not chemistry), a pH past the other side of the band, a probe that can't be read after the mix, the doses not enough, a dose that does not finish, the mix pump not starting (CS-805 too), a stop, or the watchdog. Locked, it doses no more until a person switches it off (and on again), or the tank's filled_at_entity shows a new fill. A restart in the middle is recovered as a batch's (CS-803): switched off, never resumed.
  • Can't. A tank that needs correcting while there is no pump with the role, no mix pump, an uncalibrated pump or no doses left today raises CS-809 once a day for each reason, and doses nothing.
  • Kept. dosing_state.json's room block gains ph: {day, doses, latch, told}. A file from before it has none and loads with nothing counted and nothing locked. A dosing store from before roles loads with no pump in a role, and a save from a page that does not send ph_role keeps each pump's stored role.

Light dimming on dry substrate

Opt-in per room: switch.crop_steering_<prefix>dry_dim, OFF on a new room and an updated one. The controller's only light actuation, and only for the room's mapped light_entity when it is a light.* that reports a brightness: a room whose light is a switch is never dimmed (and never switched off).

Setting (number.crop_steering_<prefix>dry_dim_<key>)DefaultRange
floor_vwc: a zone under this counts as dry15 %1..60
level_pct: the brightness it dims to50 %30..90 (never below 30)
after_min: dry this long, with the lights on, before it dims10 min1..120
max_h: the longest the room stays dimmed4 h0.5..12
  • Counted zones: enabled zones with a live fused reading this pass; a blind zone, and a probe reading under 1 % (a dead probe looks like a dry pot), never count.
  • Dims when the switch, the room and its kill switch are on, the lights are on by schedule (and more than 5 minutes before lights-off) and by the light's own state, and a counted zone has read under the floor for after_min: it records the light's brightness in state.json (the room's _dim) first, then sets the level, reads it back, and says so (CS-810). It only ever dims: a light already at or under the level is left. A light that does not read the level back is set back at once.
  • Restores the recorded brightness, read back, when every counted zone reads floor + 3 for 10 minutes, when the switch, the room or its kill switch goes off, 5 minutes before the schedule's lights-off (or at it), after max_h (with CS-811: a dimmed room is a fault too), and on the first pass after a restart. It never switches a light on to restore it: a light that reads off is left, and a person told. A light that does not come back raises CS-811 and is asked again each pass.
  • One writer. A light someone else changes while it is dimmed (another brightness, or off) is left to them: the record is dropped and a person told (CS-810), never fought over.

Shots held for too long (CS-211)

number.crop_steering_<prefix>held_alert_hours, 0 (off) by default, 0..72 h: a zone that has asked for water and been held, with no shot since, for that many hours raises CS-211 (critical), again every 30 minutes, and it clears at the zone's next shot. A hold a person set on purpose (the kill switch, a zone switch off, manual override) never counts. It only tells; the clock is kept in memory, so a restart starts it again.

Alerts (controller, group CS-8xx "Dosing", and CS-906)

CodeTitleWatering
CS-801A dosing pump ran past its time and was switched off, or a prime's or calibration's power does not read offcarries on (a batch stops), unless its power does not read off: then the room is held until it does
CS-802A dose could not be confirmed, or ended earlycarries on (a batch stops)
CS-803A dose or batch was interrupted by a restartthe rooms it held stay held until its dosing hardware reads off
CS-804The batch tank could not be filledheld rooms water again; the fill valve was closed
CS-805The mixing pump did not startheld rooms water again
CS-806Dosing was stopped, or needs a checkheld rooms water again once the hardware reads off; a dose's pump that does not read off holds its room
CS-807A dose was not taken off its stock tankcarries on
CS-808A batch's mix is off its band, or could not be checkedcarries on; an automation's next batch is refused
CS-809pH auto-correct did not correct the tank, or can't (critical)carries on (the source-water gate holds on its own); pH auto-correct is locked after a failed correction
CS-810The light was dimmed on dry substrate, set back, or handed backcarries on
CS-811The light stayed dimmed for its longest time, or did not come back (critical)carries on
CS-906Batch tank low, refill it (Refill reminder, above)carries on

Each event (a dose, a batch, a restart) raises its own notification, so a second one within half an hour is never swallowed by the first, and an alert about hardware that does not read off is updated on the same card (every 30 minutes, and once it reads off). An alert Home Assistant does not take (it is down, typically at the moment hardware fails) is kept in dosing_state.json and raised again until it does. Switching a room off does not dismiss its dosing notifications: they are about the tank. CS-801, CS-803 and CS-806 always push to a phone, whatever Jev's Alerts judge says.

Stock tanks

A pump may be linked to one of the room's stock tanks (stock_tank: the id of a tank in the room's stock store, crop_steering.stock.<entry_id>, or null). A stock tank is linked to at most one pump, and its level then follows what that pump actually runs, whoever starts it: the controller, a Home Assistant automation or script pressing the pump's start, a button on the doser, or a hand on its power switch. The integration watches the pump itself (stock_api.py), and that is the only thing that draws a linked tank:

  • A run. The pump runs while its power_entity reads on (the motor's own switch on these firmwares) or its dosing_entity reads dosing (a binary sensor on; a sensor whose state starts with dosing_prefix). Either is enough, because firmwares differ: on a 5.1.x ESPHome peristaltic doser, Home Assistant's history shows a dose button pressed by an automation turning the power switch on and the status to "Running (Manual)", which the default prefix "Dosing" never matches, for 27.13 s, then off and "Stopped" within a few milliseconds of each other. The run ends when neither reads running any more: off, idle, unavailable, unknown or gone.
  • What it draws. When a run ends, the seconds it ran times the pump's flow_entity reading (mL/s, read as it started, or as it ended when it could not be read then) come off the tank, never below 0, with a history entry {at, source: "pump", draw_ml, key, note} (the note says how long it ran and at what flow) and a new revision. The firmware times a dose from the same calibrated flow, so this is the dose it was set to give: on that doser, 27.13 s at 11.0612 mL/s is 300.1 mL of the 300 mL its volume number held. It is also what a dose stopped part way, or a run by hand, actually moved. A flow that reads nothing usable draws nothing, and the integration logs a warning. The run is timed by when Home Assistant saw its states change.
  • Once. A run is drawn when it ends, under the key pump:<pump>:<when it started>, so the same run is never drawn twice. A room reloaded in the middle of a run finds the pump running and takes the run from when its entity last changed: the whole run, once. When Home Assistant itself restarts in the middle of a run, only what it sees once it is back is drawn (a doser that comes back still running is drawn from then): what ran while it was down is not seen, and never guessed at.
  • The controller's reports. When a dose ends, the controller still reports what it dosed to crop_steering.stock_draw (below): "finished" → the requested mL; "ended early", "ran past its time" or "stopped" → the requested mL or the seconds from the start press to its end × the flow, whichever is less; "not confirmed" → nothing. It sends that with the key <prefix>:<pump>:<the dose's started_at>, asking for the service's answer (?return_response), once the dose or batch has ended, and keeps a report not yet taken in /data/dosing_state.json, sending it again every 2 s (every 30 s while a dose or batch runs, each with a 2 s timeout). The integration leaves a linked tank in a report as it is, because the pump's run already drew it, and lists it in the answer's linked. So a dose is counted once whoever reports it, and the controller's own results ("not confirmed; nothing drawn from its stock tank") say what it reported, not what the tank lost. A report is dropped, kept in the room's history (a draw entry) and alerted once (CS-807) when the answer names its tank as one the room does not have (skipped), when Home Assistant refuses it (HTTP 4xx: sending it again changes nothing), and when it has not been taken 24 hours after the dose.
  • Fills and batches by hand. The stock store's fill-based batch draw (a newer time on the room's tank last-fill entity takes each tank's per-batch dose) skips the tanks linked to a pump, so a batch that stamps filled_at_entity never counts them twice, and a fill that draws no tank at all is not logged (a fill entity stamped many times a day would push the pumps' draws out of the history). A batch recorded by hand (crop_steering.stock_record_batch) skips them the same way; its answer lists the linked tanks it skipped (skipped).
  • Batches left. What one batch takes from a linked tank (stock_get's doses, the stock sensor's dose_ml and batches_left, the low-stock card) is its pump's dose in the recipe in use: the feed schedule's this week, a recipe picked by hand, else the batch's own (its ml_entity reading when usable, else its ml); 0 when that recipe passes the pump by. It follows a dosing setup saved and the schedule's week moving on at midnight. A tank no pump is linked to keeps its own dose_ml / dose_entity.
  • sensor.crop_steering_<prefix>stock_low gives each entry of its tanks attribute an id, and a pump: the id of the pump linked to it, or null.
ServiceWhoDataResponse
crop_steering.stock_drawadmin; automations and the controller app (Home Assistant's Supervisor user) too, as for stock_record_batchroom_id, key, draws: {tank_id: ml}, source ("dose" | "batch"), note?the stock_get answer, with counted, duplicate, skipped (the tank ids it does not know) and linked (its tanks linked to a pump, left to the pump's runs)

stock_draw is idempotent on key (the stock document keeps the last 100 keys, those of the pumps' runs among them). It takes ml / 1000 from each named tank no pump is linked to (never below 0), appends a history entry {at, source, draw_ml, key} and commits a new revision, which raises the low-stock Repairs card (CS-608) as usual. Unknown tank ids are skipped and listed in the response. It answers only when asked (SupportsResponse.OPTIONAL): the controller asks, over REST (?return_response); a call without asking (an older controller) is taken all the same.

The page (Equipment › Feed & dosing)

For the selected room:

  • The tank: level, full, EC, pH and temperature from the room's tank mapping, and the batch state in one line.
  • Stock: every stock tank a pump is linked to (stock_tank), in pump order: its level, its litres, about how many batches or doses it has left, its low mark drawn, and a way to Equipment › Stock.
  • Pumps: a card per pump with a peristaltic-pump drawing (the rotor turns while it doses; still under reduced motion), its name, a status pill (Idle, Dosing, Unavailable, Not calibrated), its flow in mL/s, its last dose (mL, when, result), and a progress bar during a dose (elapsed against expected). Its stock tank, found by the pump's stock_tank id in sensor.crop_steering_<prefix>stock_low (never by name), is drawn as a bottle to scale with its level, "x.x L of y L" and about how many batches it has left at the pump's recipe amount (or doses at its last dose, when the recipe passes the pump by); amber at or under the tank's low mark, red at or under half of it. A pump without one says "No stock tank linked".
  • Dose a pump: the pump, the mL (up to its max_ml), the expected seconds, a review, then the request; while it runs, the target, the elapsed time, the estimated mL so far, and Stop.
  • Make a batch: the steps with their state, the recipe (mL per pump, the total, the expected time), Make a batch through a review, and Stop while it runs.
  • History: the last doses and batches.
  • Dosing setup: the pumps and the batch hardware, from dosing_get candidates, and each pump's stock tank from stock_get (a tank linked to another pump is not offered), saved with dosing_save. Admins only; everyone else sees it read-only.

A room without pumps shows what dosing does and Set up dosing.