* Remove unnecessary exception re-wraps * Preserve exception chains on re-raise We slap "from cause" to almost all possible cases here. In some cases it could conceivably be better to do "from None" if we really want to hide the cause. However those should be in the minority, and "from cause" should be an improvement over the corresponding raise without a "from" in all cases anyway. The only case where we raise from None here is in plex, where the exception for an original invalid SSL cert is not the root cause for failure to validate a newly fetched one. Follow local convention on exception variable names if there is a consistent one, otherwise `err` to match with majority of codebase. * Fix mistaken re-wrap in homematicip_cloud/hap.py Missed the difference between HmipConnectionError and HmipcConnectionError. * Do not hide original error on plex new cert validation error Original is not the cause for the new one, but showing old in the traceback is useful nevertheless.
459 lines
16 KiB
Python
459 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(session=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 from err
|
|
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
|