Skip to content

Commit

Permalink
feat: Safety disable if sensor unavailable or not updated
Browse files Browse the repository at this point in the history
Fixes #168
  • Loading branch information
= authored and swingerman committed May 16, 2024
1 parent 31d5909 commit 3555db8
Show file tree
Hide file tree
Showing 11 changed files with 416 additions and 12 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ The internal values can be set by the component only and the external values can
| `opening` | The thermostat is idle because an opening is open |
| `limit` | The thermostat is idle because the floor temperature is at the limit |
| `overheat` | The thermostat is idle because the floor temperature is too high |
| `TEMPERATURE_SENSOR_TIMED_OUT` | The thermostat is idle because the temperature sensor is not provided data for the defined time that could indicate a malfunctioning sensor |

#### HVAC Action Reason External values

Expand Down Expand Up @@ -328,6 +329,10 @@ The internal values can be set by the component only and the external values can

_(required) (string)_ "`entity_id` for a temperature sensor, target_sensor.state must be temperature."

### target_sensor_safety_delay

_(optional) (timedelta)_ Set a delay for the target sensor to be considered valid. If the sensor is not available for the specified time the thermostat will be turned off.

### floor_sensor

_(optional) (string)_ "`entity_id` for the floor temperature sensor, floor_sensor.state must be temperature."
Expand Down
4 changes: 4 additions & 0 deletions custom_components/dual_smart_thermostat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
CONF_PRESETS,
CONF_PRESETS_OLD,
CONF_SENSOR,
CONF_SENSOR_SAFETY_DELAY,
CONF_TARGET_TEMP,
CONF_TARGET_TEMP_HIGH,
CONF_TARGET_TEMP_LOW,
Expand Down Expand Up @@ -162,6 +163,9 @@
vol.Required(CONF_HEATER): cv.entity_id,
vol.Optional(CONF_COOLER): cv.entity_id,
vol.Required(CONF_SENSOR): cv.entity_id,
vol.Optional(CONF_SENSOR_SAFETY_DELAY): vol.All(
cv.time_period, cv.positive_timedelta
),
vol.Optional(CONF_AC_MODE): cv.boolean,
vol.Optional(CONF_HEAT_COOL_MODE): cv.boolean,
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
Expand Down
1 change: 1 addition & 0 deletions custom_components/dual_smart_thermostat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
CONF_FAN_ON_WITH_AC = "fan_on_with_ac"
CONF_FAN_HOT_TOLERANCE = "fan_hot_tolerance"
CONF_SENSOR = "target_sensor"
CONF_SENSOR_SAFETY_DELAY = "target_sensor_safety_delay"
CONF_FLOOR_SENSOR = "floor_sensor"
CONF_MIN_TEMP = "min_temp"
CONF_MAX_TEMP = "max_temp"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ class HVACActionReasonInternal(enum.StrEnum):
LIMIT = "limit"

OVERHEAT = "overheat"

TEMPERATURE_SENSOR_TIMED_OUT = "temperature_sensor_timed_out"
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ def hvac_action(self) -> HVACAction:
async def async_control_hvac(self, time=None, force=False):
_LOGGER.debug({self.__class__.__name__})
_LOGGER.debug("async_control_hvac")
_LOGGER.debug(
"sensor safety timed out: %s", self.temperatures.is_sensor_safety_timed_out
)
self._set_self_active()

if not self._needs_control(time, force):
Expand All @@ -84,11 +87,19 @@ async def _async_control_device_when_on(self, time=None) -> None:
too_hot = self.temperatures.is_too_hot(self.target_temp_attr)
is_floor_hot = self.temperatures.is_floor_hot
is_floor_cold = self.temperatures.is_floor_cold
is_sensor_safety_timed_out = self.temperatures.is_sensor_safety_timed_out
any_opening_open = self.openings.any_opening_open(self.hvac_mode)

_LOGGER.debug("_async_control_device_when_on, floor cold: %s", is_floor_cold)
_LOGGER.debug("_async_control_device_when_on, too_hot: %s", too_hot)
_LOGGER.debug(
"is sensor safety timed out: %s",
self.temperatures.is_sensor_safety_timed_out,
)

if ((too_hot or is_floor_hot) or any_opening_open) and not is_floor_cold:
if (
(too_hot or is_floor_hot) or any_opening_open or is_sensor_safety_timed_out
) and not is_floor_cold:
_LOGGER.debug("Turning off heater %s", self.entity_id)

await self.async_turn_off()
Expand All @@ -99,8 +110,15 @@ async def _async_control_device_when_on(self, time=None) -> None:
self._hvac_action_reason = HVACActionReason.OVERHEAT
if any_opening_open:
self._hvac_action_reason = HVACActionReason.OPENING

elif time is not None and not any_opening_open and not is_floor_hot:
if is_sensor_safety_timed_out:
self._hvac_action_reason = HVACActionReason.TEMPERATURE_SENSOR_TIMED_OUT

elif (
time is not None
and not any_opening_open
and not is_floor_hot
and not is_sensor_safety_timed_out
):
# The time argument is passed only in keep-alive case
_LOGGER.info(
"Keep-alive - Turning on heater (from active) %s",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def target_temp_attr(self) -> str:

@property
def is_active(self) -> bool:
"""If the toggleable cooler device is currently active."""
"""If the toggleable hvac device is currently active."""
if self.entity_id is not None and self.hass.states.is_state(
self.entity_id, STATE_ON
):
Expand Down Expand Up @@ -189,14 +189,12 @@ async def async_control_hvac(self, time=None, force=False):
if not self._needs_control(time, force):
return

any_opening_open = self.openings.any_opening_open(self.hvac_mode)

_LOGGER.info(
_LOGGER.debug(
"%s - async_control_hvac - is device active: %s, %s, is opening open: %s",
self._device_type,
self.entity_id,
self.is_active,
any_opening_open,
self.openings.any_opening_open(self.hvac_mode),
)

if self.is_active:
Expand All @@ -208,16 +206,21 @@ async def _async_control_when_active(self, time=None) -> None:
_LOGGER.debug("%s _async_control_when_active", self.__class__.__name__)
too_cold = self.temperatures.is_too_cold(self.target_temp_attr)
any_opening_open = self.openings.any_opening_open(self.hvac_mode)
is_sensor_safety_timed_out = self.temperatures.is_sensor_safety_timed_out

if too_cold or any_opening_open:
if too_cold or any_opening_open or is_sensor_safety_timed_out:
_LOGGER.debug("Turning off entity %s", self.entity_id)
await self.async_turn_off()
if too_cold:
self._hvac_action_reason = HVACActionReason.TARGET_TEMP_REACHED
if any_opening_open:
self._hvac_action_reason = HVACActionReason.OPENING
if is_sensor_safety_timed_out:
self._hvac_action_reason = HVACActionReason.TEMPERATURE_SENSOR_TIMED_OUT

elif time is not None and not any_opening_open:
elif (
time is not None and not any_opening_open and not is_sensor_safety_timed_out
):
# The time argument is passed only in keep-alive case
_LOGGER.debug(
"Keep-alive - Turning on entity (from active) %s",
Expand All @@ -234,6 +237,10 @@ async def _async_control_when_inactive(self, time=None) -> None:
_LOGGER.debug("any_opening_open: %s", any_opening_open)
_LOGGER.debug("is_active: %s", self.is_active)
_LOGGER.debug("time: %s", time)
_LOGGER.debug(
"is sensor safety timed out: %s",
self.temperatures.is_sensor_safety_timed_out,
)

if too_hot and not any_opening_open:
_LOGGER.debug("Turning on entity (from inactive) %s", self.entity_id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@
DEFAULT_MIN_TEMP,
)
from homeassistant.components.climate.const import HVACMode
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_WHOLE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import condition
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_conversion import TemperatureConverter

from custom_components.dual_smart_thermostat.const import (
Expand All @@ -28,6 +36,7 @@
CONF_MIN_TEMP,
CONF_PRECISION,
CONF_SENSOR,
CONF_SENSOR_SAFETY_DELAY,
CONF_TARGET_TEMP,
CONF_TARGET_TEMP_HIGH,
CONF_TARGET_TEMP_LOW,
Expand Down Expand Up @@ -61,6 +70,7 @@ def __init__(
self.hass = hass
self._sensor_floor = config.get(CONF_FLOOR_SENSOR)
self._sensor = config.get(CONF_SENSOR)
self._sensor_safety_delay = config.get(CONF_SENSOR_SAFETY_DELAY)

self._min_temp = config.get(CONF_MIN_TEMP)
self._max_temp = config.get(CONF_MAX_TEMP)
Expand Down Expand Up @@ -302,6 +312,48 @@ def is_floor_cold(self) -> bool:
return True
return False

@property
def is_sensor_safety_timed_out(self) -> bool:
"""If the sensor safety delay has timed out."""
sensor_state = self.hass.states.get(self._sensor)

if self._sensor_safety_delay is None or sensor_state is None:
return False

"""Checks when the sensor was last updated. If the sensor has not been
updated in the last sensor_safety_delay seconds, the sensor is considered
timed out."""

time_diff = dt_util.utcnow() - sensor_state.last_changed
is_value_state = sensor_state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
timed_out_temp = is_value_state and (
time_diff.total_seconds() > self._sensor_safety_delay.total_seconds()
)

_LOGGER.debug("time diff: %s, delay: %s", time_diff, self._sensor_safety_delay)
_LOGGER.debug(
"timed out temp: %s, dif_seconds: %s, delay_seconds: %s",
timed_out_temp,
time_diff.total_seconds(),
self._sensor_safety_delay.total_seconds(),
)

return (
timed_out_temp
or condition.state(
self.hass,
self._sensor,
STATE_UNKNOWN,
self._sensor_safety_delay,
)
or condition.state(
self.hass,
self._sensor,
STATE_UNAVAILABLE,
self._sensor_safety_delay,
)
)

@callback
def update_temp_from_state(self, state: State) -> None:
"""Update thermostat with latest state from sensor."""
Expand Down
73 changes: 73 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,29 @@ async def setup_comp_heat(hass: HomeAssistant) -> None:
await hass.async_block_till_done()


@pytest.fixture
async def setup_comp_heat_safety_delay(hass: HomeAssistant) -> None:
"""Initialize components."""
hass.config.units = METRIC_SYSTEM
assert await async_setup_component(
hass,
CLIMATE,
{
"climate": {
"platform": DOMAIN,
"name": "test",
"cold_tolerance": 2,
"hot_tolerance": 4,
"heater": common.ENT_SWITCH,
"target_sensor": common.ENT_SENSOR,
"target_sensor_safety_delay": datetime.timedelta(minutes=2),
"initial_hvac_mode": HVACMode.HEAT,
}
},
)
await hass.async_block_till_done()


@pytest.fixture
async def setup_comp_heat_floor_sensor(hass: HomeAssistant) -> None:
"""Initialize components."""
Expand Down Expand Up @@ -180,6 +203,31 @@ async def setup_comp_heat_ac_cool(hass: HomeAssistant) -> None:
await hass.async_block_till_done()


@pytest.fixture
async def setup_comp_heat_ac_cool_safety_delay(hass: HomeAssistant) -> None:
"""Initialize components."""
hass.config.units = METRIC_SYSTEM
assert await async_setup_component(
hass,
CLIMATE,
{
"climate": {
"platform": DOMAIN,
"name": "test",
"cold_tolerance": 2,
"hot_tolerance": 4,
"ac_mode": True,
"heater": common.ENT_SWITCH,
"target_sensor": common.ENT_SENSOR,
"target_sensor_safety_delay": datetime.timedelta(minutes=2),
"initial_hvac_mode": HVACMode.COOL,
PRESET_AWAY: {"temperature": 30},
}
},
)
await hass.async_block_till_done()


@pytest.fixture
async def setup_comp_fan_only_config(hass: HomeAssistant) -> None:
"""Initialize components."""
Expand Down Expand Up @@ -784,6 +832,31 @@ async def setup_comp_heat_cool_presets(hass: HomeAssistant) -> None:
await hass.async_block_till_done()


@pytest.fixture
async def setup_comp_heat_cool_safety_delay(hass: HomeAssistant) -> None:
"""Initialize components."""
hass.config.units = METRIC_SYSTEM
assert await async_setup_component(
hass,
CLIMATE,
{
"climate": {
"platform": DOMAIN,
"name": "test",
"cold_tolerance": 2,
"hot_tolerance": 4,
"heat_cool_mode": True,
"heater": common.ENT_SWITCH,
"cooler": common.ENT_COOLER,
"target_sensor": common.ENT_SENSOR,
"target_sensor_safety_delay": datetime.timedelta(minutes=2),
"initial_hvac_mode": HVACMode.HEAT_COOL,
}
},
)
await hass.async_block_till_done()


@pytest.fixture
async def setup_comp_heat_cool_fan_presets(hass: HomeAssistant) -> None:
"""Initialize components."""
Expand Down
Loading

0 comments on commit 3555db8

Please sign in to comment.