# Batch-tank dosing

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>`)

```jsonc
{
  "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_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: <entity> 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 <id> 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; <entity>
could not be stamped" or "stopped: <step>: <reason>"; a refusal "refused: <reason>" (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; <entity> 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: <pump> 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: <pump> 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 <pump>
   could not be put back to <x> 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: <reason>", 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: <entity> 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: <reason>" 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](https://cropsteering.growlabs.nz/docs/calibration#dosing-pump-prime-and-calibrate). 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](https://cropsteering.growlabs.nz/docs/calibration#safety-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
<pump> 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:
<pump> 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 `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; <entity> 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: <entity>". 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).

```jsonc
{
  "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_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>`) | 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 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)

| 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_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.

| 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_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**.
