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
This commit is contained in:
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 .config_flow import configured_instances
from .const import ( from .const import (
DATA_CLIENT, DATA_CLIENT,
DATA_PROGRAMS,
DATA_PROVISION_SETTINGS,
DATA_RESTRICTIONS_CURRENT,
DATA_RESTRICTIONS_UNIVERSAL,
DATA_ZONES,
DATA_ZONES_DETAILS,
DEFAULT_PORT, DEFAULT_PORT,
DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,
DEFAULT_SSL, DEFAULT_SSL,
DOMAIN, DOMAIN,
PROVISION_SETTINGS, PROGRAM_UPDATE_TOPIC,
RESTRICTIONS_CURRENT, SENSOR_UPDATE_TOPIC,
RESTRICTIONS_UNIVERSAL, ZONE_UPDATE_TOPIC,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DATA_LISTENER = "listener" 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_CONTROLLERS = "controllers"
CONF_PROGRAM_ID = "program_id" CONF_PROGRAM_ID = "program_id"
CONF_SECONDS = "seconds" CONF_SECONDS = "seconds"
@ -150,6 +152,9 @@ async def async_setup_entry(hass, config_entry):
_LOGGER.error("An error occurred: %s", err) _LOGGER.error("An error occurred: %s", err)
raise ConfigEntryNotReady 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 hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = rainmachine
for component in ("binary_sensor", "sensor", "switch"): for component in ("binary_sensor", "sensor", "switch"):
@ -161,37 +166,37 @@ async def async_setup_entry(hass, config_entry):
async def disable_program(call): async def disable_program(call):
"""Disable a program.""" """Disable a program."""
await rainmachine.client.programs.disable(call.data[CONF_PROGRAM_ID]) 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 @_verify_domain_control
async def disable_zone(call): async def disable_zone(call):
"""Disable a zone.""" """Disable a zone."""
await rainmachine.client.zones.disable(call.data[CONF_ZONE_ID]) 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 @_verify_domain_control
async def enable_program(call): async def enable_program(call):
"""Enable a program.""" """Enable a program."""
await rainmachine.client.programs.enable(call.data[CONF_PROGRAM_ID]) 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 @_verify_domain_control
async def enable_zone(call): async def enable_zone(call):
"""Enable a zone.""" """Enable a zone."""
await rainmachine.client.zones.enable(call.data[CONF_ZONE_ID]) 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 @_verify_domain_control
async def pause_watering(call): async def pause_watering(call):
"""Pause watering for a set number of seconds.""" """Pause watering for a set number of seconds."""
await rainmachine.client.watering.pause_all(call.data[CONF_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 @_verify_domain_control
async def start_program(call): async def start_program(call):
"""Start a particular program.""" """Start a particular program."""
await rainmachine.client.programs.start(call.data[CONF_PROGRAM_ID]) 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 @_verify_domain_control
async def start_zone(call): async def start_zone(call):
@ -199,31 +204,31 @@ async def async_setup_entry(hass, config_entry):
await rainmachine.client.zones.start( await rainmachine.client.zones.start(
call.data[CONF_ZONE_ID], call.data[CONF_ZONE_RUN_TIME] 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 @_verify_domain_control
async def stop_all(call): async def stop_all(call):
"""Stop all watering.""" """Stop all watering."""
await rainmachine.client.watering.stop_all() await rainmachine.client.watering.stop_all()
async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) await rainmachine.async_update_programs_and_zones()
@_verify_domain_control @_verify_domain_control
async def stop_program(call): async def stop_program(call):
"""Stop a program.""" """Stop a program."""
await rainmachine.client.programs.stop(call.data[CONF_PROGRAM_ID]) 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 @_verify_domain_control
async def stop_zone(call): async def stop_zone(call):
"""Stop a zone.""" """Stop a zone."""
await rainmachine.client.zones.stop(call.data[CONF_ZONE_ID]) 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 @_verify_domain_control
async def unpause_watering(call): async def unpause_watering(call):
"""Unpause watering.""" """Unpause watering."""
await rainmachine.client.watering.unpause_all() 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 [ for service, method, schema in [
("disable_program", disable_program, SERVICE_ALTER_PROGRAM), ("disable_program", disable_program, SERVICE_ALTER_PROGRAM),
@ -265,7 +270,7 @@ class RainMachine:
def __init__(self, hass, client, default_zone_runtime, scan_interval): def __init__(self, hass, client, default_zone_runtime, scan_interval):
"""Initialize.""" """Initialize."""
self._async_unsub_dispatcher_connect = None self._async_cancel_time_interval_listener = None
self._scan_interval_seconds = scan_interval self._scan_interval_seconds = scan_interval
self.client = client self.client = client
self.data = {} self.data = {}
@ -274,48 +279,58 @@ class RainMachine:
self.hass = hass self.hass = hass
self._api_category_count = { self._api_category_count = {
PROVISION_SETTINGS: 0, DATA_PROVISION_SETTINGS: 0,
RESTRICTIONS_CURRENT: 0, DATA_RESTRICTIONS_CURRENT: 0,
RESTRICTIONS_UNIVERSAL: 0, DATA_RESTRICTIONS_UNIVERSAL: 0,
} }
self._api_category_locks = { self._api_category_locks = {
PROVISION_SETTINGS: asyncio.Lock(), DATA_PROVISION_SETTINGS: asyncio.Lock(),
RESTRICTIONS_CURRENT: asyncio.Lock(), DATA_RESTRICTIONS_CURRENT: asyncio.Lock(),
RESTRICTIONS_UNIVERSAL: 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): async def _async_update_listener_action(self, now):
"""Define an async_track_time_interval action to update data.""" """Define an async_track_time_interval action to update data."""
await self.async_update() await self.async_update()
@callback @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.""" """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 # If this deregistration should leave us with no registration at all, remove the
# time interval: # time interval:
if sum(self._api_category_count.values()) == 0: if sum(self._api_category_count.values()) == 0:
if self._async_unsub_dispatcher_connect: if self._async_cancel_time_interval_listener:
self._async_unsub_dispatcher_connect() self._async_cancel_time_interval_listener()
self._async_unsub_dispatcher_connect = None self._async_cancel_time_interval_listener = None
return 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.""" """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 this is the first registration we have, start a time interval:
if not self._async_unsub_dispatcher_connect: if not self._async_cancel_time_interval_listener:
self._async_unsub_dispatcher_connect = async_track_time_interval( self._async_cancel_time_interval_listener = async_track_time_interval(
self.hass, self.hass,
self._async_update_listener_action, self._async_update_listener_action,
timedelta(seconds=self._scan_interval_seconds), timedelta(seconds=self._scan_interval_seconds),
@ -323,19 +338,27 @@ class RainMachine:
self._api_category_count[api_category] += 1 self._api_category_count[api_category] += 1
# Lock API updates in case multiple entities are trying to call the same API # If a sensor registers interest in a particular API call and the data doesn't
# endpoint at once: # exist for it yet, make the API call and grab the data:
async with self._api_category_locks[api_category]: async with self._api_category_locks[api_category]:
if api_category not in self.data: 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): 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.""" """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 = {} tasks = {}
for category, count in self._api_category_count.items(): for category, count in self._api_category_count.items():
if count == 0: if count == 0:
continue 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) results = await asyncio.gather(*tasks.values(), return_exceptions=True)
for api_category, result in zip(tasks, results): 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 "There was an error while updating %s: %s", api_category, result
) )
continue continue
self.data[api_category] = result
async_dispatcher_send(self.hass, SENSOR_UPDATE_TOPIC) 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): class RainMachineEntity(Entity):
"""Define a generic RainMachine entity.""" """Define a generic RainMachine entity."""
@ -389,6 +439,16 @@ class RainMachineEntity(Entity):
"""Return the name of the entity.""" """Return the name of the entity."""
return self._name 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): async def async_will_remove_from_hass(self):
"""Disconnect dispatcher listener when removed.""" """Disconnect dispatcher listener when removed."""
for handler in self._dispatcher_handlers: for handler in self._dispatcher_handlers:

View file

@ -2,17 +2,16 @@
import logging import logging
from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import ( from . import RainMachineEntity
from .const import (
DATA_CLIENT, DATA_CLIENT,
DATA_PROVISION_SETTINGS,
DATA_RESTRICTIONS_CURRENT,
DATA_RESTRICTIONS_UNIVERSAL,
DOMAIN as RAINMACHINE_DOMAIN, DOMAIN as RAINMACHINE_DOMAIN,
PROVISION_SETTINGS,
RESTRICTIONS_CURRENT,
RESTRICTIONS_UNIVERSAL,
SENSOR_UPDATE_TOPIC, SENSOR_UPDATE_TOPIC,
RainMachineEntity,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -28,35 +27,45 @@ TYPE_RAINSENSOR = "rainsensor"
TYPE_WEEKDAY = "weekday" TYPE_WEEKDAY = "weekday"
BINARY_SENSORS = { BINARY_SENSORS = {
TYPE_FLOW_SENSOR: ("Flow Sensor", "mdi:water-pump", True, PROVISION_SETTINGS), TYPE_FLOW_SENSOR: ("Flow Sensor", "mdi:water-pump", True, DATA_PROVISION_SETTINGS),
TYPE_FREEZE: ("Freeze Restrictions", "mdi:cancel", True, RESTRICTIONS_CURRENT), TYPE_FREEZE: ("Freeze Restrictions", "mdi:cancel", True, DATA_RESTRICTIONS_CURRENT),
TYPE_FREEZE_PROTECTION: ( TYPE_FREEZE_PROTECTION: (
"Freeze Protection", "Freeze Protection",
"mdi:weather-snowy", "mdi:weather-snowy",
True, True,
RESTRICTIONS_UNIVERSAL, DATA_RESTRICTIONS_UNIVERSAL,
), ),
TYPE_HOT_DAYS: ( TYPE_HOT_DAYS: (
"Extra Water on Hot Days", "Extra Water on Hot Days",
"mdi:thermometer-lines", "mdi:thermometer-lines",
True, True,
RESTRICTIONS_UNIVERSAL, DATA_RESTRICTIONS_UNIVERSAL,
), ),
TYPE_HOURLY: ("Hourly Restrictions", "mdi:cancel", False, RESTRICTIONS_CURRENT), TYPE_HOURLY: (
TYPE_MONTH: ("Month Restrictions", "mdi:cancel", False, RESTRICTIONS_CURRENT), "Hourly Restrictions",
"mdi:cancel",
False,
DATA_RESTRICTIONS_CURRENT,
),
TYPE_MONTH: ("Month Restrictions", "mdi:cancel", False, DATA_RESTRICTIONS_CURRENT),
TYPE_RAINDELAY: ( TYPE_RAINDELAY: (
"Rain Delay Restrictions", "Rain Delay Restrictions",
"mdi:cancel", "mdi:cancel",
False, False,
RESTRICTIONS_CURRENT, DATA_RESTRICTIONS_CURRENT,
), ),
TYPE_RAINSENSOR: ( TYPE_RAINSENSOR: (
"Rain Sensor Restrictions", "Rain Sensor Restrictions",
"mdi:cancel", "mdi:cancel",
False, 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 the status of the sensor."""
return self._state return self._state
@property
def should_poll(self):
"""Disable polling."""
return False
@property @property
def unique_id(self) -> str: def unique_id(self) -> str:
"""Return a unique, Home Assistant friendly identifier for this entity.""" """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): async def async_added_to_hass(self):
"""Register callbacks.""" """Register callbacks."""
@callback
def update():
"""Update the state."""
self.async_schedule_update_ha_state(True)
self._dispatcher_handlers.append( 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() await self.async_update()
async def async_update(self): async def async_update(self):
"""Update the state.""" """Update the state."""
if self._sensor_type == TYPE_FLOW_SENSOR: 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" "useFlowSensor"
) )
elif self._sensor_type == TYPE_FREEZE: 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: elif self._sensor_type == TYPE_FREEZE_PROTECTION:
self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ self._state = self.rainmachine.data[DATA_RESTRICTIONS_UNIVERSAL][
"freezeProtectEnabled" "freezeProtectEnabled"
] ]
elif self._sensor_type == TYPE_HOT_DAYS: elif self._sensor_type == TYPE_HOT_DAYS:
self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ self._state = self.rainmachine.data[DATA_RESTRICTIONS_UNIVERSAL][
"hotDaysExtraWatering" "hotDaysExtraWatering"
] ]
elif self._sensor_type == TYPE_HOURLY: 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: 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: 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: 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: 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): async def async_will_remove_from_hass(self):
"""Disconnect dispatcher listeners and deregister API interest.""" """Disconnect dispatcher listeners and deregister API interest."""
super().async_will_remove_from_hass() 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" DOMAIN = "rainmachine"
DATA_CLIENT = "client" 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_PORT = 8080
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
DEFAULT_SSL = True DEFAULT_SSL = True
PROVISION_SETTINGS = "provision.settings" PROGRAM_UPDATE_TOPIC = f"{DOMAIN}_program_update"
RESTRICTIONS_CURRENT = "restrictions.current" SENSOR_UPDATE_TOPIC = f"{DOMAIN}_data_update"
RESTRICTIONS_UNIVERSAL = "restrictions.universal" ZONE_UPDATE_TOPIC = f"{DOMAIN}_zone_update"
TOPIC_UPDATE = "update_{0}"

View file

@ -1,16 +1,15 @@
"""This platform provides support for sensor data from RainMachine.""" """This platform provides support for sensor data from RainMachine."""
import logging import logging
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import ( from . import RainMachineEntity
from .const import (
DATA_CLIENT, DATA_CLIENT,
DATA_PROVISION_SETTINGS,
DATA_RESTRICTIONS_UNIVERSAL,
DOMAIN as RAINMACHINE_DOMAIN, DOMAIN as RAINMACHINE_DOMAIN,
PROVISION_SETTINGS,
RESTRICTIONS_UNIVERSAL,
SENSOR_UPDATE_TOPIC, SENSOR_UPDATE_TOPIC,
RainMachineEntity,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -28,7 +27,7 @@ SENSORS = {
"clicks/m^3", "clicks/m^3",
None, None,
False, False,
PROVISION_SETTINGS, DATA_PROVISION_SETTINGS,
), ),
TYPE_FLOW_SENSOR_CONSUMED_LITERS: ( TYPE_FLOW_SENSOR_CONSUMED_LITERS: (
"Flow Sensor Consumed Liters", "Flow Sensor Consumed Liters",
@ -36,7 +35,7 @@ SENSORS = {
"liter", "liter",
None, None,
False, False,
PROVISION_SETTINGS, DATA_PROVISION_SETTINGS,
), ),
TYPE_FLOW_SENSOR_START_INDEX: ( TYPE_FLOW_SENSOR_START_INDEX: (
"Flow Sensor Start Index", "Flow Sensor Start Index",
@ -44,7 +43,7 @@ SENSORS = {
"index", "index",
None, None,
False, False,
PROVISION_SETTINGS, DATA_PROVISION_SETTINGS,
), ),
TYPE_FLOW_SENSOR_WATERING_CLICKS: ( TYPE_FLOW_SENSOR_WATERING_CLICKS: (
"Flow Sensor Clicks", "Flow Sensor Clicks",
@ -52,7 +51,7 @@ SENSORS = {
"clicks", "clicks",
None, None,
False, False,
PROVISION_SETTINGS, DATA_PROVISION_SETTINGS,
), ),
TYPE_FREEZE_TEMP: ( TYPE_FREEZE_TEMP: (
"Freeze Protect Temperature", "Freeze Protect Temperature",
@ -60,7 +59,7 @@ SENSORS = {
"°C", "°C",
"temperature", "temperature",
True, True,
RESTRICTIONS_UNIVERSAL, DATA_RESTRICTIONS_UNIVERSAL,
), ),
} }
@ -124,11 +123,6 @@ class RainMachineSensor(RainMachineEntity):
"""Return the icon.""" """Return the icon."""
return self._icon return self._icon
@property
def should_poll(self):
"""Disable polling."""
return False
@property @property
def state(self) -> str: def state(self) -> str:
"""Return the name of the entity.""" """Return the name of the entity."""
@ -148,50 +142,44 @@ class RainMachineSensor(RainMachineEntity):
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Register callbacks.""" """Register callbacks."""
@callback
def update():
"""Update the state."""
self.async_schedule_update_ha_state(True)
self._dispatcher_handlers.append( 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() await self.async_update()
async def async_update(self): async def async_update(self):
"""Update the sensor's state.""" """Update the sensor's state."""
if self._sensor_type == TYPE_FLOW_SENSOR_CLICK_M3: 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" "flowSensorClicksPerCubicMeter"
) )
elif self._sensor_type == TYPE_FLOW_SENSOR_CONSUMED_LITERS: 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" "flowSensorWateringClicks"
) )
clicks_per_m3 = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( clicks_per_m3 = self.rainmachine.data[DATA_PROVISION_SETTINGS][
"flowSensorClicksPerCubicMeter" "system"
) ].get("flowSensorClicksPerCubicMeter")
if clicks and clicks_per_m3: if clicks and clicks_per_m3:
self._state = (clicks * 1000) / clicks_per_m3 self._state = (clicks * 1000) / clicks_per_m3
else: else:
self._state = None self._state = None
elif self._sensor_type == TYPE_FLOW_SENSOR_START_INDEX: 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" "flowSensorStartIndex"
) )
elif self._sensor_type == TYPE_FLOW_SENSOR_WATERING_CLICKS: 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" "flowSensorWateringClicks"
) )
elif self._sensor_type == TYPE_FREEZE_TEMP: elif self._sensor_type == TYPE_FREEZE_TEMP:
self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ self._state = self.rainmachine.data[DATA_RESTRICTIONS_UNIVERSAL][
"freezeProtectTemp" "freezeProtectTemp"
] ]
async def async_will_remove_from_hass(self): async def async_will_remove_from_hass(self):
"""Disconnect dispatcher listeners and deregister API interest.""" """Disconnect dispatcher listeners and deregister API interest."""
super().async_will_remove_from_hass() 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.components.switch import SwitchDevice
from homeassistant.const import ATTR_ID from homeassistant.const import ATTR_ID
from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from . import ( from . import RainMachineEntity
from .const import (
DATA_CLIENT, DATA_CLIENT,
DATA_PROGRAMS,
DATA_ZONES,
DATA_ZONES_DETAILS,
DOMAIN as RAINMACHINE_DOMAIN, DOMAIN as RAINMACHINE_DOMAIN,
PROGRAM_UPDATE_TOPIC, PROGRAM_UPDATE_TOPIC,
ZONE_UPDATE_TOPIC, ZONE_UPDATE_TOPIC,
RainMachineEntity,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -94,22 +93,19 @@ VEGETATION_MAP = {
99: "Other", 99: "Other",
} }
SWITCH_TYPE_PROGRAM = "program"
SWITCH_TYPE_ZONE = "zone"
async def async_setup_entry(hass, entry, async_add_entities): async def async_setup_entry(hass, entry, async_add_entities):
"""Set up RainMachine switches based on a config entry.""" """Set up RainMachine switches based on a config entry."""
rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id]
entities = [] entities = []
for program in rainmachine.data[DATA_PROGRAMS]:
programs = await rainmachine.client.programs.all(include_inactive=True)
for program in programs:
entities.append(RainMachineProgram(rainmachine, program)) entities.append(RainMachineProgram(rainmachine, program))
for zone in rainmachine.data[DATA_ZONES]:
zones = await rainmachine.client.zones.all(include_inactive=True) entities.append(RainMachineZone(rainmachine, zone))
for zone in zones:
entities.append(
RainMachineZone(rainmachine, zone, rainmachine.default_zone_runtime)
)
async_add_entities(entities, True) async_add_entities(entities, True)
@ -117,25 +113,31 @@ async def async_setup_entry(hass, entry, async_add_entities):
class RainMachineSwitch(RainMachineEntity, SwitchDevice): class RainMachineSwitch(RainMachineEntity, SwitchDevice):
"""A class to represent a generic RainMachine switch.""" """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.""" """Initialize a generic RainMachine switch."""
super().__init__(rainmachine) super().__init__(rainmachine)
self._name = obj["name"] self._is_on = False
self._obj = obj self._name = switch_data["name"]
self._rainmachine_entity_id = obj["uid"] self._switch_data = switch_data
self._switch_type = switch_type self._rainmachine_entity_id = switch_data["uid"]
self._switch_type = None
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
return bool(self._obj.get("active")) return self._switch_data["active"]
@property @property
def icon(self) -> str: def icon(self) -> str:
"""Return the icon.""" """Return the icon."""
return "mdi:water" return "mdi:water"
@property
def is_on(self) -> bool:
"""Return whether the program is running."""
return self._is_on
@property @property
def unique_id(self) -> str: def unique_id(self) -> str:
"""Return a unique, Home Assistant friendly identifier for this entity.""" """Return a unique, Home Assistant friendly identifier for this entity."""
@ -145,70 +147,79 @@ class RainMachineSwitch(RainMachineEntity, SwitchDevice):
self._rainmachine_entity_id, self._rainmachine_entity_id,
) )
@callback async def _async_run_switch_coroutine(self, api_coro) -> None:
def _program_updated(self): """Run a coroutine to toggle the switch."""
"""Update state, trigger updates.""" try:
self.async_schedule_update_ha_state(True) 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): class RainMachineProgram(RainMachineSwitch):
"""A RainMachine program.""" """A RainMachine program."""
def __init__(self, rainmachine, obj): def __init__(self, rainmachine, switch_data):
"""Initialize a generic RainMachine switch.""" """Initialize a generic RainMachine switch."""
super().__init__(rainmachine, "program", obj) super().__init__(rainmachine, switch_data)
self._switch_type = SWITCH_TYPE_PROGRAM
@property
def is_on(self) -> bool:
"""Return whether the program is running."""
return bool(self._obj.get("status"))
@property @property
def zones(self) -> list: def zones(self) -> list:
"""Return a list of active zones associated with this program.""" """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): async def async_added_to_hass(self):
"""Register callbacks.""" """Register callbacks."""
self._dispatcher_handlers.append( self._dispatcher_handlers.append(
async_dispatcher_connect( 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: async def async_turn_off(self, **kwargs) -> None:
"""Turn the program off.""" """Turn the program off."""
await self._async_run_switch_coroutine(
try: self.rainmachine.client.programs.stop(self._rainmachine_entity_id)
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)
) )
async def async_turn_on(self, **kwargs) -> None: async def async_turn_on(self, **kwargs) -> None:
"""Turn the program on.""" """Turn the program on."""
await self._async_run_switch_coroutine(
try: self.rainmachine.client.programs.start(self._rainmachine_entity_id)
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)
) )
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update info for the program.""" """Update info for the program."""
[self._switch_data] = [
p
for p in self.rainmachine.data[DATA_PROGRAMS]
if p["uid"] == self._rainmachine_entity_id
]
try: self._is_on = bool(self._switch_data["status"])
self._obj = await self.rainmachine.client.programs.get(
self._rainmachine_entity_id
)
try: try:
next_run = datetime.strptime( next_run = datetime.strptime(
"{0} {1}".format(self._obj["nextRun"], self._obj["startTime"]), "{0} {1}".format(
self._switch_data["nextRun"], self._switch_data["startTime"]
),
"%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M",
).isoformat() ).isoformat()
except ValueError: except ValueError:
@ -216,102 +227,76 @@ class RainMachineProgram(RainMachineSwitch):
self._attrs.update( self._attrs.update(
{ {
ATTR_ID: self._obj["uid"], ATTR_ID: self._switch_data["uid"],
ATTR_NEXT_RUN: next_run, ATTR_NEXT_RUN: next_run,
ATTR_SOAK: self._obj.get("soak"), ATTR_SOAK: self._switch_data.get("soak"),
ATTR_STATUS: PROGRAM_STATUS_MAP[self._obj.get("status")], ATTR_STATUS: PROGRAM_STATUS_MAP[self._switch_data["status"]],
ATTR_ZONES: ", ".join(z["name"] for z in self.zones), 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)
)
class RainMachineZone(RainMachineSwitch): class RainMachineZone(RainMachineSwitch):
"""A RainMachine zone.""" """A RainMachine zone."""
def __init__(self, rainmachine, obj, zone_run_time): def __init__(self, rainmachine, switch_data):
"""Initialize a RainMachine zone.""" """Initialize a RainMachine zone."""
super().__init__(rainmachine, "zone", obj) super().__init__(rainmachine, switch_data)
self._switch_type = SWITCH_TYPE_ZONE
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"))
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Register callbacks.""" """Register callbacks."""
self._dispatcher_handlers.append( self._dispatcher_handlers.append(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated self.hass, PROGRAM_UPDATE_TOPIC, self._update_state
) )
) )
self._dispatcher_handlers.append( self._dispatcher_handlers.append(
async_dispatcher_connect( async_dispatcher_connect(self.hass, ZONE_UPDATE_TOPIC, self._update_state)
self.hass, ZONE_UPDATE_TOPIC, self._program_updated
)
) )
async def async_turn_off(self, **kwargs) -> None: async def async_turn_off(self, **kwargs) -> None:
"""Turn the zone off.""" """Turn the zone off."""
await self._async_run_switch_coroutine(
try: self.rainmachine.client.zones.stop(self._rainmachine_entity_id)
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))
async def async_turn_on(self, **kwargs) -> None: async def async_turn_on(self, **kwargs) -> None:
"""Turn the zone on.""" """Turn the zone on."""
await self._async_run_switch_coroutine(
try: self.rainmachine.client.zones.start(
await self.rainmachine.client.zones.start( self._rainmachine_entity_id, self.rainmachine.default_zone_runtime
self._rainmachine_entity_id, self._run_time )
) )
except RequestError as err:
_LOGGER.error('Unable to turn on zone "%s": %s', self.unique_id, str(err))
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update info for the zone.""" """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._is_on = bool(self._switch_data["state"])
self._obj = await self.rainmachine.client.zones.get(
self._rainmachine_entity_id
)
self._properties_json = await self.rainmachine.client.zones.get(
self._rainmachine_entity_id, details=True
)
self._attrs.update( self._attrs.update(
{ {
ATTR_ID: self._obj["uid"], ATTR_ID: self._switch_data["uid"],
ATTR_AREA: self._properties_json.get("waterSense").get("area"), ATTR_AREA: details.get("waterSense").get("area"),
ATTR_CURRENT_CYCLE: self._obj.get("cycle"), ATTR_CURRENT_CYCLE: self._switch_data.get("cycle"),
ATTR_FIELD_CAPACITY: self._properties_json.get("waterSense").get( ATTR_FIELD_CAPACITY: details.get("waterSense").get("fieldCapacity"),
"fieldCapacity" ATTR_NO_CYCLES: self._switch_data.get("noOfCycles"),
), ATTR_PRECIP_RATE: details.get("waterSense").get("precipitationRate"),
ATTR_NO_CYCLES: self._obj.get("noOfCycles"), ATTR_RESTRICTIONS: self._switch_data.get("restriction"),
ATTR_PRECIP_RATE: self._properties_json.get("waterSense").get( ATTR_SLOPE: SLOPE_TYPE_MAP.get(details.get("slope")),
"precipitationRate" ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(details.get("sun")),
), ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(details.get("group_id")),
ATTR_RESTRICTIONS: self._obj.get("restriction"), ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(details.get("sun")),
ATTR_SLOPE: SLOPE_TYPE_MAP.get(self._properties_json.get("slope")), ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(self._switch_data.get("type")),
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)
)