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
| Layer | Owns | Never does |
|---|---|---|
| Integration | Each 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 controller | Switch, press or set any hardware |
| Controller app | Every 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 status | Act 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) |
| Dashboard | Equipment › Feed & dosing: pumps, a dose form, the batch, the recipe, history, and the dosing setup | Write 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.
| Key | Default | Range | What it sets |
|---|---|---|---|
hold_wait_s | 600 | 60..3600 | how long a batch waits at Hold for a shot in flight |
fill_open_s | 10 | 2..120 | how long the fill valve has to read on |
full_blind_s | 15 | 5..300 | how long full_entity may read nothing while the tank fills |
mix_power_s | 20 | 5..300 | how long the mix pump has to draw mix_min_w |
min_flow_ml_s | 0.05 | 0.01..100 | the lowest flow that counts as a calibration |
max_dose_s | 1200 | 60..3600 | the 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
| Service | Who | Data | Response |
|---|---|---|---|
crop_steering.dosing_get | any user | room_id | {schema_version: 1, room_id, config, feed_now, candidates[], can_edit, error} |
crop_steering.dosing_save | admin | room_id, expected_revision, pumps, batch, feed?, profile? (left out: the stored ones are kept) | the same as dosing_get, or error |
crop_steering.dosing_request | admin; a stop: any signed-in user | room_id, action ("dose" | "batch" | "stop"), pump?, ml?, run_anyway? | {request, error} |
room_idis"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
stoponly 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. candidateslists 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(indosing_get, and indosing_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
requestis set AND itsiddiffers from thehandledattribute ofsensor.crop_steering_<prefix>dosing(read withhass.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_requeststores one request{id, action, pump, ml, at, by}(ida uuid4 hex,bythe user's name, null for an automation). Astopalways replaces what is pending. Adoseor abatchreturnserror: "busy"while one is pending, and adoseis refused when the pump is unknown ormlis not in 0 < ml ≤max_ml(errorthen says which). A request does not move the revision.- A test refill run anyway: a
batchwithrun_anyway: true(an administrator's call, as every batch is) is stored withrun_anyway: true; without it the key is left out, as before. Only a batch may carry it: on adoseor astopit 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_savereturnserror: "busy"too (the same word) while a request is pending or whilesensor.crop_steering_<prefix>dosingreadsdosingorbatch(a dose or a batch runs from the setup it started with, which must not change under it),error: "revision"whenexpected_revisionis stale, and anerrornaming the field when the setup breaks a rule under Validation; a refused save answers with the stored setup, unchanged. A pump saved without anidgets 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
handledwith 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 astopfor 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'spower_entityin 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_entityis the motor's own switch on the firmwares this drives (off = idle), so a stop never switches it back on.dose: one pump,mlmL, into the tank as it is.prime/calibrate: one pump's power on forseconds(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'sstopends those); otherwise the pump'spower_entityis 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 astop, it is acted on however old it is while the room is busy or held.batch: the batch sequence. Refused whilehold_entityis 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
doseor abatchis refused (astopis 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 ownhandled_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 astoporstop_pumpstill switches itspower_entityoff. 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)
- Refuse unless the pump's flow (its
flow_entity; with none, itscalibrated_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 timeml / flowis at most 20 minutes,dosing_entityreads, and does not read dosing, andpower_entitydoes not read on ("refused: is running by hand": on these firmwares it is the motor's own switch). - 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. - 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 whenrestore_volumeis on and the volume number can't be read (it could not be put back). - Press
start_entity(button.press,input_button.pressorscript.turn_on), noting the time just before. The pump's power is never switched on. - Expected time
t = ml / flow. Wait (reading every second) until the pump has been seen dosing and then reads not dosing. Deadlinet × 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_entityoff, 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.
- 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
- Put the volume number back when
restore_volumeis 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". - 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.
- Refuse ("refused: ", nothing switched) when the pump is not set up,
secondsis 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'shold_entityis 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 itsseconds, and never longer than 30 s for a prime or 120 s for a calibration. - Write it to
dosing_state.json(its pump's power to switch off) before anything moves. - Switch
power_entityon and read it back on, waiting at most the run's ownseconds(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". - Every second: the app stopping, a
stoporstop_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". - At
seconds, or on any end (an error in the controller included): switchpower_entityoff and read it back (re-sent once). The report'sran_sis the seconds from the switch-on to the off (0.1 s);resultis "done", "stopped", "refused: " or "stuck". - A power that does not read off: "stuck",
ran_s0 (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.
| Step | What happens | Ends the batch (with its code) when |
|---|---|---|
| Hold | hold_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 |
| Close | switch off each held room's valves, main line and pump, and close_entities; read back | a switch does not read off (CS-806) |
| Fill | skipped 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 back | the 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) |
| Mix | skipped 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 power | a mix valve does not open (CS-806); the pump does not read on or does not draw its power (CS-805) |
| Premix | premix_min minutes | |
| Dose | each recipe pump in order, as "One dose", with the amounts read before Hold, in whole mL; 0 skips | a dose ends any other way than finished (CS-801/802) |
| Postmix | postmix_min minutes | |
| Finish | stop 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 back | a switch does not read off (CS-806) |
- A
stopat any point ends the batch: every dosing pump off, the fill valve closed, the mix pump off, the mix valves closed, and thenhold_entityoff. 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_entitystays 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_entitythat 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_entityis 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
batchasked for with no user (bynull: 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_configcan't be read, the check waits. Adosing_configthat 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>):
| Key | Default | Range | What it sets |
|---|---|---|---|
dose_s | 5 | 1..30 s | one dose: seconds of the pump's calibrated flow, in whole mL, never above its max_ml |
max_tries | 2 | 1..3 | doses in one correction |
mix_min | 5 | 2..30 min | mixing after each dose before the tank is read again |
daily_max | 3 | 1..10 | doses in one day, all corrections together |
margin | 0.1 | 0..0.5 pH | how 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_entityoff 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_entityon, the mix pump read back on and its power checked), then up tomax_triesdoses as One dose, each followed bymix_minof mixing and a fresh read of the probe. It ends with the mix pump off and its valves closed, read back, thenhold_entityoff. The status shows it as abatchnamed "pH correction", and its history entry iskind: "correct"with the pH it read (ph). - Stops. At the band (
min + margingoing up,max - margingoing 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'sfilled_at_entityshows 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 gainsph:{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 sendph_rolekeeps 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>) | Default | Range |
|---|---|---|
floor_vwc: a zone under this counts as dry | 15 % | 1..60 |
level_pct: the brightness it dims to | 50 % | 30..90 (never below 30) |
after_min: dry this long, with the lights on, before it dims | 10 min | 1..120 |
max_h: the longest the room stays dimmed | 4 h | 0.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 instate.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 + 3for 10 minutes, when the switch, the room or its kill switch goes off, 5 minutes before the schedule's lights-off (or at it), aftermax_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)
| Code | Title | Watering |
|---|---|---|
| CS-801 | A dosing pump ran past its time and was switched off, or a prime's or calibration's power does not read off | carries on (a batch stops), unless its power does not read off: then the room is held until it does |
| CS-802 | A dose could not be confirmed, or ended early | carries on (a batch stops) |
| CS-803 | A dose or batch was interrupted by a restart | the rooms it held stay held until its dosing hardware reads off |
| CS-804 | The batch tank could not be filled | held rooms water again; the fill valve was closed |
| CS-805 | The mixing pump did not start | held rooms water again |
| CS-806 | Dosing was stopped, or needs a check | held rooms water again once the hardware reads off; a dose's pump that does not read off holds its room |
| CS-807 | A dose was not taken off its stock tank | carries on |
| CS-808 | A batch's mix is off its band, or could not be checked | carries on; an automation's next batch is refused |
| CS-809 | pH 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-810 | The light was dimmed on dry substrate, set back, or handed back | carries on |
| CS-811 | The light stayed dimmed for its longest time, or did not come back (critical) | carries on |
| CS-906 | Batch 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_entityreads on (the motor's own switch on these firmwares) or itsdosing_entityreads dosing (a binary sensor on; a sensor whose state starts withdosing_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_entityreading (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'slinked. 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 (adrawentry) 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_entitynever 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'sdoses, the stock sensor'sdose_mlandbatches_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 (itsml_entityreading when usable, else itsml); 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 owndose_ml/dose_entity. sensor.crop_steering_<prefix>stock_lowgives each entry of itstanksattribute anid, and apump: the id of the pump linked to it, or null.
| Service | Who | Data | Response |
|---|---|---|---|
crop_steering.stock_draw | admin; automations and the controller app (Home Assistant's Supervisor user) too, as for stock_record_batch | room_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_tankid insensor.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_getcandidates, and each pump's stock tank fromstock_get(a tank linked to another pump is not offered), saved withdosing_save. Admins only; everyone else sees it read-only.
A room without pumps shows what dosing does and Set up dosing.