From 56868e3824ecf67924194d327013c53575bab09f Mon Sep 17 00:00:00 2001 From: basbruss <68892092+basbruss@users.noreply.github.com> Date: Thu, 21 Mar 2024 13:00:53 +0100 Subject: [PATCH] Add outdoor temp control --- .../adaptive_cover/calculation.py | 46 +++++++++++++------ .../adaptive_cover/config_flow.py | 31 +++++++++++-- custom_components/adaptive_cover/const.py | 1 + .../adaptive_cover/coordinator.py | 32 ++++++++++--- custom_components/adaptive_cover/helpers.py | 14 +----- custom_components/adaptive_cover/sensor.py | 4 +- custom_components/adaptive_cover/strings.json | 8 +++- custom_components/adaptive_cover/switch.py | 33 ++++++++++--- .../adaptive_cover/translations/en.json | 12 +++-- .../adaptive_cover/translations/nl.json | 12 +++-- 10 files changed, 139 insertions(+), 54 deletions(-) diff --git a/custom_components/adaptive_cover/calculation.py b/custom_components/adaptive_cover/calculation.py index 94cd416..617ffce 100644 --- a/custom_components/adaptive_cover/calculation.py +++ b/custom_components/adaptive_cover/calculation.py @@ -7,10 +7,11 @@ import numpy as np import pandas as pd from homeassistant.core import HomeAssistant +from homeassistant.helpers.template import state_attr from numpy import cos, sin, tan from numpy import radians as rad -from .helpers import get_domain, get_safe_attribute, get_safe_state +from .helpers import get_domain, get_safe_state from .sun import SunData @@ -147,20 +148,39 @@ class ClimateCoverData: presence_entity: str weather_entity: str weather_condition: list[str] + outside_entity: str + temp_switch: bool blind_type: str @property - def current_temperature(self) -> float: - """Get current temp from entity.""" + def outside_temperature(self): + """Get outside temperature.""" + temp = None + if self.weather_entity: + temp = state_attr(self.hass, self.weather_entity, "temperature") + if self.outside_entity: + temp = get_safe_state(self.outside_entity, self.hass) + return temp + + @property + def inside_temperature(self): + """Get inside temp from entity.""" if self.temp_entity is not None: if get_domain(self.temp_entity) != "climate": - temp = get_safe_state(self.hass, self.temp_entity) + temp = get_safe_state(self.temp_entity, self.hass) else: - temp = get_safe_attribute( - self.hass, self.temp_entity, "current_temperature" - ) + temp = state_attr(self.hass, self.temp_entity, "current_temperature") return temp + @property + def get_current_temperature(self) -> float: + """Get temperature.""" + if self.temp_switch: + if self.outside_temperature: + return float(self.outside_temperature) + if self.inside_temperature: + return float(self.inside_temperature) + @property def is_presence(self): """Checks if people are present.""" @@ -181,15 +201,15 @@ def is_presence(self): @property def is_winter(self) -> bool: """Check if temperature is below threshold.""" - if self.temp_low is not None and self.current_temperature is not None: - return float(self.current_temperature) < self.temp_low + if self.temp_low is not None and self.get_current_temperature is not None: + return self.get_current_temperature < self.temp_low return False @property def is_summer(self) -> bool: """Check if temperature is over threshold.""" - if self.temp_high is not None and self.current_temperature is not None: - return float(self.current_temperature) > self.temp_high + if self.temp_high is not None and self.get_current_temperature is not None: + return self.get_current_temperature > self.temp_high return False @property @@ -197,7 +217,7 @@ def is_sunny(self) -> bool: """Check if condition can contain radiation in winter.""" weather_state = None if self.weather_entity is not None: - weather_state = get_safe_state(self.hass, self.weather_entity) + weather_state = get_safe_state(self.weather_entity, self.hass) if self.weather_condition is not None: return weather_state in self.weather_condition return True @@ -272,7 +292,7 @@ def control_method_tilt_bi(self): def tilt_state(self): """Add tilt specific controls.""" if ( - self.climate_data.current_temperature is not None + self.climate_data.get_current_temperature is not None and self.cover.sol_elev > 0 ): if self.cover.mode == "mode1": diff --git a/custom_components/adaptive_cover/config_flow.py b/custom_components/adaptive_cover/config_flow.py index b2e16f6..6eedc72 100644 --- a/custom_components/adaptive_cover/config_flow.py +++ b/custom_components/adaptive_cover/config_flow.py @@ -30,6 +30,7 @@ CONF_LENGTH_AWNING, CONF_MAX_POSITION, CONF_MODE, + CONF_OUTSIDETEMP_ENTITY, CONF_PRESENCE_ENTITY, CONF_SENSOR_TYPE, CONF_SUNSET_OFFSET, @@ -148,21 +149,30 @@ CLIMATE_OPTIONS = vol.Schema( { + vol.Required(CONF_TEMP_ENTITY): selector.EntitySelector( + selector.EntityFilterSelectorConfig(domain=["climate", "sensor"]) + ), vol.Required(CONF_TEMP_LOW, default=21): selector.NumberSelector( selector.NumberSelectorConfig(min=0, max=86, step=1, mode="slider") ), vol.Required(CONF_TEMP_HIGH, default=25): selector.NumberSelector( selector.NumberSelectorConfig(min=0, max=90, step=1, mode="slider") ), - vol.Required(CONF_TEMP_ENTITY): selector.EntitySelector( - selector.EntityFilterSelectorConfig(domain=["climate", "sensor"]) + vol.Optional( + CONF_OUTSIDETEMP_ENTITY, default=vol.UNDEFINED + ): selector.EntitySelector( + selector.EntityFilterSelectorConfig(domain=["sensor"]) ), - vol.Optional(CONF_PRESENCE_ENTITY): selector.EntitySelector( + vol.Optional( + CONF_PRESENCE_ENTITY, default=vol.UNDEFINED + ): selector.EntitySelector( selector.EntityFilterSelectorConfig( domain=["device_tracker", "zone", "binary_sensor", "input_boolean"] ) ), - vol.Optional(CONF_WEATHER_ENTITY): selector.EntitySelector( + vol.Optional( + CONF_WEATHER_ENTITY, default=vol.UNDEFINED + ): selector.EntitySelector( selector.EntityFilterSelectorConfig(domain="weather") ), } @@ -319,6 +329,7 @@ async def async_step_update(self, user_input: dict[str, Any] | None = None): CONF_WEATHER_ENTITY: self.config.get(CONF_WEATHER_ENTITY), CONF_TEMP_LOW: self.config.get(CONF_TEMP_LOW), CONF_TEMP_HIGH: self.config.get(CONF_TEMP_HIGH), + CONF_OUTSIDETEMP_ENTITY: self.config.get(CONF_OUTSIDETEMP_ENTITY), CONF_CLIMATE_MODE: self.config.get(CONF_CLIMATE_MODE), CONF_WEATHER_STATE: self.config.get(CONF_WEATHER_STATE), }, @@ -406,6 +417,12 @@ async def async_step_tilt(self, user_input: dict[str, Any] | None = None): async def async_step_climate(self, user_input: dict[str, Any] | None = None): """Manage climate options.""" if user_input is not None: + entities = [ + CONF_OUTSIDETEMP_ENTITY, + CONF_WEATHER_ENTITY, + CONF_PRESENCE_ENTITY, + ] + self.optional_entities(entities, user_input) self.options.update(user_input) if self.options.get(CONF_WEATHER_ENTITY): return await self.async_step_weather() @@ -432,3 +449,9 @@ async def async_step_weather(self, user_input: dict[str, Any] | None = None): async def _update_options(self) -> FlowResult: """Update config entry options.""" return self.async_create_entry(title="", data=self.options) + + def optional_entities(self, keys: list, user_input: dict[str, Any] | None = None): + """Set value to None if key does not exist.""" + for key in keys: + if key not in user_input: + user_input[key] = None diff --git a/custom_components/adaptive_cover/const.py b/custom_components/adaptive_cover/const.py index 07dd058..5cd37a7 100644 --- a/custom_components/adaptive_cover/const.py +++ b/custom_components/adaptive_cover/const.py @@ -34,6 +34,7 @@ CONF_CLIMATE_MODE = "climate_mode" CONF_WEATHER_STATE = "weather_state" CONF_MAX_POSITION = "max_position" +CONF_OUTSIDETEMP_ENTITY = "outside_temp" STRATEGY_MODE_BASIC = "basic" STRATEGY_MODE_CLIMATE = "climate" diff --git a/custom_components/adaptive_cover/coordinator.py b/custom_components/adaptive_cover/coordinator.py index b338751..f0fcf43 100644 --- a/custom_components/adaptive_cover/coordinator.py +++ b/custom_components/adaptive_cover/coordinator.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.template import state_attr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .calculation import ( @@ -29,6 +30,7 @@ CONF_INVERSE_STATE, CONF_LENGTH_AWNING, CONF_MAX_POSITION, + CONF_OUTSIDETEMP_ENTITY, CONF_PRESENCE_ENTITY, CONF_SUNSET_OFFSET, CONF_SUNSET_POS, @@ -43,7 +45,6 @@ DOMAIN, LOGGER, ) -from .helpers import get_safe_attribute @dataclass @@ -76,6 +77,7 @@ def __init__(self, hass: HomeAssistant) -> None: # noqa: D107 self._climate_mode = self.config_entry.options.get(CONF_CLIMATE_MODE, False) self._switch_mode = True if self._climate_mode else False self._inverse_state = self.config_entry.options.get(CONF_INVERSE_STATE, False) + self._temp_toggle = False async def async_check_entity_state_change( self, entity: str, old_state: State | None, new_state: State | None @@ -86,8 +88,8 @@ async def async_check_entity_state_change( async def _async_update_data(self) -> AdaptiveCoverData: pos_sun = [ - get_safe_attribute(self.hass, "sun.sun", "azimuth"), - get_safe_attribute(self.hass, "sun.sun", "elevation"), + state_attr(self.hass, "sun.sun", "azimuth"), + state_attr(self.hass, "sun.sun", "elevation"), ] common_data = [ @@ -142,6 +144,8 @@ async def _async_update_data(self) -> AdaptiveCoverData: self.config_entry.options.get(CONF_PRESENCE_ENTITY), self.config_entry.options.get(CONF_WEATHER_ENTITY), self.config_entry.options.get(CONF_WEATHER_STATE), + self.config_entry.options.get(CONF_OUTSIDETEMP_ENTITY), + self._temp_toggle, self._cover_type, ] climate = ClimateCoverData(*climate_data_var) @@ -154,16 +158,19 @@ async def _async_update_data(self) -> AdaptiveCoverData: default_state = round(NormalCoverState(cover_data).get_state()) + state = default_state + if self._switch_mode: + state = climate_state + if self._inverse_state: - default_state = 100 - default_state - if self._climate_mode: - climate_state = 100 - climate_state + state = 100 - state return AdaptiveCoverData( climate_mode_toggle=self.switch_mode, states={ "normal": default_state, "climate": climate_state, + "state": state, "start": NormalCoverState(cover_data).cover.solar_times()[0], "end": NormalCoverState(cover_data).cover.solar_times()[1], "control": control_method, @@ -180,6 +187,10 @@ async def _async_update_data(self) -> AdaptiveCoverData: ], "entity_id": self.config_entry.options.get(CONF_ENTITIES), "cover_type": self._cover_type, + "outside": self.config_entry.options.get(CONF_OUTSIDETEMP_ENTITY), + "outside_temp": climate_data.outside_temperature, + "current_temp": climate_data.get_current_temperature, + "toggle": climate_data.temp_switch, }, ) @@ -191,3 +202,12 @@ def switch_mode(self): @switch_mode.setter def switch_mode(self, value): self._switch_mode = value + + @property + def temp_toggle(self): + """Let switch toggle climate mode.""" + return self._temp_toggle + + @temp_toggle.setter + def temp_toggle(self, value): + self._temp_toggle = value diff --git a/custom_components/adaptive_cover/helpers.py b/custom_components/adaptive_cover/helpers.py index ddc5e3b..cd50ea5 100644 --- a/custom_components/adaptive_cover/helpers.py +++ b/custom_components/adaptive_cover/helpers.py @@ -1,9 +1,9 @@ """Helper functions.""" -from homeassistant.core import split_entity_id +from homeassistant.core import HomeAssistant, split_entity_id -def get_safe_state(hass, entity_id: str): +def get_safe_state(entity_id: str, hass: HomeAssistant): """Get a safe state value if not available.""" state = hass.states.get(entity_id) if not state or state.state in ["unknown", "unavailable"]: @@ -11,16 +11,6 @@ def get_safe_state(hass, entity_id: str): return state.state -def get_safe_attribute(hass, entity_id: str, attribute: str): - """Get a safe value from attribute.""" - if not get_safe_state(hass, entity_id): - return None - attr_obj = hass.states.get(entity_id).attributes - if attribute not in attr_obj: - return None - return attr_obj[attribute] - - def get_domain(entity: str): """Get domain of entity.""" if entity is not None: diff --git a/custom_components/adaptive_cover/sensor.py b/custom_components/adaptive_cover/sensor.py index 56a5ba0..c2da73b 100644 --- a/custom_components/adaptive_cover/sensor.py +++ b/custom_components/adaptive_cover/sensor.py @@ -116,9 +116,7 @@ def name(self): @property def native_value(self) -> str | None: """Handle when entity is added.""" - if self.data.climate_mode_toggle: - return self.data.states["climate"] - return self.data.states["normal"] + return self.data.states["state"] @property def device_info(self) -> DeviceInfo: diff --git a/custom_components/adaptive_cover/strings.json b/custom_components/adaptive_cover/strings.json index 665a063..f7528f4 100644 --- a/custom_components/adaptive_cover/strings.json +++ b/custom_components/adaptive_cover/strings.json @@ -45,12 +45,14 @@ "temp_entity": "Inside temperature entity", "presence_entity": "Presence entity", "weather_entity": "Weather entity (optional)", + "outside_temp": "Outside temperature sensor", "temp_low": "Low temperature threshold", "temp_high": "High temperature threshold" }, "data_description":{ "presence_entity": "Entity to represent the presence status in the room or home", - "weather_entity": "Checks for weather conditions", + "weather_entity": "Checks for weather conditions and can be used for the outside temperature", + "outside_temp":"This entity overrides the outside temperature from the weather entity if both are set", "temp_low": "The minimum comfort temperature", "temp_high": "The maximum comfort temperature" }, @@ -177,12 +179,14 @@ "temp_entity": "Inside temperature entity", "presence_entity": "Presence entity", "weather_entity": "Weather entity (optional)", + "outside_temp": "Outside temperature sensor", "temp_low": "Low temperature threshold", "temp_high": "High temperature threshold" }, "data_description":{ "presence_entity": "Entity to represent the presence status in the room or home", - "weather_entity": "Checks for weather conditions", + "weather_entity": "Checks for weather conditions and can be used for the outside temperature", + "outside_temp":"This entity overrides the outside temperature from the weather entity if both are set", "temp_low": "The minimum comfort temperature", "temp_high": "The maximum comfort temperature" }, diff --git a/custom_components/adaptive_cover/switch.py b/custom_components/adaptive_cover/switch.py index 86734d6..9fa221b 100644 --- a/custom_components/adaptive_cover/switch.py +++ b/custom_components/adaptive_cover/switch.py @@ -11,7 +11,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_CLIMATE_MODE, CONF_SENSOR_TYPE, DOMAIN +from .const import ( + CONF_CLIMATE_MODE, + CONF_OUTSIDETEMP_ENTITY, + CONF_SENSOR_TYPE, + CONF_WEATHER_ENTITY, + DOMAIN, +) from .coordinator import AdaptiveDataUpdateCoordinator @@ -31,13 +37,28 @@ async def async_setup_entry( "Climate Mode", True, "mdi:home-thermometer-outline", - True, + "switch_mode", + coordinator, + ) + temp_switch = AdaptiveCoverSwitch( + config_entry, + config_entry.entry_id, + "Outside Temperature", + False, + "mdi:thermometer", + "temp_toggle", coordinator, ) climate_mode = config_entry.options.get(CONF_CLIMATE_MODE) + weather_entity = config_entry.options.get(CONF_WEATHER_ENTITY) + sensor_entity = config_entry.options.get(CONF_OUTSIDETEMP_ENTITY) switches = [] + if climate_mode: switches.append(climate_switch) + if weather_entity or sensor_entity: + switches.append(temp_switch) + async_add_entities(switches) @@ -56,7 +77,7 @@ def __init__( switch_name: str, state: bool, icon: str | None, - assumed: bool, + key: str, coordinator: AdaptiveDataUpdateCoordinator, device_class: SwitchDeviceClass | None = None, ) -> None: @@ -68,9 +89,9 @@ def __init__( "cover_tilt": "Tilt", } self._name = config_entry.data["name"] + self._key = key self._device_name = self.type[config_entry.data[CONF_SENSOR_TYPE]] self._switch_name = switch_name - self._attr_assumed_state = assumed self._attr_device_class = device_class self._attr_icon = icon self._attr_is_on = state @@ -89,13 +110,13 @@ def name(self): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self._attr_is_on = True - self.coordinator.switch_mode = True + setattr(self.coordinator, self._key, True) await self.coordinator.async_refresh() self.schedule_update_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" self._attr_is_on = False - self.coordinator.switch_mode = False + setattr(self.coordinator, self._key, False) await self.coordinator.async_refresh() self.schedule_update_ha_state() diff --git a/custom_components/adaptive_cover/translations/en.json b/custom_components/adaptive_cover/translations/en.json index 665a063..dcd1c15 100644 --- a/custom_components/adaptive_cover/translations/en.json +++ b/custom_components/adaptive_cover/translations/en.json @@ -43,14 +43,16 @@ "climate":{ "data":{ "temp_entity": "Inside temperature entity", - "presence_entity": "Presence entity", + "presence_entity": "Presence entity (optional)", "weather_entity": "Weather entity (optional)", + "outside_temp": "Outside temperature sensor (optional)", "temp_low": "Low temperature threshold", "temp_high": "High temperature threshold" }, "data_description":{ "presence_entity": "Entity to represent the presence status in the room or home", - "weather_entity": "Checks for weather conditions", + "weather_entity": "Checks for weather conditions and can be used for the outside temperature", + "outside_temp":"This entity overrides the outside temperature from the weather entity if both are set", "temp_low": "The minimum comfort temperature", "temp_high": "The maximum comfort temperature" }, @@ -175,14 +177,16 @@ "climate":{ "data":{ "temp_entity": "Inside temperature entity", - "presence_entity": "Presence entity", + "presence_entity": "Presence entity (optional)", "weather_entity": "Weather entity (optional)", + "outside_temp": "Outside temperature sensor (optional)", "temp_low": "Low temperature threshold", "temp_high": "High temperature threshold" }, "data_description":{ "presence_entity": "Entity to represent the presence status in the room or home", - "weather_entity": "Checks for weather conditions", + "weather_entity": "Checks for weather conditions and can be used for the outside temperature", + "outside_temp":"This entity overrides the outside temperature from the weather entity if both are set", "temp_low": "The minimum comfort temperature", "temp_high": "The maximum comfort temperature" }, diff --git a/custom_components/adaptive_cover/translations/nl.json b/custom_components/adaptive_cover/translations/nl.json index 274b77a..1aa6b2b 100644 --- a/custom_components/adaptive_cover/translations/nl.json +++ b/custom_components/adaptive_cover/translations/nl.json @@ -43,14 +43,16 @@ "climate":{ "data":{ "temp_entity": "Binnen temperatuur entiteit", - "presence_entity": "Aanwezigheid entiteit", + "presence_entity": "Aanwezigheid entiteit (optioneel)", "weather_entity": "Weer entiteit (optioneel)", + "outside_temp": "Buitentemperatuur sensor (optioneel)", "temp_low": "Minimale comfort temperatuur", "temp_high": "Maximale comfort temperatuur" }, "data_description":{ "presence_entity": "", - "weather_entity": "", + "weather_entity":"Met de weer entiteit kunt u zelf de weercondities instellen, tevens kan deze ook gebruikt worden om te schakelen op de buitentemperatuur", + "outside_temp": "Overschrijft de temperatuur waarde van de weersentiteit indien bij zijn ingesteld", "temp_low": "", "temp_high": "" }, @@ -175,14 +177,16 @@ "climate":{ "data":{ "temp_entity": "Binnen temperatuur entiteit", - "presence_entity": "Aanwezigheid entiteit", + "presence_entity": "Aanwezigheid entiteit (optioneel)", "weather_entity": "Weer entiteit (optioneel)", + "outside_temp": "Buitentemperatuur sensor (optioneel)", "temp_low": "Minimale comfort temperatuur", "temp_high": "Maximale comfort temperatuur" }, "data_description":{ "presence_entity": "", - "weather_entity": "", + "weather_entity":"Met de weer entiteit kunt u zelf de weercondities instellen, tevens kan deze ook gebruikt worden om te schakelen op de buitentemperatuur", + "outside_temp": "Overschrijft de temperatuur waarde van de weersentiteit indien bij zijn ingesteld", "temp_low": "", "temp_high": "" },