I have a RainMachine Touch HD.
Like all other RainMachine controllers, it is meant to no longer receive support as it has been discontinued.
I paid for a one-year subscription to their premium service solely and exclusively for two reasons
- I wanted to receive notifications about the irrigation of my zones
- I wanted to receive notifications about the update of the weather services
- I wanted the 7 days forecast (yesterday included)
The official integration is fantastic, but it doesn’t provide this information.
Below, I explain how I did it. I’m with Home Assistant OS.
The python code was written with the help of ChatGPT.
I used the generated sensors to receive notifications and to create additional sensors for making graphs, and therefore, I canceled the premium service subscription and disconnected the RainMachine from their cloud.
- Install the official AppDaemon add-on and start it.
- Using the File Editor addon or via SMB create the file /addon_configs/XXXXXX_appdaemon/apps/rainmachine_status.py
import appdaemon.plugins.hass.hassapi as hass
import requests
import urllib3
import json
from datetime import datetime, timedelta
import pytz
from datetime import time as dtime
TZ = pytz.timezone("Europe/Rome")
class RainMachineStatus(hass.Hass):
def initialize(self):
self.run_every(self.update_all, self._next_5min_boundary(), 5*60)
self.run_daily(self._pre_midnight_reset, dtime(hour=23, minute=59, second=58, tzinfo=TZ))
self.run_daily(self._midnight_anchor, dtime(hour=0, minute=0, second=0, tzinfo=TZ))
# Delay
self.delay_number_entity = self.args.get("delay_number_entity", "input_number.rainmachine_delay_days")
self.apply_button_entity = self.args.get("apply_button_entity", "input_button.rainmachine_apply_raindelay")
self.listen_state(self._on_apply_rain_delay, self.apply_button_entity)
def seconds_to_min_sec(self, seconds):
minutes = int(seconds) // 60
secs = int(seconds) % 60
return f"{minutes}:{secs:02d}"
def parse_pre_json(self, response_text):
text = response_text.strip()
if text.startswith("<pre>") and text.endswith("</pre>"):
text = text[5:-6].strip()
return json.loads(text)
def _next_5min_boundary(self):
now = datetime.now(TZ)
base = now.replace(second=0, microsecond=0)
add_min = (5 - (base.minute % 5)) % 5
if add_min == 0:
add_min = 5
return base + timedelta(minutes=add_min)
def update_all(self, kwargs):
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
login_url = self.args["login_url"]
parser_url = self.args["parser_url"]
watering_url = self.args["watering_url"]
details_url = self.args["details_url"]
forecast_url = self.args["forecast_url"]
raindelay_url = self.args["raindelay_url"]
password = self.args["password"]
headers = { "Content-Type": "text/plain" }
payload = { "pwd": password, "remember": 1 }
try:
self.log("RainMachine login...")
r = requests.post(login_url, json=payload, headers=headers, verify=False)
r.raise_for_status()
token = r.json().get("access_token")
self.log("Login OK")
def add_token(url):
sep = '&' if '?' in url else '?'
return f"{url}{sep}access_token={token}"
# === PARSERS ===
self.log("Getting parser data...")
r = requests.get(add_token(parser_url), verify=False)
r.raise_for_status()
try:
parsed = self.parse_pre_json(r.text)
parsers = parsed.get("parsers", [])
except Exception as e:
self.error(f"parser_url JSON parsing failed: {e}\nRaw content:\n{r.text}")
return
description_map = {
"met.no": "METNO",
"openweathermap.org": "OpenWeatherMap",
# "accuweather.com": "AccuWeather",
"WH2550A": "WH2550A", #YOU CAN DELETE THIS LINE, IT'S MY PERSONAL WEATHER STATION
"Weather Underground": "WUnderground"
}
parser_friendly_names = {
"METNO": "MET.NO",
"OpenWeatherMap": "OpenWeather",
# "AccuWeather": "AccuWeather",
"WH2550A": "WH2550A personal station", #YOU CAN DELETE THIS LINE, IT'S MY PERSONAL WEATHER STATION
"WUnderground": "WUnderground"
}
for parser in parsers:
desc = parser.get("description", "")
last_run = parser.get("lastRun", "unknown")
for key, label in description_map.items():
if key in desc:
sensor = f"sensor.rainmachine_{label}_last_run"
self.set_state(sensor,
state=last_run,
attributes={
"friendly_name": parser_friendly_names.get(label, label),
"device_class": "timestamp" if last_run != "unknown" else None,
"active": last_run != "unknown",
"icon": self.get_state("sensor.rainmachine_forecast_condition_1", attribute="icon") # METEO ICON BASED ON TODAY FORECAST CONDITIONS
})
# === TODAY WATERING ===
self.log("Getting today's watering summary...")
r = requests.get(add_token(watering_url), verify=False)
r.raise_for_status()
try:
watering_data = self.parse_pre_json(r.text)
days = watering_data.get("waterLog", {}).get("days", [])
except Exception as e:
self.error(f"watering_url JSON parsing failed: {e}\nRaw content:\n{r.text}")
return
if isinstance(days, list):
today_str = datetime.now(TZ).strftime("%Y-%m-%d")
today_entry = next((d for d in days if d.get("date") == today_str), None)
if today_entry is None:
real_duration = 0
user_duration = 0
date_str = today_str
else:
real_duration = int(today_entry.get("realDuration", 0))
user_duration = int(today_entry.get("userDuration", 0))
date_str = today_entry.get("date", today_str)
self.set_state(
"sensor.rainmachine_today_watering",
state=str(real_duration // 60),
attributes={
"friendly_name": "Irrigazione giornaliera",
"unit_of_measurement": "min",
"date": date_str,
"userDuration": self.seconds_to_min_sec(user_duration),
"icon": "mdi:sprinkler"
}
)
# === RAIN DELAY ===
self.log("Getting rain delay...")
r = requests.get(add_token(raindelay_url), verify=False)
r.raise_for_status()
translations = {
"days": "giorni", # TRANSLATE IN YOUR LANGUAGE
"hours": "ore", # TRANSLATE IN YOUR LANGUAGE
"mins": "minuti" # TRANSLATE IN YOUR LANGUAGE
}
try:
rd = self.parse_pre_json(r.text)
delay_sec = int(rd.get("delayCounter", -1))
except Exception as e:
self.error(f"raindelay_url JSON parsing failed: {e}\nRaw content:\n{r.text}")
delay_sec = -1
if delay_sec == -1:
state_str = f"0 {translations['days']} 0 {translations['hours']} 0 {translations['mins']}"
icon = "mdi:timer-off"
ends_at = None
days = hours = minutes = 0
else:
days = delay_sec // 86400
hours = (delay_sec % 86400) // 3600
minutes = (delay_sec % 3600) // 60
state_str = f"{days} {translations['days']} {hours} {translations['hours']} {minutes} {translations['mins']}"
icon = "mdi:timer-sand"
ends_at = (datetime.now() + timedelta(seconds=delay_sec)).strftime("%Y-%m-%d %H:%M:%S")
self.set_state(
"sensor.rainmachine_rain_delay",
state=state_str,
attributes={
"friendly_name": "Sospensione irrigazione", # English: Snooze watering
"icon": icon,
"seconds_remaining": 0 if delay_sec == -1 else delay_sec,
"minutes_remaining": minutes,
"hours_remaining": hours,
"days_remaining": days,
"ends_at": ends_at # timestamp fine ritardo (se attivo)
}
)
# === WATERING ZONE DETAILS ===
self.log("Getting watering details by zone...")
r = requests.get(add_token(details_url), verify=False)
r.raise_for_status()
try:
details_data = self.parse_pre_json(r.text)
zones = details_data.get("waterLog", {}).get("days", [])[0].get("programs", [])[0].get("zones", [])
except Exception as e:
self.error(f"details_url JSON parsing failed: {e}\nRaw content:\n{r.text}")
return
zone_names = {
1: "uid1",
2: "uid2",
3: "uid3",
4: "uid4"
}
zone_friendly_names = { # CUSTOMIZE ZONE NAMES HERE
"uid1": "Fronte parco",
"uid2": "Laterale",
"uid3": "Laterale II",
"uid4": "Fronte strada"
}
flag_map = { # TRANSLATE THE FOLLOWING ITEMS IN YOUR LANGUAGE
0: "Irrigazione normale", # English: Normal watering
1: "Interrotto dall'utente", # English: Interrupted by user
2: "Soglia di restrizione", # English: Restriction Threshold
3: "Protezione antigelo attiva", # English: Restriction Freeze Protect
4: "Giorno soggetto a restrizione", # English: Restriction Day
5: "Fuori dai giorni consentiti", # English: Restriction Out Of Day
6: "Eccesso d'acqua", # English: Water surplus
7: "Interrotto dal sensore pioggia", # English: Stopped by Rain Sensor
8: "Restrizione software per pioggia", # English: Software rain sensor restriction
9: "Restrizione mensile attiva", # English: Month Restricted
10: "Ritardo impostato dall'utente", # English: Delay set by user
11: "Restrizione pioggia del programma", # English: Program Rain Restriction
12: "Saltato per frequenza adattiva" # English: Adaptive Frequency Skip
}
for zone in zones:
uid = zone.get("uid")
if uid in zone_names:
name = zone_names[uid]
cycle = zone.get("cycles", [])[0]
real_duration = cycle.get("realDuration", 0)
user_duration = cycle.get("userDuration", 0)
start_time = cycle.get("startTime")
flag = zone.get("flag", -1)
self.set_state(f"sensor.rainmachine_{name}_watering",
state=str(int(real_duration) // 60),
attributes={
"friendly_name": zone_friendly_names.get(name, f"Zona {name}"),
"unit_of_measurement": "min",
"userDuration": int(user_duration) // 60,
"userDuration_unit": "min",
"realDuration": int(real_duration) // 60,
"realDuration_unit": "min",
"userDuration_display": f"{int(user_duration // 60)} min previsti", # TEXT ATTRIBUTE WITH UNIT
"realDuration_display": f"{int(real_duration // 60)} min effettivi", # TEXT ATTRIBUTE WITH UNIT
"startTime": start_time,
"flag": flag_map.get(flag, f"Flag sconosciuto: {flag}"), # TRANSLATE IN YOUR LANGUAGE
"icon": "mdi:sprinkler" # ZONES ICON
})
# === FORECAST CONDITIONS ===
self.log("Getting forecast conditions...")
r = requests.get(add_token(forecast_url), verify=False)
r.raise_for_status()
forecast_data = self.parse_pre_json(r.text)
weather_conditions = {
0: "cloudy", 1: "sunny", 2: "partly-cloudy", 3: "partly-cloudy", 4: "partly-cloudy", 5: "rainy",
6: "sunny", 7: "snowy", 8: "snowy", 9: "sunny", 10: "snowy", 11: "partly-rainy",
12: "lightning-rainy", 13: "partly-cloudy", 14: "cloudy", 15: "sunny", 16: "sunny",
17: "lightning-rainy", 18: "partly-rainy", 19: "pouring", 20: "partly-cloudy", 21: "cloudy"
}
weather_conditions_tranlated = { # TRANSLATE THE FOLLOWING ITEMS IN YOUR LANGUAGE
"sunny": "Sereno",
"partly-cloudy": "Parzialmente nuvoloso",
"cloudy": "Nuvoloso",
"rainy": "Piovoso",
"lightning-rainy": "Temporale",
"snowy": "Nevoso",
"partly-rainy": "Parzialmente piovoso",
"pouring": "Pioggia intensa",
"unknown": "Sconosciuto"
}
unknown_condition = "Sconosciuto" # TRANSLATE IN YOUR LANGUAGE
icon_map = {
"sunny": "mdi:weather-sunny",
"partly-cloudy": "mdi:weather-partly-cloudy",
"cloudy": "mdi:weather-cloudy",
"rainy": "mdi:weather-rainy",
"snowy": "mdi:weather-snowy",
"partly-rainy": "mdi:weather-partly-rainy",
"pouring": "mdi:weather-pouring",
"unknown": "mdi:weather-cloudy-alert"
}
translation_days = { # TRANSLATE THE FOLLOWING ITEMS IN YOUR LANGUAGE
"monday": "Lunedì",
"tuesday": "Martedì",
"wednesday": "Mercoledì",
"thursday": "Giovedì",
"friday": "Venerdì",
"saturday": "Sabato",
"sunday": "Domenica"
}
today = datetime.today().date()
yesterday = today - timedelta(days=1)
selected_days = []
for daily in forecast_data["mixerData"][0]["dailyValues"]:
day_date = datetime.strptime(daily["day"], "%Y-%m-%d %H:%M:%S").date()
if yesterday <= day_date <= yesterday + timedelta(days=6):
selected_days.append((day_date, daily))
selected_days.sort(key=lambda x: x[0])
for i, (day_date, daily) in enumerate(selected_days):
delta = (day_date - today).days
condition_code = daily["condition"]
condition = weather_conditions.get(condition_code, "unknown")
state_translated = weather_conditions_tranlated.get(condition, unknown_condition)
icon = icon_map.get(condition, "mdi:weather-cloudy-alert")
weekday_name = datetime.strptime(daily["day"], "%Y-%m-%d %H:%M:%S").strftime("%A").lower()
if delta == -1:
friendly_day = "Ieri" # TRANSLATE IN YOUR LANGUAGE
elif delta == 0:
friendly_day = "Oggi" # TRANSLATE IN YOUR LANGUAGE
elif delta == 1:
friendly_day = "Domani" # TRANSLATE IN YOUR LANGUAGE
else:
weekday_name = day_date.strftime("%A").lower()
friendly_day = translation_days.get(weekday_name, weekday_name)
attributes = {
"temperature": int(daily["temperature"]),
"temperature_unit": "°C",
"temperature_display": f"{int(daily['temperature'])}°", # TEXT ATTRIBUTE WITH UNIT
"min_temperature": int(daily["minTemp"]),
"min_temperature_unit": "°C",
"min_temperature_display": f"{int(daily['minTemp'])}° min", # TEXT ATTRIBUTE WITH UNIT
"max_temperature": int(daily["maxTemp"]),
"max_temperature_unit": "°C",
"max_temperature_display": f"{int(daily['maxTemp'])}° max", # TEXT ATTRIBUTE WITH UNIT
"rain": daily["rain"],
"rain_unit": "mm",
"rain_display": f"{daily['rain']} mm di pioggia", # TEXT ATTRIBUTE WITH UNIT
"precipitation_forecast": daily["qpf"],
"precipitation_forecast_unit": "mm",
"precipitation_forecast_display": f"{daily['qpf']} mm di pioggia prevista", # TEXT ATTRIBUTE WITH UNIT
"EvapoTranspiration": daily["et0final"],
"EvapoTranspiration_unit": "mm",
"EvapoTranspiration_display": f"{daily['et0final']} mm", # TEXT ATTRIBUTE WITH UNIT
"day": daily["day"].split(" ")[0],
"meteocode": condition_code,
"friendly_name": friendly_day,
"state_translated": state_translated,
"icon": f"mdi:weather-{condition}"
}
sensor_name = f"sensor.rainmachine_forecast_condition_{i}"
self.set_state(sensor_name, state=condition, attributes=attributes)
except Exception as e:
self.error(f"RainMachine update failed: {e}")
# === RESET TODAY WATERING SENSOR ===
def _pre_midnight_reset(self, kwargs):
self._init_today_watering(None)
self.run_in(self._init_today_watering, 1)
def _midnight_anchor(self, kwargs):
self._init_today_watering(None)
def _init_today_watering(self, kwargs):
entity = "sensor.rainmachine_today_watering"
attrs = {
"friendly_name": "Irrigazione giornaliera",
"unit_of_measurement": "min",
"date": datetime.now(TZ).strftime("%Y-%m-%d"),
"userDuration": "0:00",
"icon": "mdi:sprinkler"
}
self.set_state(entity, state="0", attributes=attrs)
# === SET WATERING DELAY ===
def _login_and_get_token(self):
login_url = self.args["login_url"]
password = self.args["password"]
headers = { "Content-Type": "text/plain" }
payload = { "pwd": password, "remember": 1 }
r = requests.post(login_url, json=payload, headers=headers, verify=False)
r.raise_for_status()
token = r.json().get("access_token")
if not token:
raise RuntimeError("Token non ricevuto dal login RainMachine")
return token
def _add_token(self, url, token):
sep = '&' if '?' in url else '?'
return f"{url}{sep}access_token={token}"
def _apply_rain_delay_days(self, days: int):
post_url = self.args.get("raindelay_post_url")
if not post_url:
base = self.args["raindelay_url"]
post_url = base.split("?", 1)[0]
token = self._login_and_get_token()
url = self._add_token(post_url, token)
headers = { "Content-Type": "text/plain" }
payload = { "rainDelay": int(days) }
r = requests.post(url, json=payload, headers=headers, verify=False)
r.raise_for_status()
return r.text
def _on_apply_rain_delay(self, entity, attribute, old, new, kwargs):
try:
value = self.get_state(self.delay_number_entity)
days = int(float(value))
self.log(f"Applicazione Rain Delay: {days} giorni...")
resp = self._apply_rain_delay_days(days)
self.log(f"Rain Delay applicato ({days}g). Risposta: {resp}")
self.update_all(None)
except Exception as e:
self.error(f"Errore applicando Rain Delay: {e}")
self.call_service("persistent_notification/create",
title="RainMachine - Errore",
message=f"Impossibile applicare Rain Delay: {e}")
- Add the following code to the file /addon_configs/XXXXXX_appdaemon/apps/apps.yaml
rainmachine_status:
module: rainmachine_status
class: RainMachineStatus
password: !secret rainmachine_password
login_url: !secret rainmachine_login_url
parser_url: !secret rainmachine_parser_url
watering_url: !secret rainmachine_watering_url
details_url: !secret rainmachine_details_url
forecast_url: !secret rainmachine_forecast_url
raindelay_url: !secret rainmachine_delay_url
- Add the following code to the file /homeassistant/secrets.yaml
rainmachine_login_url: "https://your_local_rainmachine_ip:8080/api/4/auth/login"
rainmachine_parser_url: "https://your_local_rainmachine_ip:8080/api/4/parser?format"
rainmachine_watering_url: "https://your_local_rainmachine_ip:8080/api/4/watering/log?format"
rainmachine_details_url: "https://your_local_rainmachine_ip:8080/api/4/watering/log/details?format"
rainmachine_forecast_url: "https://your_local_rainmachine_ip:8080/api/4/mixer?format=json"
rainmachine_delay_url: "https://your_local_rainmachine_ip:8080/api/4/restrictions/raindelay?format"
rainmachine_password: your_rainmachine_password
- Add the following code to the file /addon_configs/XXXXXX_appdaemon/appdaemon.yaml
secrets: /homeassistant/secrets.yaml
- Restart the AppDeamon addon
The scripts will generate the sensors and then update them every 1 min.
-
Sensors
A. Parsers sensors
sensor.rainmachine_metno_last_run
sensor.rainmachine_openweathermap_last_run
sensor.rainmachine_wunderground_last_run
sensor.rainmachine_accuweather_last_runThe sensor’s state represents the last update
B. Total watering sensor
sensor.rainmachine_today_wateringThe sensor’s state represents the today real total watering time in minutes
Additional attributes:
date: the date of the watering
userDuration: the watering duration defined by the userC. Zones sensors
sensor.rainmachine_uid1_watering
sensor.rainmachine_uid2_watering
sensor.rainmachine_uid3_watering
sensor.rainmachine_uid4_wateringThe sensor’s state represents the today real total watering time in minutes
Additional attributes:
date: the date of the watering
userDuration: the watering duration defined by the user
flag: The reason why an irrigation was reduced or skipped or whateverD. Forecast sensors
sensor.rainmachine_forecast_condition_0 (yesterdaty)
sensor.rainmachine_forecast_condition_1 (today)
sensor.rainmachine_forecast_condition_2 (tomorrow)
sensor.rainmachine_forecast_condition_3 (day of the week name)
sensor.rainmachine_forecast_condition_4 (day of the week name)
sensor.rainmachine_forecast_condition_5 (day of the week name)
sensor.rainmachine_forecast_condition_6 (day of the week name)The sensor’s state represents the meteo state (eg. sunny)
The icon of the sensor is updated based on the meteo state.
Additional attributes:
temperature
min_temperature
max_temperature
precipitation
EvapoTranspiration
day
meteocode (I still need to check some of them)E. Watering Snooze sensors
sensor.rainmachine_rain_delay
Preview:
- Notification
The following automation sends a notification at the end of irrigation (10 minutes after completion to allow RainMachine to finish the log) indicating the scheduled irrigation time and the actual time. If the actual time is shorter, the reasons for the reduction will also be reported for each zone.
description: ""
mode: single
triggers:
- trigger: state
entity_id:
- switch.rainmachine_program #CHANGE IT ACCORDING ON WHAT YOUR IRRIGATION PROGRAM IS CALLED (SEE RainMachine Addon)
id: rainmachine
from: "on"
to: "off"
conditions: []
actions:
- delay:
hours: 0
minutes: 10
seconds: 0
milliseconds: 0
- alias: Scheduled-Actual Irrigation
data:
title: 💦 Garden Irrigation
message: >-
{% set user_dur =
state_attr('sensor.rainmachine_today_watering','userDuration') %} {% set
eff = states('sensor.rainmachine_today_watering') | float(0) %} {% if
user_dur %}
{% set mm = (user_dur.split(':')[0] | int) %}
{% set ss = (user_dur.split(':')[1] | int) %}
{% set total = (mm*60 + ss) / 60 %}
{% set msg = namespace(t="") %}
{% set msg.t = msg.t ~ "Scheduled Irrigation: " ~ (total | round(0)) ~ " min\n" %}
{% set msg.t = msg.t ~ "Scheduled Irrigation: " ~ (eff | round(0)) ~ " min" %}
{% if eff > 0 %}
{% set zones = {
'Zone 1': (state_attr('sensor.rainmachine_uid1_watering','flag')|string).strip(),
'Zone 2': (state_attr('sensor.rainmachine_uid2_watering','flag')|string).strip(),
'Zone 3': (state_attr('sensor.rainmachine_uid3_watering','flag')|string).strip(),
'Zone 4': (state_attr('sensor.rainmachine_uid4_watering','flag')|string).strip()
} %}
{% for name, flag in zones.items() if flag and flag != 'Normal watering' %}
{% set msg.t = msg.t ~ "\n" ~ name ~ ": " ~ flag %}
{% endfor %}
{% endif %}
{{ msg.t }}
{% else %}
No data available for today
{% endif %}
action: notify.notify
EDIT 2025.07.24
Added 7days forecast sensors
Added a new parser sensor: sensor.rainmachine_accuweather_last_run
Added few more attributes and more comments for translation in your language
EDIT 2025.07.25
Added “state_class”: “measurement” for compatible sensors
EDIT 2025.07.26
Small code fixing
EDIT 2025.08.18
Today watering code fixing
EDIT 2025.08.19
Added notification automation
Added watering snooze sensor
EDIT 2025.08.26
Today watering code fixing
EDIT 2025.09.01
Today watering code fixing
EDIT 2025.10.25
Removed forbidden attributes device_class and state_class from all set_state() calls to comply with new Home Assistant API rules.
Ensured all states are strings when creating or updating entities.
