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_PCTis 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/hWeatherStation_WindGust— wind gust km/hWeatherStation_OutdoorTemp— temperature °CWeatherStation_Rainfall_Today— today’s rainfall mmWeatherStation_Rainfall_LastEvent— last rain event mmWeatherStation_Rainfall_ThisWeek— weekly rainfall mmIrrigation_Status— String, current statusIrrigation_LastRun— String, last run timestampGRP_IRRIGATION— GROUP, All Items correlated to IrrigationGRP_PERS_HISTORY_MAPDB— GROUP, Items that persist in MAPDBGRP_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!