diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 1c850915e26..fb404adb199 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -20,7 +20,7 @@ from .const import ( DATA_RESTRICTIONS_UNIVERSAL, DOMAIN, ) -from .model import RainMachineSensorDescriptionMixin +from .model import RainMachineDescriptionMixinApiCategory TYPE_FLOW_SENSOR = "flow_sensor" TYPE_FREEZE = "freeze" @@ -35,7 +35,7 @@ TYPE_WEEKDAY = "weekday" @dataclass class RainMachineBinarySensorDescription( - BinarySensorEntityDescription, RainMachineSensorDescriptionMixin + BinarySensorEntityDescription, RainMachineDescriptionMixinApiCategory ): """Describe a RainMachine binary sensor.""" @@ -119,7 +119,7 @@ async def async_setup_entry( coordinators = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] @callback - def async_get_sensor(api_category: str) -> partial: + def async_get_sensor_by_api_category(api_category: str) -> partial: """Generate the appropriate sensor object for an API category.""" if api_category == DATA_PROVISION_SETTINGS: return partial( @@ -143,7 +143,9 @@ async def async_setup_entry( async_add_entities( [ - async_get_sensor(description.api_category)(controller, description) + async_get_sensor_by_api_category(description.api_category)( + controller, description + ) for description in BINARY_SENSOR_DESCRIPTIONS ] ) diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py index cd66c05025b..9f638d486aa 100644 --- a/homeassistant/components/rainmachine/model.py +++ b/homeassistant/components/rainmachine/model.py @@ -3,7 +3,14 @@ from dataclasses import dataclass @dataclass -class RainMachineSensorDescriptionMixin: +class RainMachineDescriptionMixinApiCategory: """Define an entity description mixin for binary and regular sensors.""" api_category: str + + +@dataclass +class RainMachineDescriptionMixinUid: + """Define an entity description mixin for switches.""" + + uid: int diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 2db16ec9058..bdb1377dace 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime, timedelta from functools import partial from homeassistant.components.sensor import ( @@ -15,6 +16,7 @@ from homeassistant.const import TEMP_CELSIUS, VOLUME_CUBIC_METERS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import utcnow from . import RainMachineEntity from .const import ( @@ -22,26 +24,49 @@ from .const import ( DATA_COORDINATOR, DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_UNIVERSAL, + DATA_ZONES, DOMAIN, ) -from .model import RainMachineSensorDescriptionMixin +from .model import ( + RainMachineDescriptionMixinApiCategory, + RainMachineDescriptionMixinUid, +) + +DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE = timedelta(seconds=5) TYPE_FLOW_SENSOR_CLICK_M3 = "flow_sensor_clicks_cubic_meter" TYPE_FLOW_SENSOR_CONSUMED_LITERS = "flow_sensor_consumed_liters" TYPE_FLOW_SENSOR_START_INDEX = "flow_sensor_start_index" TYPE_FLOW_SENSOR_WATERING_CLICKS = "flow_sensor_watering_clicks" TYPE_FREEZE_TEMP = "freeze_protect_temp" +TYPE_ZONE_RUN_COMPLETION_TIME = "zone_run_completion_time" + +ZONE_STATE_NOT_RUNNING = "not_running" +ZONE_STATE_PENDING = "pending" +ZONE_STATE_RUNNING = "running" +ZONE_STATE_MAP = { + 0: ZONE_STATE_NOT_RUNNING, + 1: ZONE_STATE_RUNNING, + 2: ZONE_STATE_PENDING, +} @dataclass -class RainMachineSensorEntityDescription( - SensorEntityDescription, RainMachineSensorDescriptionMixin +class RainMachineSensorDescriptionApiCategory( + SensorEntityDescription, RainMachineDescriptionMixinApiCategory +): + """Describe a RainMachine sensor.""" + + +@dataclass +class RainMachineSensorDescriptionUid( + SensorEntityDescription, RainMachineDescriptionMixinUid ): """Describe a RainMachine sensor.""" SENSOR_DESCRIPTIONS = ( - RainMachineSensorEntityDescription( + RainMachineSensorDescriptionApiCategory( key=TYPE_FLOW_SENSOR_CLICK_M3, name="Flow Sensor Clicks per Cubic Meter", icon="mdi:water-pump", @@ -51,7 +76,7 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, ), - RainMachineSensorEntityDescription( + RainMachineSensorDescriptionApiCategory( key=TYPE_FLOW_SENSOR_CONSUMED_LITERS, name="Flow Sensor Consumed Liters", icon="mdi:water-pump", @@ -61,7 +86,7 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.TOTAL_INCREASING, api_category=DATA_PROVISION_SETTINGS, ), - RainMachineSensorEntityDescription( + RainMachineSensorDescriptionApiCategory( key=TYPE_FLOW_SENSOR_START_INDEX, name="Flow Sensor Start Index", icon="mdi:water-pump", @@ -70,7 +95,7 @@ SENSOR_DESCRIPTIONS = ( entity_registry_enabled_default=False, api_category=DATA_PROVISION_SETTINGS, ), - RainMachineSensorEntityDescription( + RainMachineSensorDescriptionApiCategory( key=TYPE_FLOW_SENSOR_WATERING_CLICKS, name="Flow Sensor Clicks", icon="mdi:water-pump", @@ -80,7 +105,7 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, ), - RainMachineSensorEntityDescription( + RainMachineSensorDescriptionApiCategory( key=TYPE_FREEZE_TEMP, name="Freeze Protect Temperature", icon="mdi:thermometer", @@ -101,7 +126,7 @@ async def async_setup_entry( coordinators = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] @callback - def async_get_sensor(api_category: str) -> partial: + def async_get_sensor_by_api_category(api_category: str) -> partial: """Generate the appropriate sensor object for an API category.""" if api_category == DATA_PROVISION_SETTINGS: return partial( @@ -116,12 +141,31 @@ async def async_setup_entry( coordinators[DATA_RESTRICTIONS_UNIVERSAL], ) - async_add_entities( - [ - async_get_sensor(description.api_category)(controller, description) - for description in SENSOR_DESCRIPTIONS - ] - ) + sensors = [ + async_get_sensor_by_api_category(description.api_category)( + controller, description + ) + for description in SENSOR_DESCRIPTIONS + ] + + zone_coordinator = coordinators[DATA_ZONES] + for uid, zone in zone_coordinator.data.items(): + sensors.append( + ZoneTimeRemainingSensor( + entry, + zone_coordinator, + controller, + RainMachineSensorDescriptionUid( + key=f"{TYPE_ZONE_RUN_COMPLETION_TIME}_{uid}", + name=f"{zone['name']} Run Completion Time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + uid=uid, + ), + ) + ) + + async_add_entities(sensors) class ProvisionSettingsSensor(RainMachineEntity, SensorEntity): @@ -162,3 +206,32 @@ class UniversalRestrictionsSensor(RainMachineEntity, SensorEntity): """Update the state.""" if self.entity_description.key == TYPE_FREEZE_TEMP: self._attr_native_value = self.coordinator.data["freezeProtectTemp"] + + +class ZoneTimeRemainingSensor(RainMachineEntity, SensorEntity): + """Define a sensor that shows the amount of time remaining for a zone.""" + + entity_description: RainMachineSensorDescriptionUid + + @callback + def update_from_latest_data(self) -> None: + """Update the state.""" + data = self.coordinator.data[self.entity_description.uid] + now = utcnow() + + if ZONE_STATE_MAP.get(data["state"]) != ZONE_STATE_RUNNING: + # If the zone isn't actively running, return immediately: + return + + new_timestamp = now + timedelta(seconds=data["remaining"]) + + if self._attr_native_value: + assert isinstance(self._attr_native_value, datetime) + if ( + new_timestamp - self._attr_native_value + ) < DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE: + # If the deviation between the previous and new timestamps is less than + # a "wobble tolerance," don't spam the state machine: + return + + self._attr_native_value = new_timestamp diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 523e4f94640..8a361945f8e 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -31,6 +31,7 @@ from .const import ( DEFAULT_ZONE_RUN, DOMAIN, ) +from .model import RainMachineDescriptionMixinUid ATTR_AREA = "area" ATTR_CS_ON = "cs_on" @@ -49,7 +50,6 @@ ATTR_SOIL_TYPE = "soil_type" ATTR_SPRINKLER_TYPE = "sprinkler_head_type" ATTR_STATUS = "status" ATTR_SUN_EXPOSURE = "sun_exposure" -ATTR_TIME_REMAINING = "time_remaining" ATTR_VEGETATION_TYPE = "vegetation_type" ATTR_ZONES = "zones" @@ -109,16 +109,9 @@ VEGETATION_MAP = { } -@dataclass -class RainMachineSwitchDescriptionMixin: - """Define an entity description mixin for switches.""" - - uid: int - - @dataclass class RainMachineSwitchDescription( - SwitchEntityDescription, RainMachineSwitchDescriptionMixin + SwitchEntityDescription, RainMachineDescriptionMixinUid ): """Describe a RainMachine switch.""" @@ -411,7 +404,6 @@ class RainMachineZone(RainMachineActivitySwitch): ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data.get("group_id")), ATTR_STATUS: RUN_STATUS_MAP[data["state"]], ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(data.get("sun")), - ATTR_TIME_REMAINING: data.get("remaining"), ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(data.get("type")), } )