From 4c4f7263238ee4f1273367d425168d8fa4ecba1c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 25 Jan 2020 20:27:35 -0700 Subject: [PATCH] Refactor RainMachine switch platform (#31148) * Import constants sanely * Linting * Rename data constants for consistency * Refactor RainMachine switch platform * Comments * Cleanup * Refactor switch and sensor API calls to be separate * Linting * Make sure zones are updated in appropriate service calls * Correctly decrement * Linting * Don't do weird inheritance * Ensure service calls update data properly * Docstring * Docstring * Errors can be logged without string conversion * Code review comments --- .../components/rainmachine/__init__.py | 158 +++++++---- .../components/rainmachine/binary_sensor.py | 74 +++-- homeassistant/components/rainmachine/const.py | 14 +- .../components/rainmachine/sensor.py | 52 ++-- .../components/rainmachine/switch.py | 257 +++++++++--------- 5 files changed, 295 insertions(+), 260 deletions(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 1e0421385ab..20b74f4f66e 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -27,23 +27,25 @@ from homeassistant.helpers.service import verify_domain_control from .config_flow import configured_instances from .const import ( DATA_CLIENT, + DATA_PROGRAMS, + DATA_PROVISION_SETTINGS, + DATA_RESTRICTIONS_CURRENT, + DATA_RESTRICTIONS_UNIVERSAL, + DATA_ZONES, + DATA_ZONES_DETAILS, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN, - PROVISION_SETTINGS, - RESTRICTIONS_CURRENT, - RESTRICTIONS_UNIVERSAL, + PROGRAM_UPDATE_TOPIC, + SENSOR_UPDATE_TOPIC, + ZONE_UPDATE_TOPIC, ) _LOGGER = logging.getLogger(__name__) DATA_LISTENER = "listener" -PROGRAM_UPDATE_TOPIC = f"{DOMAIN}_program_update" -SENSOR_UPDATE_TOPIC = f"{DOMAIN}_data_update" -ZONE_UPDATE_TOPIC = f"{DOMAIN}_zone_update" - CONF_CONTROLLERS = "controllers" CONF_PROGRAM_ID = "program_id" CONF_SECONDS = "seconds" @@ -150,6 +152,9 @@ async def async_setup_entry(hass, config_entry): _LOGGER.error("An error occurred: %s", err) raise ConfigEntryNotReady + # 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"): @@ -161,37 +166,37 @@ async def async_setup_entry(hass, config_entry): async def disable_program(call): """Disable a program.""" await rainmachine.client.programs.disable(call.data[CONF_PROGRAM_ID]) - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def disable_zone(call): """Disable a zone.""" await rainmachine.client.zones.disable(call.data[CONF_ZONE_ID]) - async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def enable_program(call): """Enable a program.""" await rainmachine.client.programs.enable(call.data[CONF_PROGRAM_ID]) - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def enable_zone(call): """Enable a zone.""" await rainmachine.client.zones.enable(call.data[CONF_ZONE_ID]) - async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + 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.client.watering.pause_all(call.data[CONF_SECONDS]) - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def start_program(call): """Start a particular program.""" await rainmachine.client.programs.start(call.data[CONF_PROGRAM_ID]) - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def start_zone(call): @@ -199,31 +204,31 @@ async def async_setup_entry(hass, config_entry): await rainmachine.client.zones.start( call.data[CONF_ZONE_ID], call.data[CONF_ZONE_RUN_TIME] ) - async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def stop_all(call): """Stop all watering.""" await rainmachine.client.watering.stop_all() - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def stop_program(call): """Stop a program.""" await rainmachine.client.programs.stop(call.data[CONF_PROGRAM_ID]) - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def stop_zone(call): """Stop a zone.""" await rainmachine.client.zones.stop(call.data[CONF_ZONE_ID]) - async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def unpause_watering(call): """Unpause watering.""" await rainmachine.client.watering.unpause_all() - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() for service, method, schema in [ ("disable_program", disable_program, SERVICE_ALTER_PROGRAM), @@ -265,7 +270,7 @@ class RainMachine: def __init__(self, hass, client, default_zone_runtime, scan_interval): """Initialize.""" - self._async_unsub_dispatcher_connect = None + self._async_cancel_time_interval_listener = None self._scan_interval_seconds = scan_interval self.client = client self.data = {} @@ -274,48 +279,58 @@ class RainMachine: self.hass = hass self._api_category_count = { - PROVISION_SETTINGS: 0, - RESTRICTIONS_CURRENT: 0, - RESTRICTIONS_UNIVERSAL: 0, + DATA_PROVISION_SETTINGS: 0, + DATA_RESTRICTIONS_CURRENT: 0, + DATA_RESTRICTIONS_UNIVERSAL: 0, } self._api_category_locks = { - PROVISION_SETTINGS: asyncio.Lock(), - RESTRICTIONS_CURRENT: asyncio.Lock(), - RESTRICTIONS_UNIVERSAL: asyncio.Lock(), + DATA_PROVISION_SETTINGS: asyncio.Lock(), + DATA_RESTRICTIONS_CURRENT: asyncio.Lock(), + DATA_RESTRICTIONS_UNIVERSAL: asyncio.Lock(), } - async def _async_fetch_from_api(self, api_category): - """Execute the appropriate coroutine to fetch particular data from the API.""" - if api_category == PROVISION_SETTINGS: - data = await self.client.provisioning.settings() - elif api_category == RESTRICTIONS_CURRENT: - data = await self.client.restrictions.current() - elif api_category == RESTRICTIONS_UNIVERSAL: - data = await self.client.restrictions.universal() - - return data - 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_api_interest(self, api_category): + 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_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() - self._async_unsub_dispatcher_connect = None + 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_register_api_interest(self, api_category): + 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.client.programs.all(include_inactive=True) + elif api_category == DATA_PROVISION_SETTINGS: + data = await self.client.provisioning.settings() + elif api_category == DATA_RESTRICTIONS_CURRENT: + data = await self.client.restrictions.current() + elif api_category == DATA_RESTRICTIONS_UNIVERSAL: + data = await self.client.restrictions.universal() + elif api_category == DATA_ZONES: + data = await self.client.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.client.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_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect = async_track_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), @@ -323,19 +338,27 @@ class RainMachine: self._api_category_count[api_category] += 1 - # Lock API updates in case multiple entities are trying to call the same API - # endpoint at once: + # 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: - self.data[api_category] = await self._async_fetch_from_api(api_category) + 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) + 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): @@ -344,10 +367,37 @@ class RainMachine: "There was an error while updating %s: %s", api_category, result ) continue - self.data[api_category] = result 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.""" @@ -389,6 +439,16 @@ class RainMachineEntity(Entity): """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.async_schedule_update_ha_state(True) + async def async_will_remove_from_hass(self): """Disconnect dispatcher listener when removed.""" for handler in self._dispatcher_handlers: diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index ace977ca356..34b8de80b88 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -2,17 +2,16 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ( +from . import RainMachineEntity +from .const import ( DATA_CLIENT, + DATA_PROVISION_SETTINGS, + DATA_RESTRICTIONS_CURRENT, + DATA_RESTRICTIONS_UNIVERSAL, DOMAIN as RAINMACHINE_DOMAIN, - PROVISION_SETTINGS, - RESTRICTIONS_CURRENT, - RESTRICTIONS_UNIVERSAL, SENSOR_UPDATE_TOPIC, - RainMachineEntity, ) _LOGGER = logging.getLogger(__name__) @@ -28,35 +27,45 @@ TYPE_RAINSENSOR = "rainsensor" TYPE_WEEKDAY = "weekday" BINARY_SENSORS = { - TYPE_FLOW_SENSOR: ("Flow Sensor", "mdi:water-pump", True, PROVISION_SETTINGS), - TYPE_FREEZE: ("Freeze Restrictions", "mdi:cancel", True, RESTRICTIONS_CURRENT), + TYPE_FLOW_SENSOR: ("Flow Sensor", "mdi:water-pump", True, DATA_PROVISION_SETTINGS), + TYPE_FREEZE: ("Freeze Restrictions", "mdi:cancel", True, DATA_RESTRICTIONS_CURRENT), TYPE_FREEZE_PROTECTION: ( "Freeze Protection", "mdi:weather-snowy", True, - RESTRICTIONS_UNIVERSAL, + DATA_RESTRICTIONS_UNIVERSAL, ), TYPE_HOT_DAYS: ( "Extra Water on Hot Days", "mdi:thermometer-lines", True, - RESTRICTIONS_UNIVERSAL, + DATA_RESTRICTIONS_UNIVERSAL, ), - TYPE_HOURLY: ("Hourly Restrictions", "mdi:cancel", False, RESTRICTIONS_CURRENT), - TYPE_MONTH: ("Month Restrictions", "mdi:cancel", False, RESTRICTIONS_CURRENT), + TYPE_HOURLY: ( + "Hourly Restrictions", + "mdi:cancel", + False, + DATA_RESTRICTIONS_CURRENT, + ), + TYPE_MONTH: ("Month Restrictions", "mdi:cancel", False, DATA_RESTRICTIONS_CURRENT), TYPE_RAINDELAY: ( "Rain Delay Restrictions", "mdi:cancel", False, - RESTRICTIONS_CURRENT, + DATA_RESTRICTIONS_CURRENT, ), TYPE_RAINSENSOR: ( "Rain Sensor Restrictions", "mdi:cancel", False, - RESTRICTIONS_CURRENT, + DATA_RESTRICTIONS_CURRENT, + ), + TYPE_WEEKDAY: ( + "Weekday Restrictions", + "mdi:cancel", + False, + DATA_RESTRICTIONS_CURRENT, ), - TYPE_WEEKDAY: ("Weekday Restrictions", "mdi:cancel", False, RESTRICTIONS_CURRENT), } @@ -107,11 +116,6 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): """Return the status of the sensor.""" return self._state - @property - def should_poll(self): - """Disable polling.""" - return False - @property def unique_id(self) -> str: """Return a unique, Home Assistant friendly identifier for this entity.""" @@ -121,46 +125,40 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): async def async_added_to_hass(self): """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - self._dispatcher_handlers.append( - async_dispatcher_connect(self.hass, SENSOR_UPDATE_TOPIC, update) + async_dispatcher_connect(self.hass, SENSOR_UPDATE_TOPIC, self._update_state) ) - await self.rainmachine.async_register_api_interest(self._api_category) + await self.rainmachine.async_register_sensor_api_interest(self._api_category) await self.async_update() async def async_update(self): """Update the state.""" if self._sensor_type == TYPE_FLOW_SENSOR: - self._state = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( + self._state = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get( "useFlowSensor" ) elif self._sensor_type == TYPE_FREEZE: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["freeze"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["freeze"] elif self._sensor_type == TYPE_FREEZE_PROTECTION: - self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ + self._state = self.rainmachine.data[DATA_RESTRICTIONS_UNIVERSAL][ "freezeProtectEnabled" ] elif self._sensor_type == TYPE_HOT_DAYS: - self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ + self._state = self.rainmachine.data[DATA_RESTRICTIONS_UNIVERSAL][ "hotDaysExtraWatering" ] elif self._sensor_type == TYPE_HOURLY: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["hourly"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["hourly"] elif self._sensor_type == TYPE_MONTH: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["month"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["month"] elif self._sensor_type == TYPE_RAINDELAY: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["rainDelay"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["rainDelay"] elif self._sensor_type == TYPE_RAINSENSOR: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["rainSensor"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["rainSensor"] elif self._sensor_type == TYPE_WEEKDAY: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["weekDay"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["weekDay"] async def async_will_remove_from_hass(self): """Disconnect dispatcher listeners and deregister API interest.""" super().async_will_remove_from_hass() - self.rainmachine.async_deregister_api_interest(self._api_category) + self.rainmachine.async_deregister_sensor_api_interest(self._api_category) diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py index c3612645a8f..b912f8d95ef 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -7,13 +7,17 @@ LOGGER = logging.getLogger(__package__) DOMAIN = "rainmachine" DATA_CLIENT = "client" +DATA_PROGRAMS = "programs" +DATA_PROVISION_SETTINGS = "provision.settings" +DATA_RESTRICTIONS_CURRENT = "restrictions.current" +DATA_RESTRICTIONS_UNIVERSAL = "restrictions.universal" +DATA_ZONES = "zones" +DATA_ZONES_DETAILS = "zones_details" DEFAULT_PORT = 8080 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) DEFAULT_SSL = True -PROVISION_SETTINGS = "provision.settings" -RESTRICTIONS_CURRENT = "restrictions.current" -RESTRICTIONS_UNIVERSAL = "restrictions.universal" - -TOPIC_UPDATE = "update_{0}" +PROGRAM_UPDATE_TOPIC = f"{DOMAIN}_program_update" +SENSOR_UPDATE_TOPIC = f"{DOMAIN}_data_update" +ZONE_UPDATE_TOPIC = f"{DOMAIN}_zone_update" diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 957ad7bda21..8487628a32b 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -1,16 +1,15 @@ """This platform provides support for sensor data from RainMachine.""" import logging -from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ( +from . import RainMachineEntity +from .const import ( DATA_CLIENT, + DATA_PROVISION_SETTINGS, + DATA_RESTRICTIONS_UNIVERSAL, DOMAIN as RAINMACHINE_DOMAIN, - PROVISION_SETTINGS, - RESTRICTIONS_UNIVERSAL, SENSOR_UPDATE_TOPIC, - RainMachineEntity, ) _LOGGER = logging.getLogger(__name__) @@ -28,7 +27,7 @@ SENSORS = { "clicks/m^3", None, False, - PROVISION_SETTINGS, + DATA_PROVISION_SETTINGS, ), TYPE_FLOW_SENSOR_CONSUMED_LITERS: ( "Flow Sensor Consumed Liters", @@ -36,7 +35,7 @@ SENSORS = { "liter", None, False, - PROVISION_SETTINGS, + DATA_PROVISION_SETTINGS, ), TYPE_FLOW_SENSOR_START_INDEX: ( "Flow Sensor Start Index", @@ -44,7 +43,7 @@ SENSORS = { "index", None, False, - PROVISION_SETTINGS, + DATA_PROVISION_SETTINGS, ), TYPE_FLOW_SENSOR_WATERING_CLICKS: ( "Flow Sensor Clicks", @@ -52,7 +51,7 @@ SENSORS = { "clicks", None, False, - PROVISION_SETTINGS, + DATA_PROVISION_SETTINGS, ), TYPE_FREEZE_TEMP: ( "Freeze Protect Temperature", @@ -60,7 +59,7 @@ SENSORS = { "°C", "temperature", True, - RESTRICTIONS_UNIVERSAL, + DATA_RESTRICTIONS_UNIVERSAL, ), } @@ -124,11 +123,6 @@ class RainMachineSensor(RainMachineEntity): """Return the icon.""" return self._icon - @property - def should_poll(self): - """Disable polling.""" - return False - @property def state(self) -> str: """Return the name of the entity.""" @@ -148,50 +142,44 @@ class RainMachineSensor(RainMachineEntity): async def async_added_to_hass(self): """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - self._dispatcher_handlers.append( - async_dispatcher_connect(self.hass, SENSOR_UPDATE_TOPIC, update) + async_dispatcher_connect(self.hass, SENSOR_UPDATE_TOPIC, self._update_state) ) - await self.rainmachine.async_register_api_interest(self._api_category) + await self.rainmachine.async_register_sensor_api_interest(self._api_category) await self.async_update() async def async_update(self): """Update the sensor's state.""" if self._sensor_type == TYPE_FLOW_SENSOR_CLICK_M3: - self._state = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( + self._state = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get( "flowSensorClicksPerCubicMeter" ) elif self._sensor_type == TYPE_FLOW_SENSOR_CONSUMED_LITERS: - clicks = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( + clicks = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get( "flowSensorWateringClicks" ) - clicks_per_m3 = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( - "flowSensorClicksPerCubicMeter" - ) + clicks_per_m3 = self.rainmachine.data[DATA_PROVISION_SETTINGS][ + "system" + ].get("flowSensorClicksPerCubicMeter") if clicks and clicks_per_m3: self._state = (clicks * 1000) / clicks_per_m3 else: self._state = None elif self._sensor_type == TYPE_FLOW_SENSOR_START_INDEX: - self._state = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( + self._state = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get( "flowSensorStartIndex" ) elif self._sensor_type == TYPE_FLOW_SENSOR_WATERING_CLICKS: - self._state = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( + self._state = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get( "flowSensorWateringClicks" ) elif self._sensor_type == TYPE_FREEZE_TEMP: - self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ + self._state = self.rainmachine.data[DATA_RESTRICTIONS_UNIVERSAL][ "freezeProtectTemp" ] async def async_will_remove_from_hass(self): """Disconnect dispatcher listeners and deregister API interest.""" super().async_will_remove_from_hass() - self.rainmachine.async_deregister_api_interest(self._api_category) + self.rainmachine.async_deregister_sensor_api_interest(self._api_category) diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 8da2cc4ee45..ff706cd7be5 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -6,18 +6,17 @@ from regenmaschine.errors import RequestError from homeassistant.components.switch import SwitchDevice from homeassistant.const import ATTR_ID -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ( +from . import RainMachineEntity +from .const import ( DATA_CLIENT, + DATA_PROGRAMS, + DATA_ZONES, + DATA_ZONES_DETAILS, DOMAIN as RAINMACHINE_DOMAIN, PROGRAM_UPDATE_TOPIC, ZONE_UPDATE_TOPIC, - RainMachineEntity, ) _LOGGER = logging.getLogger(__name__) @@ -94,22 +93,19 @@ VEGETATION_MAP = { 99: "Other", } +SWITCH_TYPE_PROGRAM = "program" +SWITCH_TYPE_ZONE = "zone" + async def async_setup_entry(hass, entry, async_add_entities): """Set up RainMachine switches based on a config entry.""" rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] entities = [] - - programs = await rainmachine.client.programs.all(include_inactive=True) - for program in programs: + for program in rainmachine.data[DATA_PROGRAMS]: entities.append(RainMachineProgram(rainmachine, program)) - - zones = await rainmachine.client.zones.all(include_inactive=True) - for zone in zones: - entities.append( - RainMachineZone(rainmachine, zone, rainmachine.default_zone_runtime) - ) + for zone in rainmachine.data[DATA_ZONES]: + entities.append(RainMachineZone(rainmachine, zone)) async_add_entities(entities, True) @@ -117,25 +113,31 @@ async def async_setup_entry(hass, entry, async_add_entities): class RainMachineSwitch(RainMachineEntity, SwitchDevice): """A class to represent a generic RainMachine switch.""" - def __init__(self, rainmachine, switch_type, obj): + def __init__(self, rainmachine, switch_data): """Initialize a generic RainMachine switch.""" super().__init__(rainmachine) - self._name = obj["name"] - self._obj = obj - self._rainmachine_entity_id = obj["uid"] - self._switch_type = switch_type + self._is_on = False + self._name = switch_data["name"] + self._switch_data = switch_data + self._rainmachine_entity_id = switch_data["uid"] + self._switch_type = None @property def available(self) -> bool: """Return True if entity is available.""" - return bool(self._obj.get("active")) + return self._switch_data["active"] @property def icon(self) -> str: """Return the icon.""" return "mdi:water" + @property + def is_on(self) -> bool: + """Return whether the program is running.""" + return self._is_on + @property def unique_id(self) -> str: """Return a unique, Home Assistant friendly identifier for this entity.""" @@ -145,173 +147,156 @@ class RainMachineSwitch(RainMachineEntity, SwitchDevice): self._rainmachine_entity_id, ) - @callback - def _program_updated(self): - """Update state, trigger updates.""" - self.async_schedule_update_ha_state(True) + async def _async_run_switch_coroutine(self, api_coro) -> None: + """Run a coroutine to toggle the switch.""" + try: + resp = await api_coro + except RequestError as err: + _LOGGER.error( + 'Error while toggling %s "%s": %s', + self._switch_type, + self.unique_id, + err, + ) + return + + if resp["statusCode"] != 0: + _LOGGER.error( + 'Error while toggling %s "%s": %s', + self._switch_type, + self.unique_id, + resp["message"], + ) + return + + self.hass.async_create_task(self.rainmachine.async_update_programs_and_zones()) class RainMachineProgram(RainMachineSwitch): """A RainMachine program.""" - def __init__(self, rainmachine, obj): + def __init__(self, rainmachine, switch_data): """Initialize a generic RainMachine switch.""" - super().__init__(rainmachine, "program", obj) - - @property - def is_on(self) -> bool: - """Return whether the program is running.""" - return bool(self._obj.get("status")) + super().__init__(rainmachine, switch_data) + self._switch_type = SWITCH_TYPE_PROGRAM @property def zones(self) -> list: """Return a list of active zones associated with this program.""" - return [z for z in self._obj["wateringTimes"] if z["active"]] + return [z for z in self._switch_data["wateringTimes"] if z["active"]] async def async_added_to_hass(self): """Register callbacks.""" self._dispatcher_handlers.append( async_dispatcher_connect( - self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated + self.hass, PROGRAM_UPDATE_TOPIC, self._update_state ) ) async def async_turn_off(self, **kwargs) -> None: """Turn the program off.""" - - try: - await self.rainmachine.client.programs.stop(self._rainmachine_entity_id) - async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC) - except RequestError as err: - _LOGGER.error( - 'Unable to turn off program "%s": %s', self.unique_id, str(err) - ) + await self._async_run_switch_coroutine( + self.rainmachine.client.programs.stop(self._rainmachine_entity_id) + ) async def async_turn_on(self, **kwargs) -> None: """Turn the program on.""" - - try: - await self.rainmachine.client.programs.start(self._rainmachine_entity_id) - async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC) - except RequestError as err: - _LOGGER.error( - 'Unable to turn on program "%s": %s', self.unique_id, str(err) - ) + await self._async_run_switch_coroutine( + self.rainmachine.client.programs.start(self._rainmachine_entity_id) + ) async def async_update(self) -> None: """Update info for the program.""" + [self._switch_data] = [ + p + for p in self.rainmachine.data[DATA_PROGRAMS] + if p["uid"] == self._rainmachine_entity_id + ] + + self._is_on = bool(self._switch_data["status"]) try: - self._obj = await self.rainmachine.client.programs.get( - self._rainmachine_entity_id - ) + next_run = datetime.strptime( + "{0} {1}".format( + self._switch_data["nextRun"], self._switch_data["startTime"] + ), + "%Y-%m-%d %H:%M", + ).isoformat() + except ValueError: + next_run = None - try: - next_run = datetime.strptime( - "{0} {1}".format(self._obj["nextRun"], self._obj["startTime"]), - "%Y-%m-%d %H:%M", - ).isoformat() - except ValueError: - next_run = None - - self._attrs.update( - { - ATTR_ID: self._obj["uid"], - ATTR_NEXT_RUN: next_run, - ATTR_SOAK: self._obj.get("soak"), - ATTR_STATUS: PROGRAM_STATUS_MAP[self._obj.get("status")], - ATTR_ZONES: ", ".join(z["name"] for z in self.zones), - } - ) - except RequestError as err: - _LOGGER.error( - 'Unable to update info for program "%s": %s', self.unique_id, str(err) - ) + self._attrs.update( + { + ATTR_ID: self._switch_data["uid"], + ATTR_NEXT_RUN: next_run, + ATTR_SOAK: self._switch_data.get("soak"), + ATTR_STATUS: PROGRAM_STATUS_MAP[self._switch_data["status"]], + ATTR_ZONES: ", ".join(z["name"] for z in self.zones), + } + ) class RainMachineZone(RainMachineSwitch): """A RainMachine zone.""" - def __init__(self, rainmachine, obj, zone_run_time): + def __init__(self, rainmachine, switch_data): """Initialize a RainMachine zone.""" - super().__init__(rainmachine, "zone", obj) - - self._properties_json = {} - self._run_time = zone_run_time - - @property - def is_on(self) -> bool: - """Return whether the zone is running.""" - return bool(self._obj.get("state")) + super().__init__(rainmachine, switch_data) + self._switch_type = SWITCH_TYPE_ZONE async def async_added_to_hass(self): """Register callbacks.""" self._dispatcher_handlers.append( async_dispatcher_connect( - self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated + self.hass, PROGRAM_UPDATE_TOPIC, self._update_state ) ) self._dispatcher_handlers.append( - async_dispatcher_connect( - self.hass, ZONE_UPDATE_TOPIC, self._program_updated - ) + async_dispatcher_connect(self.hass, ZONE_UPDATE_TOPIC, self._update_state) ) async def async_turn_off(self, **kwargs) -> None: """Turn the zone off.""" - - try: - await self.rainmachine.client.zones.stop(self._rainmachine_entity_id) - except RequestError as err: - _LOGGER.error('Unable to turn off zone "%s": %s', self.unique_id, str(err)) + await self._async_run_switch_coroutine( + self.rainmachine.client.zones.stop(self._rainmachine_entity_id) + ) async def async_turn_on(self, **kwargs) -> None: """Turn the zone on.""" - - try: - await self.rainmachine.client.zones.start( - self._rainmachine_entity_id, self._run_time + await self._async_run_switch_coroutine( + self.rainmachine.client.zones.start( + self._rainmachine_entity_id, self.rainmachine.default_zone_runtime ) - except RequestError as err: - _LOGGER.error('Unable to turn on zone "%s": %s', self.unique_id, str(err)) + ) async def async_update(self) -> None: """Update info for the zone.""" + [self._switch_data] = [ + z + for z in self.rainmachine.data[DATA_ZONES] + if z["uid"] == self._rainmachine_entity_id + ] + [details] = [ + z + for z in self.rainmachine.data[DATA_ZONES_DETAILS] + if z["uid"] == self._rainmachine_entity_id + ] - try: - self._obj = await self.rainmachine.client.zones.get( - self._rainmachine_entity_id - ) + self._is_on = bool(self._switch_data["state"]) - self._properties_json = await self.rainmachine.client.zones.get( - self._rainmachine_entity_id, details=True - ) - - self._attrs.update( - { - ATTR_ID: self._obj["uid"], - ATTR_AREA: self._properties_json.get("waterSense").get("area"), - ATTR_CURRENT_CYCLE: self._obj.get("cycle"), - ATTR_FIELD_CAPACITY: self._properties_json.get("waterSense").get( - "fieldCapacity" - ), - ATTR_NO_CYCLES: self._obj.get("noOfCycles"), - ATTR_PRECIP_RATE: self._properties_json.get("waterSense").get( - "precipitationRate" - ), - ATTR_RESTRICTIONS: self._obj.get("restriction"), - ATTR_SLOPE: SLOPE_TYPE_MAP.get(self._properties_json.get("slope")), - ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(self._properties_json.get("sun")), - ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get( - self._properties_json.get("group_id") - ), - ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get( - self._properties_json.get("sun") - ), - ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(self._obj.get("type")), - } - ) - except RequestError as err: - _LOGGER.error( - 'Unable to update info for zone "%s": %s', self.unique_id, str(err) - ) + self._attrs.update( + { + ATTR_ID: self._switch_data["uid"], + ATTR_AREA: details.get("waterSense").get("area"), + ATTR_CURRENT_CYCLE: self._switch_data.get("cycle"), + ATTR_FIELD_CAPACITY: details.get("waterSense").get("fieldCapacity"), + ATTR_NO_CYCLES: self._switch_data.get("noOfCycles"), + ATTR_PRECIP_RATE: details.get("waterSense").get("precipitationRate"), + ATTR_RESTRICTIONS: self._switch_data.get("restriction"), + ATTR_SLOPE: SLOPE_TYPE_MAP.get(details.get("slope")), + ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(details.get("sun")), + ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(details.get("group_id")), + ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(details.get("sun")), + ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(self._switch_data.get("type")), + } + )