Soil moisture-driven irrigation for Home Assistant.
MoistureLoop pairs one soil-moisture sensor with one switch or valve
actuator per zone for closed-loop drip irrigation and watering. It waters in
bounded pulses, waits for water to redistribute through the soil, then requires
a fresh report made at or after the soak ends before deciding whether to pulse
again. Measured moisture is the automatic feedback signal; this is not a timer,
weather, or evapotranspiration controller.
Requires Home Assistant 2025.9.0 or later. Older releases are not supported.
The integration is local-only and hardware-agnostic. It has no cloud account, telemetry, API key, outbound network dependency, or Recorder safety dependency.
Each zone has exactly one sensor and one actuator:
- A new AUTO session can start only from
IDLE, with the zone enabled, when the latest report is valid and fresh andmoisture < start_threshold. - One complete pulse must fit the cycle, session, and current-day limits.
- The actuator is closed and proven OFF, then the full soak runs.
- Continuation or completion requires a valid, fresh report timestamped at or after the soak deadline. Below target, another whole pulse may run; at or above target, the session completes.
The comparisons are exact:
moisture == start_thresholddoes not start a new AUTO session.- An active AUTO session continues only while
moisture < target_threshold. moisture == target_thresholdcompletes the session.- A report exactly at the soak deadline qualifies; an earlier report does not.
- A report exactly on the freshness boundary is fresh.
When an upstream integration writes an unchanged reading to Home Assistant,
Home Assistant can expose it through the entity-filtered state_reported path
and MoistureLoop treats it as a genuine report. Some integrations suppress
unchanged source updates before they become Home Assistant state writes;
MoistureLoop cannot treat those invisible source messages as fresh reports. A
fallback scan never invents a new report timestamp.
AUTO watering remains dependent on a current sensor report while water is flowing. If the sensor becomes invalid or unavailable, the session stops and the actuator follows the shared OFF path. If reports go silent, watering stops when the newest valid report reaches its configured maximum age. A newer valid report, changed or unchanged, replaces that deadline.
SOAKING uses a separate rule: a report before the soak ends may update the UI but cannot decide the session. After the soak, MoistureLoop waits for a qualifying report for at most one sensor-freshness window, then faults stale.
MoistureLoop measures freshness from the report timestamp Home Assistant exposes. The default Sensor Report Maximum Age is a conservative, configurable two hours. During healthy operation, the selected moisture entity must produce Home Assistant state writes often enough to remain within that limit, including during long periods when the measured moisture does not change. MoistureLoop can use changed reports and unchanged reports that Home Assistant actually exposes, but it cannot infer a physical heartbeat that the upstream integration hides. Polling or scanning the entity does not substitute for a report.
For MQTT-backed sensors, Home Assistant's MQTT sensor normally has
force_update: false. If unchanged MQTT payloads are suppressed and do not
advance the moisture entity's report timestamp, configure the MQTT entity or
source so healthy unchanged reports produce Home Assistant state writes. Where
appropriate, force_update: true is one way to do this; it is not a MoistureLoop
requirement or a universal recommendation. Consider the source update rate and
system impact before forcing every update. See the official
Home Assistant MQTT Sensor documentation.
- Watering commands are globally serialized: at most one integration-commanded zone flows at a time.
- An actuator observed or conservatively believed to be flowing blocks every new integration ON, including flow started outside MoistureLoop.
- External flow outside a session is respected and is not counter-commanded; it blocks the shared resource until that exact actuator is proven OFF.
- Every AUTO pulse must fit in full. No partial trailing pulse is used to spend the last part of a budget.
- Manual watering is finite and clamped by its request, the configured manual maximum, session maximum, and remaining current-day budget.
- Every created session resets the minimum interval for later AUTO starts.
- WATERING never resumes after restart, crash, reload, or reconfiguration.
- Unknown watering duration is conservatively overestimated and charged to the affected HA-local calendar day or days.
- Safety state is written atomically and read back before an ON command. A missing, corrupt, future-version, or mismatched initialized Store blocks both AUTO and MANUAL, exhausts the detection day's budget, and raises a Repair.
- Home Assistant shutdown and entry unload close admission first and route possible flow through the same idempotent OFF operation. Full-process shutdown is owned by exactly one Home Assistant Stage-1 shutdown job, which Core runs and awaits while it can still write and verify safety state; a run is recorded as clean only after every required OFF and verified write succeeded.
Software cannot close mechanically failed hardware. Use a valve with a hardware maximum runtime, a master valve, or another independent physical failsafe. If OFF cannot be proven, MoistureLoop raises a critical Repair, retains the global blocker, and continues conservative accounting until exact OFF evidence exists.
The public source and documentation repository is
embersas/moisture-loop, and
problems can be reported through its
issue tracker.
MoistureLoop is not included in the HACS default store. It can be installed as a HACS custom repository:
- Open HACS → Integrations → ⋮ → Custom repositories.
- Add
https://github.com/embersas/moisture-loopas category Integration. - Install MoistureLoop and restart Home Assistant.
For manual development installation, copy
custom_components/moisture_loop/ into the Home Assistant
config/custom_components/ directory and restart Home Assistant.
- Settings → Devices & services → Add integration → MoistureLoop.
- Create the single controller entry.
- On that entry, choose Add zone and select the zone name, its soil-moisture sensor, and the switch or valve that waters it, then set the thresholds, pulse/soak timing, and safety limits. Each field explains itself in the form.
- The new zone is created disabled. Review its settings on the zone device page, then turn its Enabled switch on when ready.
Each native config subentry becomes one zone device. The configured sensor must
produce a finite number from 0 through 100. A switch uses ON/OFF. A valve
must support both open and close; position-only valves are unsupported.
opening, closing, unknown, unavailable, and a nonzero reported position are
never proof of OFF.
Every duration is configured in whole seconds; the setup screens state each field's allowed range in seconds and in human units.
| Setup label | Setting | Default | Allowed range |
|---|---|---|---|
| Start threshold | start_threshold |
30% | 1–99; strictly below target |
| Target threshold | target_threshold |
40% | 2–100; strictly above start |
| Pulse duration | pulse_duration |
60 min (3600 s) | 30 s–60 min |
| Soak duration | soak_duration |
20 min (1200 s) | 1 min–4 h |
| Maximum cycles per session | max_cycles |
2 | 1–20 |
| Maximum watering per session | max_session_runtime |
2 h 5 min (7500 s) | pulse duration–4 h |
| Maximum watering per day | max_daily_runtime |
4 h (14400 s) | pulse duration–12 h |
| Minimum time between automatic sessions | min_session_interval |
6 h (21600 s) | 15 min–7 d |
| Maximum sensor age | sensor_max_age |
2 h (7200 s) | 5 min–24 h |
| Valve confirmation timeout | actuator_confirm_timeout |
30 s | 5 s–5 min |
| Maximum manual watering duration | manual_max_duration |
60 min (3600 s) | 1 min–2 h |
Defaults are conservative starting points, not agronomic advice. Calibrate for the soil, emitters, probe placement, and sensor cadence in the deployment.
A newly created zone begins disabled, in state Disabled, with its Enabled
switch off. No automatic or manual watering is possible for it until the zone is
explicitly enabled, and creation itself never starts a session or commands the
actuator on — even when the moisture reading is already below the start
threshold and every other gate would pass.
Enabling is the deliberate activation step, so be aware of the opposite direction: if the zone is already dry, fresh, and otherwise eligible when Enabled is switched on, automatic watering can begin immediately. Confirm the thresholds, timing, and safety limits first.
Existing zones are unaffected. Enabled or disabled state is durable runtime state: it survives restart, reload, reconfiguration, and delete/re-add of the same durable actuator, and is changed only by an explicit user action.
Zone creation uses Home Assistant's native subentry flow. An unchanged reconfigure is a no-op. A changed reconfigure safely quiesces an active old session before the new configuration is applied. Users do not need to manually reload after add, reconfigure, or delete; configuration-flow code does not own watering safety or reload semantics.
When the durable actuator is unchanged, its retained safety and irrigation history continue. When actuator A is replaced by actuator B, A's possible-flow evidence, blockers, accounting, faults, and acknowledgement remain independently owned by A. The logical zone's conservative daily budget and minimum interval continue onto B; B cannot clear or inherit A's actuator hazard.
A zone can be deleted through Home Assistant's normal UI or API. Core removes the configuration subentry through its native path. From that visible removal, MoistureLoop rejects every new ON for the zone and safely terminates an active AUTO, MANUAL, or SOAKING session in the background. No manual reload is needed.
The zone device and entities disappear, but runtime safety evidence may remain internally as a retained tombstone. This is deliberate:
- unresolved actuator flow, blockers, accounting, faults, and Repairs survive;
- deleting a zone never means unresolved physical-flow evidence was erased;
- delete/re-add of the same durable actuator may reuse the same retained safety lineage, daily runtime, minimum interval, and acknowledgement history;
- a deleted-zone Repair remains available at entry level even without the old zone device;
- retired tombstones are not automatically purged in v0.1.
Each active zone exposes status, current-day watering runtime, last-session and
minimum-interval sensors; watering, problem, and needs-water binary sensors; an
enabled switch; Stop watering, Check now, and Clear fault buttons (entity
keys stop, evaluate_now, clear_fault are unchanged); and Water 15 min,
Water 30 min, and Water 60 min buttons (entity keys manual_pulse_15,
manual_pulse_30, manual_pulse_60). needs_water is informational and never
bypasses an AUTO guard.
Each manual button requests exactly its own fixed duration through the same
validated path as moisture_loop.start_manual_watering, so every guard, the
clamp against the configured manual, session, and remaining daily limits, and
the one-zone-at-a-time water resource all still apply. A button whose duration
exceeds a configured limit is clamped, not refused, and the clamp is reported in
the last-session attributes. Only the action can carry a caller-supplied
duration, so an open-ended manual request remains action-only.
All four actions require exactly one current MoistureLoop zone device_id.
Targets are checked again in the backend. Deleted, unloaded, non-active,
reconciling, failed, or otherwise unsafe runtimes are refused.
| Action | Required data | Behavior |
|---|---|---|
moisture_loop.start_manual_watering |
device_id, duration in seconds |
Starts one explicit bounded run; may clamp or refuse it. |
moisture_loop.stop_watering |
device_id |
Cooperatively stops an active session; no-op when inactive. |
moisture_loop.evaluate_zone |
device_id |
Runs normal AUTO evaluation and bypasses no guard. |
moisture_loop.clear_fault |
device_id |
Clears only when that fault's safety condition permits it. |
Example matching services.yaml:
action: moisture_loop.start_manual_watering
data:
device_id: abc123... # MoistureLoop zone device
duration: 600 # requested secondsMANUAL may ignore only SENSOR_UNAVAILABLE, SENSOR_STALE, and
SENSOR_INVALID. Sensor changes do not stop the bounded run. It is refused for
actuator unavailable/ON-timeout/OFF-timeout faults, invalid configuration,
restored unsafe Store state, a disabled zone, an active session, an actuator
not proven OFF, an occupied global water resource, reconciliation restrictions,
an invalid duration, or an exhausted daily budget.
Repairs cover missing current sensors or actuators, unresolved tombstone actuators, durable identity conflicts, configuration reconciliation failure, runtime Store integrity loss, and unconfirmed OFF. The unconfirmed-OFF Repair is critical. Exact OFF evidence releases only that actuator's blocker and closes its accounting; acknowledgement remains separate and cannot clear a different retained record.
Download diagnostics from the MoistureLoop config entry. They include Store schema/run integrity, configuration-application state, active and retained safety records, durable identities, blockers, sessions/accounting, current observations, and recent transitions with identifiers redacted or shortened as appropriate.
Do not delete files from .storage and do not manually edit Store data. Restore
the exact entity identity, prove the physical actuator OFF, reconfigure the zone,
or use the offered Repair flow as instructed. If watering refuses to start,
check sensor freshness, actuator OFF/availability, Repairs, daily budget,
minimum interval, and external flow before trying again.
First inspect the moisture entity's last_reported timestamp and, where
possible, compare it with the upstream integration or physical device's actual
reporting cadence. A stale fault can mean either that the sensor genuinely
stopped reporting or that source reports continued while unchanged updates were
suppressed before reaching the Home Assistant entity. If the source remains
healthy but last_reported does not advance, correct the upstream entity
configuration instead of treating a larger MoistureLoop timeout as the first fix.
For MQTT sensors, force_update: true is one possible integration-specific
remedy, not a general requirement. A different Sensor Report Maximum Age remains
available within the configured range when the sensor's legitimate observable
cadence requires it.
This session reason means the actuator stopped flowing while MoistureLoop still held an authorized pulse open. MoistureLoop reports the takeover rather than concealing it; it is almost never a MoistureLoop fault.
The common cause is a device that enforces its own run duration independently of
Home Assistant. Multi-outlet tap timers in particular often carry a per-outlet
duration that the device applies to any run, including one your switch or valve
service started, and will close the outlet when that duration elapses regardless
of what opened it. If a zone consistently stops after the same short interval,
check the device-side duration for that outlet before changing anything in
MoistureLoop, and raise it to at least the zone's pulse_duration whenever you
lengthen a pulse. A longer pulse_duration cannot extend a duration the
actuator enforces outside Home Assistant.
Other causes are a physical or app-initiated stop, another controller sharing the outlet, and loss of power or connectivity to the device. Because a genuine takeover is indistinguishable from a lost command, MoistureLoop treats the actuator as unproven until terminal OFF is observed for that exact identity.
The integration also emits moisture_loop_session_started,
moisture_loop_session_finished, moisture_loop_fault_set, and
moisture_loop_fault_cleared events.
v0.1 intentionally has no weather/ET input, calendar scheduling, flow meter, leak measurement, tank interlock, shared-pump model beyond global serialization, multi-sensor/multi-actuator zone, stuck-sensor detection, adaptive learning, or unbounded manual watering.
Automated mocked-time implementation evidence is not physical deployment validation. Status of the seven SPECIFICATION.md §46 / Slice 13 validations:
- Real Home Assistant UI/UX lifecycle validation — validated on a live Home Assistant deployment using synthetic actuators.
- A physical valve state/availability/position matrix — validated / PASS
on real irrigation hardware exposed as a Home Assistant
valve, with the remaining transitional/position/availability cases covered by clearly labelled synthetic entities. - A real entity-registry rename trial — validated live, including rename during an active session, reload and restart while renamed, and restore.
- Measured physical shutdown OFF timing — validated / PASS. A first trial
on 2026-08-26 was stopped by finding B2-1, a lifecycle defect in the
then-current specification;
0.1.0-spec.5moved full-process shutdown ownership to a single Home Assistant Stage-1 shutdown job. On the corrected implementation, a physical active-flow trial proved shutdown-path terminal valve CLOSED 1.36 s after a normal external container stop — inside both the 8-second internal budget and the 10-second external stop window — with runtime measured rather than estimated and the run recorded clean. - Approximately ten simultaneously dry zones in a deployment-scale exercise — validated live with ten synthetic zones.
- Deployment sensor-cadence/default validation — validated / PASS. A clean 2.9974-hour window showed that physical cadence supports the two-hour horizon while the tested default MQTT-to-Home Assistant entity presentation can suppress unchanged writes. The compatibility requirement is documented and the conservative two-hour default is retained.
- HACS/brand presentation — validated for custom-repository install and
presentation; the centralized
home-assistant/brandssubmission is deliberately not made.
All seven prototype validations now have live evidence, including both
water-carrying ones. PROTOTYPE_VALIDATION.md is the evidence ledger for all of
the above, including the one failed attempt that is retained unchanged as
historical evidence. Validation on this deployment is not a guarantee for other
hardware, and the centralized brand submission remains deliberately unmade.
Centralized brand submission and HACS default-store submission are not implied by the local icon or this repository metadata; neither submission has been made.
MoistureLoop software is licensed under the GNU General Public License version 3
only (GPL-3.0-only); see LICENSE. Distributed modifications and
derivative works are subject to the applicable GPL requirements.
The GPL licence applies to the software code. The MoistureLoop name, logo, and official project branding are addressed separately in TRADEMARKS.md. Forks and derivative distributions must not falsely imply that they are official MoistureLoop releases or are endorsed by the official project.