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
pull/31149/head
Aaron Bach 2020-01-25 20:27:35 -07:00 committed by GitHub
parent 37d1cdc4cb
commit 4c4f726323
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 295 additions and 260 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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"

View File

@ -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)

View File

@ -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")),
}
)