RainMachine - Additional sensors

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.

 
 

  1. Install the official AppDaemon add-on and start it.
  2. 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}")

 
 

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

 

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


 

  1. Add the following code to the file /addon_configs/XXXXXX_appdaemon/appdaemon.yaml
secrets: /homeassistant/secrets.yaml

 

  1. Restart the AppDeamon addon
    The scripts will generate the sensors and then update them every 1 min.

 
 

  1. Sensors

    A. Parsers sensors
    sensor.rainmachine_metno_last_run
    sensor.rainmachine_openweathermap_last_run
    sensor.rainmachine_wunderground_last_run
    sensor.rainmachine_accuweather_last_run

    The sensor’s state represents the last update

    B. Total watering sensor
    sensor.rainmachine_today_watering

    The 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

    C. Zones sensors
    sensor.rainmachine_uid1_watering
    sensor.rainmachine_uid2_watering
    sensor.rainmachine_uid3_watering
    sensor.rainmachine_uid4_watering

    The 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 whatever

    D. 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:

 

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

1 Like