460 lines
16 KiB
Python
460 lines
16 KiB
Python
"""Support for RainMachine devices."""
|
|
import asyncio
|
|
from datetime import timedelta
|
|
import logging
|
|
|
|
from regenmaschine import Client
|
|
from regenmaschine.errors import RainMachineError
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.config_entries import SOURCE_IMPORT
|
|
from homeassistant.const import (
|
|
ATTR_ATTRIBUTION,
|
|
CONF_IP_ADDRESS,
|
|
CONF_PASSWORD,
|
|
CONF_PORT,
|
|
CONF_SCAN_INTERVAL,
|
|
CONF_SSL,
|
|
)
|
|
from homeassistant.core import callback
|
|
from homeassistant.exceptions import ConfigEntryNotReady
|
|
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
|
from homeassistant.helpers.entity import Entity
|
|
from homeassistant.helpers.event import async_track_time_interval
|
|
from homeassistant.helpers.service import verify_domain_control
|
|
|
|
from .const import (
|
|
CONF_ZONE_RUN_TIME,
|
|
DATA_CLIENT,
|
|
DATA_PROGRAMS,
|
|
DATA_PROVISION_SETTINGS,
|
|
DATA_RESTRICTIONS_CURRENT,
|
|
DATA_RESTRICTIONS_UNIVERSAL,
|
|
DATA_ZONES,
|
|
DATA_ZONES_DETAILS,
|
|
DEFAULT_PORT,
|
|
DEFAULT_SCAN_INTERVAL,
|
|
DEFAULT_ZONE_RUN,
|
|
DOMAIN,
|
|
PROGRAM_UPDATE_TOPIC,
|
|
SENSOR_UPDATE_TOPIC,
|
|
ZONE_UPDATE_TOPIC,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
CONF_CONTROLLERS = "controllers"
|
|
CONF_PROGRAM_ID = "program_id"
|
|
CONF_SECONDS = "seconds"
|
|
CONF_ZONE_ID = "zone_id"
|
|
|
|
DEFAULT_ATTRIBUTION = "Data provided by Green Electronics LLC"
|
|
DEFAULT_ICON = "mdi:water"
|
|
DEFAULT_SSL = True
|
|
|
|
SERVICE_ALTER_PROGRAM = vol.Schema({vol.Required(CONF_PROGRAM_ID): cv.positive_int})
|
|
|
|
SERVICE_ALTER_ZONE = vol.Schema({vol.Required(CONF_ZONE_ID): cv.positive_int})
|
|
|
|
SERVICE_PAUSE_WATERING = vol.Schema({vol.Required(CONF_SECONDS): cv.positive_int})
|
|
|
|
SERVICE_START_PROGRAM_SCHEMA = vol.Schema(
|
|
{vol.Required(CONF_PROGRAM_ID): cv.positive_int}
|
|
)
|
|
|
|
SERVICE_START_ZONE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(CONF_ZONE_ID): cv.positive_int,
|
|
vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): cv.positive_int,
|
|
}
|
|
)
|
|
|
|
SERVICE_STOP_PROGRAM_SCHEMA = vol.Schema(
|
|
{vol.Required(CONF_PROGRAM_ID): cv.positive_int}
|
|
)
|
|
|
|
SERVICE_STOP_ZONE_SCHEMA = vol.Schema({vol.Required(CONF_ZONE_ID): cv.positive_int})
|
|
|
|
CONTROLLER_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(CONF_IP_ADDRESS): cv.string,
|
|
vol.Required(CONF_PASSWORD): cv.string,
|
|
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
|
vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
|
|
vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All(
|
|
cv.time_period, lambda value: value.total_seconds()
|
|
),
|
|
vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): cv.positive_int,
|
|
}
|
|
)
|
|
|
|
CONFIG_SCHEMA = vol.Schema(
|
|
{
|
|
DOMAIN: vol.Schema(
|
|
{
|
|
vol.Required(CONF_CONTROLLERS): vol.All(
|
|
cv.ensure_list, [CONTROLLER_SCHEMA]
|
|
)
|
|
}
|
|
)
|
|
},
|
|
extra=vol.ALLOW_EXTRA,
|
|
)
|
|
|
|
|
|
async def async_setup(hass, config):
|
|
"""Set up the RainMachine component."""
|
|
hass.data[DOMAIN] = {}
|
|
hass.data[DOMAIN][DATA_CLIENT] = {}
|
|
|
|
if DOMAIN not in config:
|
|
return True
|
|
|
|
conf = config[DOMAIN]
|
|
|
|
for controller in conf[CONF_CONTROLLERS]:
|
|
hass.async_create_task(
|
|
hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_IMPORT}, data=controller
|
|
)
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
async def async_setup_entry(hass, config_entry):
|
|
"""Set up RainMachine as config entry."""
|
|
if not config_entry.unique_id:
|
|
hass.config_entries.async_update_entry(
|
|
config_entry, unique_id=config_entry.data[CONF_IP_ADDRESS]
|
|
)
|
|
|
|
_verify_domain_control = verify_domain_control(hass, DOMAIN)
|
|
|
|
websession = aiohttp_client.async_get_clientsession(hass)
|
|
client = Client(websession)
|
|
|
|
try:
|
|
await client.load_local(
|
|
config_entry.data[CONF_IP_ADDRESS],
|
|
config_entry.data[CONF_PASSWORD],
|
|
port=config_entry.data[CONF_PORT],
|
|
ssl=config_entry.data.get(CONF_SSL, DEFAULT_SSL),
|
|
)
|
|
except RainMachineError as err:
|
|
_LOGGER.error("An error occurred: %s", err)
|
|
raise ConfigEntryNotReady
|
|
else:
|
|
# regenmaschine can load multiple controllers at once, but we only grab the one
|
|
# we loaded above:
|
|
controller = next(iter(client.controllers.values()))
|
|
|
|
rainmachine = RainMachine(
|
|
hass,
|
|
controller,
|
|
config_entry.data.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN),
|
|
config_entry.data.get(
|
|
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL.total_seconds()
|
|
),
|
|
)
|
|
|
|
# Update the data object, which at this point (prior to any sensors registering
|
|
# "interest" in the API), will focus on grabbing the latest program and zone data:
|
|
await rainmachine.async_update()
|
|
hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = rainmachine
|
|
|
|
for component in ("binary_sensor", "sensor", "switch"):
|
|
hass.async_create_task(
|
|
hass.config_entries.async_forward_entry_setup(config_entry, component)
|
|
)
|
|
|
|
@_verify_domain_control
|
|
async def disable_program(call):
|
|
"""Disable a program."""
|
|
await rainmachine.controller.programs.disable(call.data[CONF_PROGRAM_ID])
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
@_verify_domain_control
|
|
async def disable_zone(call):
|
|
"""Disable a zone."""
|
|
await rainmachine.controller.zones.disable(call.data[CONF_ZONE_ID])
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
@_verify_domain_control
|
|
async def enable_program(call):
|
|
"""Enable a program."""
|
|
await rainmachine.controller.programs.enable(call.data[CONF_PROGRAM_ID])
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
@_verify_domain_control
|
|
async def enable_zone(call):
|
|
"""Enable a zone."""
|
|
await rainmachine.controller.zones.enable(call.data[CONF_ZONE_ID])
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
@_verify_domain_control
|
|
async def pause_watering(call):
|
|
"""Pause watering for a set number of seconds."""
|
|
await rainmachine.controller.watering.pause_all(call.data[CONF_SECONDS])
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
@_verify_domain_control
|
|
async def start_program(call):
|
|
"""Start a particular program."""
|
|
await rainmachine.controller.programs.start(call.data[CONF_PROGRAM_ID])
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
@_verify_domain_control
|
|
async def start_zone(call):
|
|
"""Start a particular zone for a certain amount of time."""
|
|
await rainmachine.controller.zones.start(
|
|
call.data[CONF_ZONE_ID], call.data[CONF_ZONE_RUN_TIME]
|
|
)
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
@_verify_domain_control
|
|
async def stop_all(call):
|
|
"""Stop all watering."""
|
|
await rainmachine.controller.watering.stop_all()
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
@_verify_domain_control
|
|
async def stop_program(call):
|
|
"""Stop a program."""
|
|
await rainmachine.controller.programs.stop(call.data[CONF_PROGRAM_ID])
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
@_verify_domain_control
|
|
async def stop_zone(call):
|
|
"""Stop a zone."""
|
|
await rainmachine.controller.zones.stop(call.data[CONF_ZONE_ID])
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
@_verify_domain_control
|
|
async def unpause_watering(call):
|
|
"""Unpause watering."""
|
|
await rainmachine.controller.watering.unpause_all()
|
|
await rainmachine.async_update_programs_and_zones()
|
|
|
|
for service, method, schema in [
|
|
("disable_program", disable_program, SERVICE_ALTER_PROGRAM),
|
|
("disable_zone", disable_zone, SERVICE_ALTER_ZONE),
|
|
("enable_program", enable_program, SERVICE_ALTER_PROGRAM),
|
|
("enable_zone", enable_zone, SERVICE_ALTER_ZONE),
|
|
("pause_watering", pause_watering, SERVICE_PAUSE_WATERING),
|
|
("start_program", start_program, SERVICE_START_PROGRAM_SCHEMA),
|
|
("start_zone", start_zone, SERVICE_START_ZONE_SCHEMA),
|
|
("stop_all", stop_all, {}),
|
|
("stop_program", stop_program, SERVICE_STOP_PROGRAM_SCHEMA),
|
|
("stop_zone", stop_zone, SERVICE_STOP_ZONE_SCHEMA),
|
|
("unpause_watering", unpause_watering, {}),
|
|
]:
|
|
hass.services.async_register(DOMAIN, service, method, schema=schema)
|
|
|
|
return True
|
|
|
|
|
|
async def async_unload_entry(hass, config_entry):
|
|
"""Unload an OpenUV config entry."""
|
|
hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
|
|
|
|
tasks = [
|
|
hass.config_entries.async_forward_entry_unload(config_entry, component)
|
|
for component in ("binary_sensor", "sensor", "switch")
|
|
]
|
|
|
|
await asyncio.gather(*tasks)
|
|
|
|
return True
|
|
|
|
|
|
class RainMachine:
|
|
"""Define a generic RainMachine object."""
|
|
|
|
def __init__(self, hass, controller, default_zone_runtime, scan_interval):
|
|
"""Initialize."""
|
|
self._async_cancel_time_interval_listener = None
|
|
self._scan_interval_seconds = scan_interval
|
|
self.controller = controller
|
|
self.data = {}
|
|
self.default_zone_runtime = default_zone_runtime
|
|
self.device_mac = controller.mac
|
|
self.hass = hass
|
|
|
|
self._api_category_count = {
|
|
DATA_PROVISION_SETTINGS: 0,
|
|
DATA_RESTRICTIONS_CURRENT: 0,
|
|
DATA_RESTRICTIONS_UNIVERSAL: 0,
|
|
}
|
|
self._api_category_locks = {
|
|
DATA_PROVISION_SETTINGS: asyncio.Lock(),
|
|
DATA_RESTRICTIONS_CURRENT: asyncio.Lock(),
|
|
DATA_RESTRICTIONS_UNIVERSAL: asyncio.Lock(),
|
|
}
|
|
|
|
async def _async_update_listener_action(self, now):
|
|
"""Define an async_track_time_interval action to update data."""
|
|
await self.async_update()
|
|
|
|
@callback
|
|
def async_deregister_sensor_api_interest(self, api_category):
|
|
"""Decrement the number of entities with data needs from an API category."""
|
|
# If this deregistration should leave us with no registration at all, remove the
|
|
# time interval:
|
|
if sum(self._api_category_count.values()) == 0:
|
|
if self._async_cancel_time_interval_listener:
|
|
self._async_cancel_time_interval_listener()
|
|
self._async_cancel_time_interval_listener = None
|
|
return
|
|
|
|
self._api_category_count[api_category] -= 1
|
|
|
|
async def async_fetch_from_api(self, api_category):
|
|
"""Execute the appropriate coroutine to fetch particular data from the API."""
|
|
if api_category == DATA_PROGRAMS:
|
|
data = await self.controller.programs.all(include_inactive=True)
|
|
elif api_category == DATA_PROVISION_SETTINGS:
|
|
data = await self.controller.provisioning.settings()
|
|
elif api_category == DATA_RESTRICTIONS_CURRENT:
|
|
data = await self.controller.restrictions.current()
|
|
elif api_category == DATA_RESTRICTIONS_UNIVERSAL:
|
|
data = await self.controller.restrictions.universal()
|
|
elif api_category == DATA_ZONES:
|
|
data = await self.controller.zones.all(include_inactive=True)
|
|
elif api_category == DATA_ZONES_DETAILS:
|
|
# This API call needs to be separate from the DATA_ZONES one above because,
|
|
# maddeningly, the DATA_ZONES_DETAILS API call doesn't include the current
|
|
# state of the zone:
|
|
data = await self.controller.zones.all(details=True, include_inactive=True)
|
|
|
|
self.data[api_category] = data
|
|
|
|
async def async_register_sensor_api_interest(self, api_category):
|
|
"""Increment the number of entities with data needs from an API category."""
|
|
# If this is the first registration we have, start a time interval:
|
|
if not self._async_cancel_time_interval_listener:
|
|
self._async_cancel_time_interval_listener = async_track_time_interval(
|
|
self.hass,
|
|
self._async_update_listener_action,
|
|
timedelta(seconds=self._scan_interval_seconds),
|
|
)
|
|
|
|
self._api_category_count[api_category] += 1
|
|
|
|
# If a sensor registers interest in a particular API call and the data doesn't
|
|
# exist for it yet, make the API call and grab the data:
|
|
async with self._api_category_locks[api_category]:
|
|
if api_category not in self.data:
|
|
await self.async_fetch_from_api(api_category)
|
|
|
|
async def async_update(self):
|
|
"""Update all RainMachine data."""
|
|
tasks = [self.async_update_programs_and_zones(), self.async_update_sensors()]
|
|
await asyncio.gather(*tasks)
|
|
|
|
async def async_update_sensors(self):
|
|
"""Update sensor/binary sensor data."""
|
|
_LOGGER.debug("Updating sensor data for RainMachine")
|
|
|
|
# Fetch an API category if there is at least one interested entity:
|
|
tasks = {}
|
|
for category, count in self._api_category_count.items():
|
|
if count == 0:
|
|
continue
|
|
tasks[category] = self.async_fetch_from_api(category)
|
|
|
|
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
|
for api_category, result in zip(tasks, results):
|
|
if isinstance(result, RainMachineError):
|
|
_LOGGER.error(
|
|
"There was an error while updating %s: %s", api_category, result
|
|
)
|
|
continue
|
|
|
|
async_dispatcher_send(self.hass, SENSOR_UPDATE_TOPIC)
|
|
|
|
async def async_update_programs_and_zones(self):
|
|
"""Update program and zone data.
|
|
|
|
Program and zone updates always go together because of how linked they are:
|
|
programs affect zones and certain combinations of zones affect programs.
|
|
|
|
Note that this call does not take into account interested entities when making
|
|
the API calls; we make the reasonable assumption that switches will always be
|
|
enabled.
|
|
"""
|
|
_LOGGER.debug("Updating program and zone data for RainMachine")
|
|
|
|
tasks = {
|
|
DATA_PROGRAMS: self.async_fetch_from_api(DATA_PROGRAMS),
|
|
DATA_ZONES: self.async_fetch_from_api(DATA_ZONES),
|
|
DATA_ZONES_DETAILS: self.async_fetch_from_api(DATA_ZONES_DETAILS),
|
|
}
|
|
|
|
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
|
for api_category, result in zip(tasks, results):
|
|
if isinstance(result, RainMachineError):
|
|
_LOGGER.error(
|
|
"There was an error while updating %s: %s", api_category, result
|
|
)
|
|
|
|
async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC)
|
|
async_dispatcher_send(self.hass, ZONE_UPDATE_TOPIC)
|
|
|
|
|
|
class RainMachineEntity(Entity):
|
|
"""Define a generic RainMachine entity."""
|
|
|
|
def __init__(self, rainmachine):
|
|
"""Initialize."""
|
|
self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
|
|
self._device_class = None
|
|
self._name = None
|
|
self.rainmachine = rainmachine
|
|
|
|
@property
|
|
def device_class(self):
|
|
"""Return the device class."""
|
|
return self._device_class
|
|
|
|
@property
|
|
def device_info(self):
|
|
"""Return device registry information for this entity."""
|
|
return {
|
|
"identifiers": {(DOMAIN, self.rainmachine.controller.mac)},
|
|
"name": self.rainmachine.controller.name,
|
|
"manufacturer": "RainMachine",
|
|
"model": (
|
|
f"Version {self.rainmachine.controller.hardware_version} "
|
|
f"(API: {self.rainmachine.controller.api_version})"
|
|
),
|
|
"sw_version": self.rainmachine.controller.software_version,
|
|
}
|
|
|
|
@property
|
|
def device_state_attributes(self) -> dict:
|
|
"""Return the state attributes."""
|
|
return self._attrs
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Return the name of the entity."""
|
|
return self._name
|
|
|
|
@property
|
|
def should_poll(self):
|
|
"""Disable polling."""
|
|
return False
|
|
|
|
@callback
|
|
def _update_state(self):
|
|
"""Update the state."""
|
|
self.update_from_latest_data()
|
|
self.async_write_ha_state()
|
|
|
|
@callback
|
|
def update_from_latest_data(self):
|
|
"""Update the entity."""
|
|
raise NotImplementedError
|