[Massive Update] Soil Moisture-Based Irrigation Automation with Ecowitt and OpenSprinkler

Disclaimer: This is mostly created with claude AI, but tested in real world.


Updated: 2026-05-28

  • implement full adaptive learning of watering time needed to reach target moisture level

Adaptive garden irrigation with OpenSprinkler, soil sensors & self-learning run times

openHAB 5.x · JavaScript (ECMAScript Ed. 11) · OpenSprinkler HTTP API


What this rule does

This rule controls an OpenSprinkler irrigation system with two independent zones (lawn and hedge) using capacitive soil moisture sensors. Instead of fixed run times it continuously learns how efficiently each zone absorbs water and automatically adjusts the next run’s duration – extending when the soil was too dry after watering and shortening when it was overwatered.

Key features at a glance:

  • Soil-moisture threshold triggers (only waters when actually needed)

  • Multi-factor weather gate: rain rate, wind speed, frost protection, weekly rainfall accumulation

  • Dynamic threshold scaling based on recent rainfall and temperature

  • Zone-specific time windows: lawn only before 07:00 (avoid sun evaporation), hedge has no daytime limit (shaded, low evaporation)

  • Evening run at 20:00 for the hedge if it was not watered in the morning

  • Post-watering check 45 min after the last station (soil soak-in time)

  • Self-learning efficiency via exponential moving average (EMA): %moisture / minute

  • Bidirectional run-time adjustment: bonus (extend) if target not reached, malus (shorten) if overwatered

  • Hard minimum run times to prevent sub-threshold cycles

  • Full OpenSprinkler HTTP API result-code parsing with status feedback Items

  • Test mode / production mode toggle in constants


Notes & caveats

  • Capacitive soil sensors vary significantly by soil type and installation depth. Allow 5–10 cycles before the learned efficiency values are fully reliable.

  • The 45-minute post-check delay (SOAK_IN_SEC) is a conservative value for loamy garden soil. Sandy soils drain faster – you may be able to reduce this. Heavy clay soils may need 60–90 min.

  • The OpenSprinkler binding and the direct HTTP API are both in use here. Station start is done via HTTP API (/cm?pw=…&sid=…&en=1&t=…) to pass an explicit run time; the openHAB binding is used for status monitoring only.

  • Hedge species used here (Prunus laurocerasus, Photinia) tolerate short over-watering better than lawn – the HEDGE_TARGET_PCT is intentionally set 5 % higher.


Why not just soil moisture?

The WH51 is the heart of this setup — if the soil is wet enough, nothing runs, full stop. But relying on it alone leaves some gaps that can actually harm your plants or waste water.

Frozen ground If it dipped below 2°C overnight, the soil can still be partially frozen even when the air has warmed up. Pumping water onto frozen roots causes cell damage — Cherry Laurel and Photinia are particularly sensitive to this. The moisture sensor has no way of knowing the soil is frozen, it just sees water content. So we ask openHAB Persistence for the temperature minimum over the last 8 hours and skip irrigation if frost occurred.

The sensor hasn’t caught up yet When it’s actively raining, the WH51 at its burial depth often still reads low while water is actively soaking in from above. We check the live rain rate and simply don’t run if it’s already raining — no point adding more.

After heavy rain, wait a bit Even after rain stops, moisture keeps moving down through the soil for hours. The sensor might still read dry at its depth while the root zone is actually fine. We knock the trigger threshold down a few percent after significant rainfall events to account for this.

Wind makes sprinklers useless Above around 30 km/h, water drifts before it hits the ground. The soil sensor can’t tell you that — the weather station can. So we just block irrigation when it’s too windy.

Cold weather means slower drying In spring and autumn, evaporation is low and soil stays moist much longer than in summer. Irrigating to the same threshold year-round would overwater during cooler periods. We reduce the trigger threshold below 10°C so the system only kicks in when the soil is genuinely dry for that time of year.

Smarter runtimes Even when irrigation is needed, we scale the runtime based on how much rain fell that week. A wet week automatically means shorter runs.

The short version: the moisture sensor decides whether to irrigate. The weather data decides whether it’s safe and worth it.


Hardware / Bindings used

Component Details
Irrigation controller OpenSprinkler (firmware 2.2.1), openHAB binding via HTTP API
Soil sensors – Lawn Capacitive sensor, read via SoilMoisture_Lawn_Ch2
Soil sensors – Hedge Capacitive sensor, read via SoilMoisture_Hedge
Weather station Local PWS – rain rate, gust speed, temperature, daily/weekly rainfall totals
Persistence MapDB (restoreOnStartup for learned efficiency & bonus values), InfluxDB (history)

How the learning system works

Morning run (05:00) or state-change trigger
  │
  ├─ Read current moisture (snapshot "before")
  ├─ Calculate run time: base × weather factor + bonus/malus from last run
  ├─ Start stations sequentially
  │
  └─ Timer fires 45 min after last station ends (soil soak-in)
       │
       ├─ Read moisture again ("after")
       ├─ Calculate actual efficiency: Δ% / run_time_minutes
       ├─ Smooth via EMA (α = 0.3): new_eff = 0.3 × measured + 0.7 × stored
       ├─ after < target  →  bonus  = (target - after) / efficiency × 60s  [extend next run]
       ├─ after > target + 2%  →  malus = (after - target) / efficiency × 60s  [shorten next run]
       └─ after ≈ target  →  adjustment = 0  [perfect, keep base time]

The ±2 % dead band around the target prevents sensor jitter from generating false corrections.

After 3–5 irrigation cycles the system converges to stable efficiency values and delivers reliable adjustments.


Triggers

# Type Value Purpose
1 Time of Day 05:00 Morning run – lawn + hedge
2 Time of Day 20:00 Evening run – hedge only (if not watered this morning)
3 Item State Change SoilMoisture_Lawn_Ch2 React to live sensor updates
4 Item State Change SoilMoisture_Hedge React to live sensor updates

Items required

Existing Items (already in your setup)

  • SoilMoisture_Lawn_Ch2 — Number, lawn soil moisture %
  • SoilMoisture_Hedge — Number, hedge soil moisture %
  • WeatherStation_RainRate — rain rate mm/h
  • WeatherStation_WindGust — wind gust km/h
  • WeatherStation_OutdoorTemp — temperature °C
  • WeatherStation_Rainfall_Today — today’s rainfall mm
  • WeatherStation_Rainfall_LastEvent — last rain event mm
  • WeatherStation_Rainfall_ThisWeek — weekly rainfall mm
  • Irrigation_Status — String, current status
  • Irrigation_LastRun — String, last run timestamp
  • GRP_IRRIGATION — GROUP, All Items correlated to Irrigation
  • GRP_PERS_HISTORY_MAPDB— GROUP, Items that persist in MAPDB
  • GRP_PERS_HISTORY_INFLUXDB— GROUP, Items that persist in InfluxDB or similar

New Items (add before activating)

// ── Learned values – MUST be persisted via MapDB ─────────────

Number   Irrigation_Lawn_Efficiency
         label  = "Irrigation efficiency lawn [%.4f %%/min]"
         groups = GRP_IRRIGATION, GRP_PERS_HISTORY_MAPDB, GRP_PERS_HISTORY_INFLUXDB
         // Initial value: 0.5  (set via Developer Console before first run)

Number   Irrigation_Hedge_Efficiency
         label  = "Irrigation efficiency hedge [%.4f %%/min]"
         groups = GRP_IRRIGATION, GRP_PERS_HISTORY_MAPDB, GRP_PERS_HISTORY_INFLUXDB
         // Initial value: 0.3

Number   Irrigation_Lawn_RuntimeAdj_Sec
         label  = "Run-time adjustment lawn [%d s]"
         groups = GRP_IRRIGATION, GRP_PERS_HISTORY_MAPDB
         // Positive = extend, negative = shorten. Initial value: 0

Number   Irrigation_Hedge_RuntimeAdj_Sec
         label  = "Run-time adjustment hedge [%d s]"
         groups = GRP_IRRIGATION, GRP_PERS_HISTORY_MAPDB
         // Initial value: 0

// ── Snapshots (no persistence needed) ────────────────────────

Number   Irrigation_Lawn_MoistureBefore       // moisture before watering
Number   Irrigation_Hedge_MoistureBefore
Number   Irrigation_Lawn_LastRuntime_Sec    // actual run time in seconds
Number   Irrigation_Hedge_LastRuntime_Sec

// ── Status & daily flag ───────────────────────────────────────

String   Irrigation_LearningStatus             // post-check result string
String   Irrigation_RainfallStatus
String   Irrigation_Thresholds
String   Irrigation_Runtime
String   Irrigation_BlockReason

Switch   Irrigation_Hedge_WateredToday
         label  = "Hedge watered today"
         groups = GRP_IRRIGATION
         // Reset to OFF every morning at 05:00 by the rule itself

Initial values – run once in Developer Tools Console:

items.getItem('Irrigation_Lawn_Efficiency').postUpdate(0.5)
items.getItem('Irrigation_Hedge_Efficiency').postUpdate(0.3)
items.getItem('Irrigation_Lawn_RuntimeAdj_Sec').postUpdate(0)
items.getItem('Irrigation_Hedge_RuntimeAdj_Sec').postUpdate(0)
items.getItem('Irrigation_Hedge_WateredToday').postUpdate('OFF')


Configurable constants (top of script)

Constant Default Description
LAWN_THRESHOLD 22 % Moisture threshold to trigger lawn watering
HEDGE_THRESHOLD 32 % Moisture threshold to trigger hedge watering
LAWN_BASE_SEC 2700 s Base run time lawn (45 min)
HEDGE_BASE_SEC 4800 s Base run time hedge (80 min)
LAWN_TARGET_PCT 55 % Target moisture after watering – lawn
HEDGE_TARGET_PCT 60 % Target moisture after watering – hedge
SOAK_IN_SEC 2700 s Soak-in wait before post-check (45 min)
LEARN_ALPHA 0.3 EMA smoothing factor (0 = never update, 1 = instant)
MAX_EXTEND_SEC 900 s Maximum extension per zone (+15 min)
MAX_SHORTEN_SEC 900 s Maximum shortening per zone (−15 min)
MIN_RUNTIME_LAWN 900 s Hard minimum run time lawn (15 min)
MIN_RUNTIME_HEDGE 1800 s Hard minimum run time hedge (30 min)
WIND_MAX 30 km/h Wind gust block threshold
RAIN_RATE_MIN 0.5 mm/h Active rain block threshold
RAIN_TODAY_MAX 5 mm Today’s rainfall block threshold
FROST_BLOCK_TEMP 3 °C Hard frost lockout
COLD_REDUCE_TEMP 10 °C Conservative mode (reduces thresholds by −5 %)

A test mode is included as a commented-out constant block – swap in the test values to verify logic without waiting for actual soil to dry out.


Zone-specific time window logic

Lawn  → allowed only 05:00–07:00 (full sun after 07:00, high evaporation loss)
Hedge → no daytime restriction (dense canopy greatly reduces evaporation)
         morning run at 05:00 OR evening run at 20:00
         → evening run skipped if hedge was already watered in the morning


Sample log output

[RunType] MORNING (5 AM)
[Irrigation_Thresholds] Lawn: <22% | Hedge: <32% | Mode: Normal | Temp: 18.4°C
[Irrigation_Runtime] [MORNING] Factor: 100% | Lawn: 47min (-3min Adj.) | Hedge: 82min (+2min Adj.)
[Irrigation_Status] Lawn dry (19%) → 47min | Hedge dry (29%) → 82min
OS API ✓ Start Lawn Right (47min): Success
OS API ✓ Start Lawn Left (47min): Success
OS API ✓ Start Hedge and borders (82min): Success
[LearnTimer] Post-check scheduled in 221 min
...
[Learning Lawn] Δ=+31.4% in 47.0min → Eff: 0.612 → 0.628 %/min
[Adjustment Lawn] Target 55% met (54.8%) → no adjustment
[Learning Hedge] Δ=+24.1% in 82.0min → Eff: 0.298 → 0.301 %/min
[Adjustment Hedge] Target 60% not reached (52.1%) → next run: +27min
[Irrigation_LearningStatus] Lawn: 19%→54.8% Δ+31.4% | Eff:0.628%/min | Adj:+0min ||
                            Hedge: 29%→52.1% Δ+24.1% | Eff:0.301%/min | Adj:+27min


Start Rule script

Tested on openHAB 5.1.4 · JavaScript ECMAScript 262 Edition 11 · GraalJS

// ============================================================
// RULE: Irrigation Start  (uid: 71f577073f)
// Version: 3.1 – Bidirectional adaptive run time (extend + shorten)
// openHAB 5.1.4 | ECMAScript 262 Ed.11
//
// TRIGGERS (configure in rule):
//   1. Time of Day  05:00            → Morning run (lawn + hedge)
//   2. Time of Day  20:00            → Evening run (hedge only)
//   3. ItemStateChange: SoilMoisture_Hedge
//   4. ItemStateChange: SoilMoisture_Lawn_Ch2
//
// TIME WINDOW LOGIC:
//   Lawn  → only 05:00–07:00 (full sun after 07:00, high evaporation)
//   Hedge → morning run + evening run 20:00 (shaded, low evaporation)
//
// REQUIRED ITEMS (add before activating):
//   Switch  Irrigation_Hedge_WateredToday  → GRP_IRRIGATION
//   (no persistence needed – reset daily by morning run)
// ============================================================

const { items, actions, time } = require('openhab');
var LoggerFactory = Java.type('org.slf4j.LoggerFactory');
var logger = LoggerFactory.getLogger('org.openhab.rule.Irrigation');

// ── Logging ───────────────────────────────────────────────────
const LOG_ENABLED = true;

function log(level, msg) {
  if (!LOG_ENABLED) return;
  if (level === 'warn')  { logger.warn(msg);  return; }
  if (level === 'error') { logger.error(msg); return; }
  logger.info(msg);
}

function setStatus(itemName, msg) {
  log('info', `[${itemName}] ${msg}`);
  items.getItem(itemName).sendCommand(msg);
}

function safeNum(itemName, fallback) {
  try {
    const v = parseFloat(items.getItem(itemName).numericState);
    return (isNaN(v) || v === null) ? fallback : v;
  } catch(e) { return fallback; }
}

// ── Configuration ─────────────────────────────────────────────
const OS_URL        = 'http://NameOrIPfromOpenSprinker';
const OS_PW         = 'MD5Hash Password';
const SID_LAWN_R    = 0;   // Station ID – Lawn Right
const SID_LAWN_L    = 1;   // Station ID – Lawn Left
const SID_HEDGE     = 2;   // Station ID – Hedge & borders

// ── PRODUCTION mode ───────────────────────────────────────────
const LAWN_THRESHOLD      = 22;    // % – start watering lawn below this
const HEDGE_THRESHOLD     = 32;    // % – start watering hedge below this
const WIND_MAX            = 30;    // km/h – block threshold
const RAIN_RATE_MIN       = 0.5;   // mm/h – active rain block
const RAIN_TODAY_MAX      = 5;     // mm   – today's rainfall block
const RAIN_LAST_EVENT_MAX = 20;    // mm   – last rain event block
const RAIN_WEEK_MAX       = 20;    // mm   – weekly rainfall block
const LAWN_BASE_SEC       = 2700;  // s    – base run time lawn  (45 min)
const HEDGE_BASE_SEC      = 4800;  // s    – base run time hedge (80 min)
const FROST_BLOCK_TEMP    = 3;     // °C   – hard frost lockout
const COLD_REDUCE_TEMP    = 10;    // °C   – conservative mode (–5% thresholds)
/*
// ── TEST mode ─────────────────────────────────────────────────
const LAWN_THRESHOLD      = 60;
const HEDGE_THRESHOLD     = 50;
const WIND_MAX            = 30;
const RAIN_RATE_MIN       = 0.5;
const RAIN_TODAY_MAX      = 999;
const RAIN_LAST_EVENT_MAX = 999;
const RAIN_WEEK_MAX       = 999;
const LAWN_BASE_SEC       = 30;
const HEDGE_BASE_SEC      = 30;
const FROST_BLOCK_TEMP    = -99;
const COLD_REDUCE_TEMP    = -99;
*/

// ── Learning parameters ───────────────────────────────────────
const LAWN_TARGET_PCT    = 55;    // % target moisture after watering – lawn
const HEDGE_TARGET_PCT   = 60;    // % target moisture after watering – hedge
const SOAK_IN_SEC        = 2700;  // s  – wait after last station before post-check (45 min)
const LEARN_ALPHA        = 0.3;   // EMA smoothing factor (0 = never update, 1 = instant)
const MAX_EXTEND_SEC     = 900;   // s  – max. extension per zone (+15 min)
const MAX_SHORTEN_SEC    = 900;   // s  – max. shortening per zone (−15 min)
const MIN_RUNTIME_LAWN   = 900;   // s  – hard minimum lawn run time (15 min)
const MIN_RUNTIME_HEDGE  = 1800;  // s  – hard minimum hedge run time (30 min)
const EFF_DEFAULT_LAWN   = 0.50;  // %/min – default efficiency on first run
const EFF_DEFAULT_HEDGE  = 0.30;  // %/min – default efficiency on first run

// ── OpenSprinkler API result codes (firmware 2.2.1) ──────────
const OS_RESULT = {
  1:  'Success',
  2:  'Unauthorized (wrong password)',
  3:  'Password confirmation mismatch',
  16: 'Missing parameters',
  17: 'Value out of range',
  18: 'Invalid data format',
  19: 'Invalid RF code',
  32: 'Page not found',
  48: 'Not permitted (e.g. master station)',
  64: 'Upload failed'
};

function parseOsResult(responseText, action) {
  try {
    const json = JSON.parse(responseText);
    const code = json.result;
    const text = OS_RESULT[code] || `Unknown code (${code})`;
    if (code === 1) {
      log('info', `OS API ✓ ${action}: ${text}`);
    } else {
      log('warn', `OS API ✗ ${action}: ${text} [code ${code}]`);
      items.getItem('Irrigation_Status').sendCommand(`ERROR: ${action} – ${text}`);
    }
    return code === 1;
  } catch(e) {
    log('error', `OS API error ${action}: ${e}`);
    items.getItem('Irrigation_Status').sendCommand(`ERROR: ${action} – No valid response`);
    return false;
  }
}

function startStation(sid, durationSec, name) {
  const url = `${OS_URL}/cm?pw=${OS_PW}&sid=${sid}&en=1&t=${durationSec}`;
  const response = actions.HTTP.sendHttpGetRequest(url, 5000);
  const ok = parseOsResult(response, `Start ${name} (${Math.round(durationSec/60)}min)`);
  if (ok) {
    const ts = Java.type('java.time.LocalDateTime').now()
                   .format(Java.type('java.time.format.DateTimeFormatter')
                   .ofPattern('dd.MM.yyyy HH:mm'));
    items.getItem('Irrigation_LastRun').sendCommand(
      `${ts} – ${name} started (${Math.round(durationSec/60)} min)`
    );
  }
}

// ════════════════════════════════════════════════════════════
// STEP 1: DETERMINE RUN TYPE & CHECK TIME WINDOW
// ════════════════════════════════════════════════════════════
const now  = time.ZonedDateTime.now();
const hour = now.hour();

// Derive run type from current hour:
//   MORNING = 05:00–11:59 → lawn + hedge
//   EVENING = 18:00–22:59 → hedge only (if not yet watered today)
//   other   = outside window → abort
const RUN_MORNING = 'MORNING';
const RUN_EVENING = 'EVENING';

let runType;
if (hour >= 5 && hour < 12) {
  runType = RUN_MORNING;
} else if (hour >= 18 && hour < 23) {
  runType = RUN_EVENING;
} else {
  log('info', `[TimeWindow] ${hour}h – outside allowed windows (05–12 / 18–23), skipped`);
  return;
}

log('info', `[RunType] ${runType} (${hour}h)`);

// Lawn: sun protection – only start in morning window BEFORE 07:00
// Hedge: no daytime limit (shade reduces evaporation significantly)
const lawnAllowed  = (runType === RUN_MORNING) && (hour < 7);
const hedgeAllowed = true;

// Morning run: reset daily flag for new day
if (runType === RUN_MORNING) {
  items.getItem('Irrigation_Hedge_WateredToday').sendCommand('OFF');
  log('info', '[MorningRun] Daily hedge flag reset');
}

// Evening run: only proceed if hedge was NOT yet watered today
if (runType === RUN_EVENING) {
  const hedgeAlreadyWatered = items.getItem('Irrigation_Hedge_WateredToday').state === 'ON';
  if (hedgeAlreadyWatered) {
    log('info', '[EveningRun] Hedge was already watered this morning – evening run skipped');
    setStatus('Irrigation_Status', 'Evening run: hedge already watered today – no action');
    return;
  }
  log('info', '[EveningRun] Hedge not yet watered today – checking conditions');
}

// ════════════════════════════════════════════════════════════
// STEP 2: READ SENSOR VALUES
// ════════════════════════════════════════════════════════════
const moistureLawn   = safeNum('SoilMoisture_Lawn_Ch2', 0);
const moistureHedge  = safeNum('SoilMoisture_Hedge', 0);
const rainRate       = safeNum('WeatherStation_RainRate', 0);
const windGust       = safeNum('WeatherStation_WindGust', 0);
const temperature    = safeNum('WeatherStation_OutdoorTemp', 0);
const rainToday      = safeNum('WeatherStation_Rainfall_Today', 0);
const rainLastEvent  = safeNum('WeatherStation_Rainfall_LastEvent', 0);
const rainThisWeek   = safeNum('WeatherStation_Rainfall_ThisWeek', 0);

setStatus('Irrigation_RainfallStatus',
  `Today: ${rainToday}mm | Last event: ${rainLastEvent}mm | Week: ${rainThisWeek}mm | Now: ${rainRate}mm/h`);

// ════════════════════════════════════════════════════════════
// STEP 3: BLOCK CONDITIONS
// ════════════════════════════════════════════════════════════
if (temperature <= FROST_BLOCK_TEMP) {
  setStatus('Irrigation_BlockReason', `Frost risk (${temperature}°C)`);
  setStatus('Irrigation_Status', `BLOCKED: Frost risk (${temperature}°C)`);
  return;
}
if (rainRate >= RAIN_RATE_MIN) {
  setStatus('Irrigation_BlockReason', `Rain active (${rainRate} mm/h)`);
  setStatus('Irrigation_Status', `BLOCKED: Rain active (${rainRate} mm/h)`);
  return;
}
if (windGust >= WIND_MAX) {
  setStatus('Irrigation_BlockReason', `Wind too strong (${windGust} km/h)`);
  setStatus('Irrigation_Status', `BLOCKED: Wind too strong (${windGust} km/h)`);
  return;
}
if (rainToday >= RAIN_TODAY_MAX) {
  setStatus('Irrigation_BlockReason', `${rainToday}mm today – soil still absorbing`);
  setStatus('Irrigation_Status', `BLOCKED: ${rainToday}mm fallen today`);
  return;
}
items.getItem('Irrigation_BlockReason').sendCommand('No active block');

// ════════════════════════════════════════════════════════════
// STEP 4: CALCULATE THRESHOLDS & RUN TIMES
// ════════════════════════════════════════════════════════════
let lawnThreshold  = LAWN_THRESHOLD;
let hedgeThreshold = HEDGE_THRESHOLD;
let mode = 'Normal';

if (rainLastEvent >= RAIN_LAST_EVENT_MAX) {
  lawnThreshold  -= 5;
  hedgeThreshold -= 5;
  mode = 'Conservative (rain)';
}
if (temperature <= COLD_REDUCE_TEMP) {
  lawnThreshold  = Math.max(0, lawnThreshold  - 5);
  hedgeThreshold = Math.max(0, hedgeThreshold - 5);
  mode = mode === 'Normal' ? 'Conservative (cold)' : 'Conservative (rain + cold)';
}
setStatus('Irrigation_Thresholds',
  `Lawn: <${lawnThreshold}% | Hedge: <${hedgeThreshold}% | Mode: ${mode} | Temp: ${temperature}°C`);

// Weekly rainfall factor: reduce run time if it rained a lot this week
let runtimeFactor = 1.0;
if (rainThisWeek >= RAIN_WEEK_MAX) {
  runtimeFactor = Math.max(0.5, 1.0 - (rainThisWeek - RAIN_WEEK_MAX) / 100);
}

// Read adjustment (bonus/malus) & reset immediately to prevent double-use
// Value is positive (extend) or negative (shorten)
const adjLawn  = Math.min(MAX_EXTEND_SEC, Math.max(-MAX_SHORTEN_SEC, safeNum('Irrigation_Lawn_RuntimeAdj_Sec',  0)));
const adjHedge = Math.min(MAX_EXTEND_SEC, Math.max(-MAX_SHORTEN_SEC, safeNum('Irrigation_Hedge_RuntimeAdj_Sec', 0)));
items.getItem('Irrigation_Lawn_RuntimeAdj_Sec').postUpdate(0);
items.getItem('Irrigation_Hedge_RuntimeAdj_Sec').postUpdate(0);

// Final run time: base × factor + adjustment, never below minimum
const lawnDuration  = Math.max(MIN_RUNTIME_LAWN,  Math.round(LAWN_BASE_SEC  * runtimeFactor) + adjLawn);
const hedgeDuration = Math.max(MIN_RUNTIME_HEDGE, Math.round(HEDGE_BASE_SEC * runtimeFactor) + adjHedge);

const adjLawnLabel  = adjLawn  >= 0 ? `+${Math.round(adjLawn/60)}min`  : `${Math.round(adjLawn/60)}min`;
const adjHedgeLabel = adjHedge >= 0 ? `+${Math.round(adjHedge/60)}min` : `${Math.round(adjHedge/60)}min`;

setStatus('Irrigation_Runtime',
  `[${runType}] Factor: ${Math.round(runtimeFactor*100)}% | ` +
  `Lawn: ${Math.round(lawnDuration/60)}min (${adjLawnLabel} adj.) | ` +
  `Hedge: ${Math.round(hedgeDuration/60)}min (${adjHedgeLabel} adj.)`);

// ════════════════════════════════════════════════════════════
// STEP 5: BUILD WATERING SEQUENCE
// ════════════════════════════════════════════════════════════
let timeOffset    = 0;
let statusParts   = [];
let lawnWatered   = false;
let hedgeWatered  = false;

// ── LAWN: morning run only, before 07:00 ─────────────────────
if (!lawnAllowed) {
  if (runType === RUN_MORNING) {
    statusParts.push(`Lawn skipped (after 07:00 – high solar evaporation)`);
  } else {
    statusParts.push(`Lawn skipped (${runType} – morning run only)`);
  }
  log('info', `[Lawn] Time window not active (hour ${hour}) – skipped`);
} else if (moistureLawn < lawnThreshold) {
  statusParts.push(`Lawn dry (${moistureLawn}%) → ${Math.round(lawnDuration/60)}min`);
  lawnWatered = true;

  items.getItem('Irrigation_Lawn_MoistureBefore').postUpdate(moistureLawn);
  items.getItem('Irrigation_Lawn_LastRuntime_Sec').postUpdate(lawnDuration);

  actions.ScriptExecution.createTimer(time.ZonedDateTime.now().plusSeconds(timeOffset), () => {
    startStation(SID_LAWN_R, lawnDuration, 'Lawn Right');
  });
  timeOffset += lawnDuration + 60;

  actions.ScriptExecution.createTimer(time.ZonedDateTime.now().plusSeconds(timeOffset), () => {
    startStation(SID_LAWN_L, lawnDuration, 'Lawn Left');
  });
  timeOffset += lawnDuration + 60;
} else {
  statusParts.push(`Lawn OK (${moistureLawn}%)`);
}

// ── HEDGE: morning + evening run, no daytime restriction ─────
if (!hedgeAllowed) {
  statusParts.push(`Hedge skipped (night lock)`);
  log('info', '[Hedge] Night lock active – skipped');
} else if (moistureHedge < hedgeThreshold) {
  statusParts.push(`Hedge dry (${moistureHedge}%) → ${Math.round(hedgeDuration/60)}min [${runType}]`);
  hedgeWatered = true;

  items.getItem('Irrigation_Hedge_MoistureBefore').postUpdate(moistureHedge);
  items.getItem('Irrigation_Hedge_LastRuntime_Sec').postUpdate(hedgeDuration);

  // Set daily flag: hedge watered today
  items.getItem('Irrigation_Hedge_WateredToday').sendCommand('ON');

  actions.ScriptExecution.createTimer(time.ZonedDateTime.now().plusSeconds(timeOffset), () => {
    startStation(SID_HEDGE, hedgeDuration, 'Hedge and borders');
  });
  timeOffset += hedgeDuration;
} else {
  statusParts.push(`Hedge OK (${moistureHedge}%)`);
}

setStatus('Irrigation_Status', statusParts.join(' | '));

// ════════════════════════════════════════════════════════════
// STEP 6: POST-CHECK & LEARNING (45 min after last station)
// ════════════════════════════════════════════════════════════
if (lawnWatered || hedgeWatered) {
  const postCheckSec = timeOffset + SOAK_IN_SEC;
  log('info', `[LearnTimer] Post-check scheduled in ${Math.round(postCheckSec / 60)} min`);

  // Freeze values in closure
  const capMoistLawnBefore  = moistureLawn;
  const capMoistHedgeBefore = moistureHedge;
  const capDurLawn          = lawnDuration;
  const capDurHedge         = hedgeDuration;
  const capLawnWatered      = lawnWatered;
  const capHedgeWatered     = hedgeWatered;

  actions.ScriptExecution.createTimer(time.ZonedDateTime.now().plusSeconds(postCheckSec), () => {

    const moistLawnAfter  = safeNum('SoilMoisture_Lawn_Ch2', 0);
    const moistHedgeAfter = safeNum('SoilMoisture_Hedge', 0);
    const learnParts      = [];

    // ── Learning cycle for one zone ──────────────────────────
    function learnCycle(zone, mBefore, mAfter, durationSec, target, effItem, adjItem, effDefault) {
      const deltaMoist   = mAfter - mBefore;
      const runtimeMin   = durationSec / 60;

      // Load stored efficiency from mapdb
      let oldEff = safeNum(effItem, -1);
      if (oldEff <= 0 || isNaN(oldEff)) oldEff = effDefault;

      // Smooth new efficiency with EMA
      let newEff = oldEff;
      if (deltaMoist > 0.5 && runtimeMin > 0) {
        const measuredEff = deltaMoist / runtimeMin;
        if (measuredEff >= 0.05 && measuredEff <= 5.0) {
          newEff = LEARN_ALPHA * measuredEff + (1 - LEARN_ALPHA) * oldEff;
          items.getItem(effItem).postUpdate(parseFloat(newEff.toFixed(4)));
          log('info',
            `[Learning ${zone}] Δ=${deltaMoist.toFixed(1)}% in ${runtimeMin.toFixed(0)}min → ` +
            `Eff: ${oldEff.toFixed(3)} → ${newEff.toFixed(3)} %/min`);
        } else {
          log('warn', `[Learning ${zone}] Efficiency ${measuredEff.toFixed(3)} outside plausibility range [0.05–5.0] – ignored`);
        }
      } else {
        log('warn', `[Learning ${zone}] No moisture rise (Δ=${deltaMoist.toFixed(1)}%) – efficiency unchanged`);
      }

      // Calculate bidirectional adjustment for next run
      let adjSec = 0;
      if (mAfter < target) {
        // Under-watered → positive adjustment (more time next run)
        const deficit = target - mAfter;
        adjSec = Math.min(MAX_EXTEND_SEC, Math.round((deficit / newEff) * 60));
        items.getItem(adjItem).postUpdate(adjSec);
        log('info', `[Adjustment ${zone}] Target ${target}% not reached (${mAfter.toFixed(1)}%) → next run: +${Math.round(adjSec/60)}min`);
      } else if (mAfter > target + 2) {
        // Over-watered (>2% dead band to ignore sensor jitter) → negative adjustment (less time)
        const excess = mAfter - target;
        adjSec = -Math.min(MAX_SHORTEN_SEC, Math.round((excess / newEff) * 60));
        items.getItem(adjItem).postUpdate(adjSec);
        log('info', `[Adjustment ${zone}] Target ${target}% exceeded (${mAfter.toFixed(1)}%) → next run: ${Math.round(adjSec/60)}min`);
      } else {
        // Perfect – within ±2% of target
        items.getItem(adjItem).postUpdate(0);
        log('info', `[Adjustment ${zone}] Target ${target}% met (${mAfter.toFixed(1)}%) → no adjustment`);
      }

      const adjLabel = adjSec >= 0 ? `+${Math.round(adjSec/60)}min` : `${Math.round(adjSec/60)}min`;

      return (
        `${zone}: ${mBefore}%→${mAfter.toFixed(1)}% Δ${deltaMoist >= 0 ? '+' : ''}${deltaMoist.toFixed(1)}% | ` +
        `Eff:${newEff.toFixed(3)}%/min | Adj:${adjLabel}`
      );
    }
    // ─────────────────────────────────────────────────────────

    if (capLawnWatered) {
      learnParts.push(learnCycle(
        'Lawn', capMoistLawnBefore, moistLawnAfter, capDurLawn, LAWN_TARGET_PCT,
        'Irrigation_Lawn_Efficiency', 'Irrigation_Lawn_RuntimeAdj_Sec', EFF_DEFAULT_LAWN
      ));
    }
    if (capHedgeWatered) {
      learnParts.push(learnCycle(
        'Hedge', capMoistHedgeBefore, moistHedgeAfter, capDurHedge, HEDGE_TARGET_PCT,
        'Irrigation_Hedge_Efficiency', 'Irrigation_Hedge_RuntimeAdj_Sec', EFF_DEFAULT_HEDGE
      ));
    }

    setStatus('Irrigation_LearningStatus', learnParts.join(' || '));
  });
}


Stop Rule script

Tested on openHAB 5.1.4 · JavaScript ECMAScript 262 Edition 11 · GraalJS

// ============================================================
// RULE: Irrigation Stop 
// openHAB 5.1.4 | ECMAScript 262 Ed.11
//
// TRIGGERS:
//   - Item State Change: WeatherStation_RainRate
//   - Item State Change: WeatherStation_WindGust
//
// PURPOSE:
//   Immediately stops all running stations when rain or wind
//   exceeds the configured thresholds.
// ============================================================

const { items, actions } = require('openhab');
var LoggerFactory = Java.type('org.slf4j.LoggerFactory');
var logger = LoggerFactory.getLogger('org.openhab.rule.IrrigationStop');

// ── Logging ───────────────────────────────────────────────────
const LOG_ENABLED = false;

function log(level, message) {
  if (!LOG_ENABLED) return;
  if (level === 'warn')  { logger.warn(message);  return; }
  if (level === 'error') { logger.error(message); return; }
  logger.info(message);
}

// ── OpenSprinkler API result codes (firmware 2.2.1) ──────────
const OS_RESULT = {
  1:  'Success',
  2:  'Unauthorized (wrong password)',
  3:  'Password confirmation mismatch',
  16: 'Missing parameters',
  17: 'Value out of range',
  18: 'Invalid data format',
  19: 'Invalid RF code',
  32: 'Page not found',
  48: 'Not permitted (e.g. master station)',
  64: 'Upload failed'
};

function parseOsResult(responseText, action) {
  try {
    const json = JSON.parse(responseText);
    const code = json.result;
    const text = OS_RESULT[code] || `Unknown code (${code})`;
    if (code === 1) {
      log('info', `OS API ✓ ${action}: ${text}`);
    } else {
      log('warn', `OS API ✗ ${action}: ${text} [code ${code}]`);
      items.getItem('Irrigation_Status').sendCommand(`ERROR: ${action} – ${text}`);
    }
    return code === 1;
  } catch(e) {
    log('error', `OS API ${action}: Response not parseable – ${responseText}`);
    items.getItem('Irrigation_Status').sendCommand(`ERROR: ${action} – No valid response`);
    return false;
  }
}

// ── Configuration ─────────────────────────────────────────────
const OS_URL       = 'http://NameOrIPOpenSprinkler';
const OS_PW        = 'MD5Hash Password';
const RAIN_RATE_MIN = 0.5;  // mm/h – stop threshold
const WIND_MAX      = 30;   // km/h – stop threshold

// ── Stations ──────────────────────────────────────────────────
const stations = [
  { sid: 0, name: 'Lawn Right' },
  { sid: 1, name: 'Lawn Left'  },
  { sid: 2, name: 'Hedge'      }
];

function stopStation(sid, name) {
  const url = `${OS_URL}/cm?pw=${OS_PW}&sid=${sid}&en=0&t=0`;
  const response = actions.HTTP.sendHttpGetRequest(url, 5000);
  parseOsResult(response, `Stop ${name}`);
}

function stopAll(reason) {
  log('info', `[StopRule] Stopping all stations – reason: ${reason}`);
  items.getItem('Irrigation_BlockReason').sendCommand(reason);
  items.getItem('Irrigation_Status').sendCommand(`STOPPED: ${reason}`);
  stations.forEach(s => stopStation(s.sid, s.name));
}

// ── Read sensor values ────────────────────────────────────────
const rainRate = parseFloat(items.getItem('WeatherStation_RainRate').numericState) || 0;
const windGust = parseFloat(items.getItem('WeatherStation_WindGust').numericState) || 0;

log('info', `[StopRule] Checking – Rain: ${rainRate} mm/h | Wind: ${windGust} km/h`);

// ── Check stop conditions ─────────────────────────────────────
if (rainRate >= RAIN_RATE_MIN) {
  stopAll(`Rain active (${rainRate} mm/h)`);
} else if (windGust >= WIND_MAX) {
  stopAll(`Wind too strong (${windGust} km/h)`);
} else {
  log('info', '[StopRule] Conditions OK – no action');
}

Changelog

Version Changes
1.0 Basic moisture threshold + fixed run time, weather gates
2.0 Post-watering check, EMA efficiency learning, unidirectional bonus (extend only)
3.0 Zone-specific time windows, hedge evening run at 20:00, daily flag item
3.1 Bidirectional adjustment: malus (shorten) when overwatered, hard minimum run times, ±2 % dead band

Feedback and improvements very welcome!

1 Like