diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 6052855..e0893e9 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -3,9 +3,9 @@ default_config: logger: default: warning logs: - # custom_components.versatile_thermostat: debug - # custom_components.versatile_thermostat.underlyings: debug - # custom_components.versatile_thermostat.climate: debug + custom_components.versatile_thermostat: info + custom_components.versatile_thermostat.underlyings: info + custom_components.versatile_thermostat.climate: info # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) debugpy: diff --git a/custom_components/versatile_thermostat/__init__.py b/custom_components/versatile_thermostat/__init__.py index c5b4b14..e132735 100644 --- a/custom_components/versatile_thermostat/__init__.py +++ b/custom_components/versatile_thermostat/__init__.py @@ -107,6 +107,8 @@ async def async_setup( "VersatileThermostat - HA is started, initialize all links between VTherm entities" ) await api.init_vtherm_links() + await api.notify_central_mode_change() + await api.reload_central_boiler_entities_list() if hass.state == CoreState.running: await _async_startup_internal() @@ -156,8 +158,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await api.reload_central_boiler_entities_list() - await api.init_vtherm_links() + if hass.state == CoreState.running: + await api.reload_central_boiler_entities_list() + await api.init_vtherm_links() return True diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 43b1fcd..e601d4f 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -629,7 +629,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self.async_on_remove(self.remove_thermostat) - await self.async_startup() + # issue 428. Link to others entities will start at link + # await self.async_startup() def remove_thermostat(self): """Called when the thermostat will be removed""" @@ -637,155 +638,157 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): for under in self._underlyings: under.remove_entity() - async def async_startup(self): + async def async_startup(self, central_configuration): """Triggered on startup, used to get old state and set internal states accordingly""" _LOGGER.debug("%s - Calling async_startup", self) - @callback - async def _async_startup_internal(*_): - _LOGGER.debug("%s - Calling async_startup_internal", self) - need_write_state = False + _LOGGER.debug("%s - Calling async_startup_internal", self) + need_write_state = False - # Initialize all UnderlyingEntities - self.init_underlyings() + await self.get_my_previous_state() - temperature_state = self.hass.states.get(self._temp_sensor_entity_id) - if temperature_state and temperature_state.state not in ( + await self.init_presets(central_configuration) + + # Initialize all UnderlyingEntities + self.init_underlyings() + + temperature_state = self.hass.states.get(self._temp_sensor_entity_id) + if temperature_state and temperature_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + _LOGGER.debug( + "%s - temperature sensor have been retrieved: %.1f", + self, + float(temperature_state.state), + ) + await self._async_update_temp(temperature_state) + need_write_state = True + + if self._ext_temp_sensor_entity_id: + ext_temperature_state = self.hass.states.get( + self._ext_temp_sensor_entity_id + ) + if ext_temperature_state and ext_temperature_state.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, ): _LOGGER.debug( - "%s - temperature sensor have been retrieved: %.1f", + "%s - external temperature sensor have been retrieved: %.1f", self, - float(temperature_state.state), + float(ext_temperature_state.state), ) - await self._async_update_temp(temperature_state) - need_write_state = True - - if self._ext_temp_sensor_entity_id: - ext_temperature_state = self.hass.states.get( - self._ext_temp_sensor_entity_id - ) - if ext_temperature_state and ext_temperature_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - _LOGGER.debug( - "%s - external temperature sensor have been retrieved: %.1f", - self, - float(ext_temperature_state.state), - ) - await self._async_update_ext_temp(ext_temperature_state) - else: - _LOGGER.debug( - "%s - external temperature sensor have NOT been retrieved cause unknown or unavailable", - self, - ) + await self._async_update_ext_temp(ext_temperature_state) else: _LOGGER.debug( - "%s - external temperature sensor have NOT been retrieved cause no external sensor", + "%s - external temperature sensor have NOT been retrieved cause unknown or unavailable", self, ) - - if self._pmax_on: - # try to acquire current power and power max - current_power_state = self.hass.states.get(self._power_sensor_entity_id) - if current_power_state and current_power_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._current_power = float(current_power_state.state) - _LOGGER.debug( - "%s - Current power have been retrieved: %.3f", - self, - self._current_power, - ) - need_write_state = True - - # Try to acquire power max - current_power_max_state = self.hass.states.get( - self._max_power_sensor_entity_id - ) - if current_power_max_state and current_power_max_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._current_power_max = float(current_power_max_state.state) - _LOGGER.debug( - "%s - Current power max have been retrieved: %.3f", - self, - self._current_power_max, - ) - need_write_state = True - - # try to acquire window entity state - if self._window_sensor_entity_id: - window_state = self.hass.states.get(self._window_sensor_entity_id) - if window_state and window_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._window_state = window_state.state == STATE_ON - _LOGGER.debug( - "%s - Window state have been retrieved: %s", - self, - self._window_state, - ) - need_write_state = True - - # try to acquire motion entity state - if self._motion_sensor_entity_id: - motion_state = self.hass.states.get(self._motion_sensor_entity_id) - if motion_state and motion_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._motion_state = motion_state.state - _LOGGER.debug( - "%s - Motion state have been retrieved: %s", - self, - self._motion_state, - ) - # recalculate the right target_temp in activity mode - await self._async_update_motion_temp() - need_write_state = True - - if self._presence_on: - # try to acquire presence entity state - presence_state = self.hass.states.get(self._presence_sensor_entity_id) - if presence_state and presence_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - await self._async_update_presence(presence_state.state) - _LOGGER.debug( - "%s - Presence have been retrieved: %s", - self, - presence_state.state, - ) - need_write_state = True - - if need_write_state: - self.async_write_ha_state() - if self._prop_algorithm: - self._prop_algorithm.calculate( - self._target_temp, - self._cur_temp, - self._cur_ext_temp, - self._hvac_mode or HVACMode.OFF, - ) - - self.reset_last_change_time() - - await self.get_my_previous_state() - - if self.hass.state == CoreState.running: - await _async_startup_internal() else: - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, _async_startup_internal + _LOGGER.debug( + "%s - external temperature sensor have NOT been retrieved cause no external sensor", + self, ) + if self._pmax_on: + # try to acquire current power and power max + current_power_state = self.hass.states.get(self._power_sensor_entity_id) + if current_power_state and current_power_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power = float(current_power_state.state) + _LOGGER.debug( + "%s - Current power have been retrieved: %.3f", + self, + self._current_power, + ) + need_write_state = True + + # Try to acquire power max + current_power_max_state = self.hass.states.get( + self._max_power_sensor_entity_id + ) + if current_power_max_state and current_power_max_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power_max = float(current_power_max_state.state) + _LOGGER.debug( + "%s - Current power max have been retrieved: %.3f", + self, + self._current_power_max, + ) + need_write_state = True + + # try to acquire window entity state + if self._window_sensor_entity_id: + window_state = self.hass.states.get(self._window_sensor_entity_id) + if window_state and window_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._window_state = window_state.state == STATE_ON + _LOGGER.debug( + "%s - Window state have been retrieved: %s", + self, + self._window_state, + ) + need_write_state = True + + # try to acquire motion entity state + if self._motion_sensor_entity_id: + motion_state = self.hass.states.get(self._motion_sensor_entity_id) + if motion_state and motion_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._motion_state = motion_state.state + _LOGGER.debug( + "%s - Motion state have been retrieved: %s", + self, + self._motion_state, + ) + # recalculate the right target_temp in activity mode + await self._async_update_motion_temp() + need_write_state = True + + if self._presence_on: + # try to acquire presence entity state + presence_state = self.hass.states.get(self._presence_sensor_entity_id) + if presence_state and presence_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + await self._async_update_presence(presence_state.state) + _LOGGER.debug( + "%s - Presence have been retrieved: %s", + self, + presence_state.state, + ) + need_write_state = True + + if need_write_state: + self.async_write_ha_state() + if self._prop_algorithm: + self._prop_algorithm.calculate( + self._target_temp, + self._cur_temp, + self._cur_ext_temp, + self._hvac_mode or HVACMode.OFF, + ) + + self.hass.create_task(self._check_initial_state()) + + self.reset_last_change_time() + + # if self.hass.state == CoreState.running: + # await _async_startup_internal() + # else: + # self.hass.bus.async_listen_once( + # EVENT_HOMEASSISTANT_START, _async_startup_internal + # ) + def init_underlyings(self): """Initialize all underlyings. Should be overriden if necessary""" @@ -825,19 +828,20 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): # Never restore a Power or Security preset if old_preset_mode is not None and old_preset_mode not in HIDDEN_PRESETS: # old_preset_mode in self._attr_preset_modes - self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) + self._attr_preset_mode = old_preset_mode self.save_preset_mode() else: self._attr_preset_mode = PRESET_NONE - if not self._hvac_mode and old_state.state in [ + if old_state.state in [ HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, ]: self._hvac_mode = old_state.state else: - self._hvac_mode = HVACMode.OFF + if not self._hvac_mode: + self._hvac_mode = HVACMode.OFF old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY) if old_total_energy: @@ -2085,6 +2089,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): new_central_mode, ) + first_init = self._last_central_mode == None + self._last_central_mode = new_central_mode def save_all(): @@ -2093,7 +2099,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self.save_hvac_mode() if new_central_mode == CENTRAL_MODE_AUTO: - if self.window_state is not STATE_ON: + if self.window_state is not STATE_ON and not first_init: await self.restore_hvac_mode() await self.restore_preset_mode() @@ -2707,8 +2713,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if self._attr_preset_mode: await self._async_set_preset_mode_internal(self._attr_preset_mode, True) - self.hass.create_task(self._check_initial_state()) - async def async_turn_off(self) -> None: await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/custom_components/versatile_thermostat/number.py b/custom_components/versatile_thermostat/number.py index 1ac6602..b2bc336 100644 --- a/custom_components/versatile_thermostat/number.py +++ b/custom_components/versatile_thermostat/number.py @@ -353,7 +353,7 @@ class CentralConfigTemperatureNumber( # We have to reload all VTherm for which uses the central configuration api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass) # Update the VTherms which have temperature in central config - self.hass.create_task(api.init_vtherm_links(only_use_central=True)) + self.hass.create_task(api.init_vtherm_preset_with_central()) def __str__(self): return f"VersatileThermostat-{self.name}" diff --git a/custom_components/versatile_thermostat/select.py b/custom_components/versatile_thermostat/select.py index 554dda7..1f17ec3 100644 --- a/custom_components/versatile_thermostat/select.py +++ b/custom_components/versatile_thermostat/select.py @@ -18,6 +18,9 @@ from custom_components.versatile_thermostat.base_thermostat import ( BaseThermostat, ConfigData, ) + +from custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI + from .const import ( DOMAIN, DEVICE_MANUFACTURER, @@ -96,17 +99,20 @@ class CentralModeSelect(SelectEntity, RestoreEntity): if old_state is not None: self._attr_current_option = old_state.state - @callback - async def _async_startup_internal(*_): - _LOGGER.debug("%s - Calling async_startup_internal", self) - await self.notify_central_mode_change() + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass) + api.register_central_mode_select(self) - if self.hass.state == CoreState.running: - await _async_startup_internal() - else: - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, _async_startup_internal - ) + # @callback + # async def _async_startup_internal(*_): + # _LOGGER.debug("%s - Calling async_startup_internal", self) + # await self.notify_central_mode_change() + # + # if self.hass.state == CoreState.running: + # await _async_startup_internal() + # else: + # self.hass.bus.async_listen_once( + # EVENT_HOMEASSISTANT_START, _async_startup_internal + # ) @overrides async def async_select_option(self, option: str) -> None: @@ -122,17 +128,9 @@ class CentralModeSelect(SelectEntity, RestoreEntity): async def notify_central_mode_change(self, old_central_mode: str | None = None): """Notify all VTherm that the central_mode have change""" + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass) # Update all VTherm states - component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] - for entity in component.entities: - if isinstance(entity, BaseThermostat): - _LOGGER.debug( - "Changing the central_mode. We have find %s to update", - entity.name, - ) - await entity.check_central_mode( - self._attr_current_option, old_central_mode - ) + await api.notify_central_mode_change(old_central_mode) def __str__(self) -> str: return f"VersatileThermostat-{self.name}" diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index 860138e..3b41962 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -486,8 +486,8 @@ class UnderlyingClimate(UnderlyingEntity): self._underlying_climate, ) else: - _LOGGER.error( - "%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational", + _LOGGER.info( + "%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational. Will try later.", self, self.entity_id, ) @@ -780,15 +780,19 @@ class UnderlyingValve(UnderlyingEntity): """Send the percent open to the underlying valve""" # This may fails if called after shutdown try: - data = {ATTR_ENTITY_ID: self._entity_id, "value": self._percent_open} + data = {"value": self._percent_open} + target = {ATTR_ENTITY_ID: self._entity_id} domain = self._entity_id.split(".")[0] await self._hass.services.async_call( - domain, - SERVICE_SET_VALUE, - data, + domain=domain, + service=SERVICE_SET_VALUE, + service_data=data, + target=target, ) except ServiceNotFound as err: _LOGGER.error(err) + # This could happens in unit test if input_number domain is not yet loaded + # raise err async def turn_off(self): """Turn heater toggleable device off.""" diff --git a/custom_components/versatile_thermostat/vtherm_api.py b/custom_components/versatile_thermostat/vtherm_api.py index 1bedc4a..52a0609 100644 --- a/custom_components/versatile_thermostat/vtherm_api.py +++ b/custom_components/versatile_thermostat/vtherm_api.py @@ -57,6 +57,7 @@ class VersatileThermostatAPI(dict): self._threshold_number_entity = None self._nb_active_number_entity = None self._central_configuration = None + self._central_mode_select = None # A dict that will store all Number entities which holds the temperature self._number_temperatures = dict() @@ -149,8 +150,8 @@ class VersatileThermostatAPI(dict): return entity.state return None - async def init_vtherm_links(self, only_use_central=False): - """INitialize all VTherms entities links + async def init_vtherm_links(self): + """Initialize all VTherms entities links This method is called when HA is fully started (and all entities should be initialized) Or when we need to reload all VTherm links (with Number temp entities, central boiler, ...) """ @@ -162,12 +163,34 @@ class VersatileThermostatAPI(dict): ) if component: for entity in component.entities: - if hasattr(entity, "init_presets"): - if ( - only_use_central is False - or entity.use_central_config_temperature - ): - await entity.init_presets(self.find_central_configuration()) + # if hasattr(entity, "init_presets"): + # if ( + # only_use_central is False + # or entity.use_central_config_temperature + # ): + # await entity.init_presets(self.find_central_configuration()) + + # A little hack to test if the climate is a VTherm. Cannot use isinstance due to circular dependency of BaseThermostat + if ( + entity.device_info + and entity.device_info.get("model", None) == DOMAIN + ): + await entity.async_startup(self.find_central_configuration()) + + async def init_vtherm_preset_with_central(self): + """Init all VTherm presets when the VTherm uses central temperature""" + # Initialization of all preset for all VTherm + component: EntityComponent[ClimateEntity] = self._hass.data.get( + CLIMATE_DOMAIN, None + ) + if component: + for entity in component.entities: + if ( + entity.device_info + and entity.device_info.get("model", None) == DOMAIN + and entity.use_central_config_temperature + ): + await entity.init_presets(self.find_central_configuration()) async def reload_central_boiler_binary_listener(self): """Reloads the BinarySensor entity which listen to the number of @@ -180,6 +203,27 @@ class VersatileThermostatAPI(dict): if self._nb_active_number_entity is not None: await self._nb_active_number_entity.listen_vtherms_entities() + def register_central_mode_select(self, central_mode_select): + """Register the select entity which holds the central_mode""" + self._central_mode_select = central_mode_select + + async def notify_central_mode_change(self, old_central_mode: str | None = None): + """Notify all VTherm that the central_mode have change""" + if self._central_mode_select is None: + return + + # Update all VTherm states + component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if entity.device_info and entity.device_info.get("model", None) == DOMAIN: + _LOGGER.debug( + "Changing the central_mode. We have find %s to update", + entity.name, + ) + await entity.check_central_mode( + self._central_mode_select.state, old_central_mode + ) + @property def self_regulation_expert(self): """Get the self regulation params""" @@ -229,6 +273,14 @@ class VersatileThermostatAPI(dict): return None return int(self._threshold_number_entity.native_value) + @property + def central_mode(self) -> str | None: + """Get the current central mode or None""" + if self._central_mode_select: + return self._central_mode_select.state + else: + return None + @property def hass(self): """Get the HomeAssistant object""" diff --git a/tests/test_valve.py b/tests/test_valve.py index 408f9d7..cbf1271 100644 --- a/tests/test_valve.py +++ b/tests/test_valve.py @@ -181,14 +181,18 @@ async def test_over_valve_full_start( mock_service_call.assert_has_calls( [ call.async_call( - "number", - "set_value", - {"entity_id": "number.mock_valve", "value": 90}, + domain="number", + service="set_value", + service_data={"value": 90}, + target={"entity_id": "number.mock_valve"}, + # {"entity_id": "number.mock_valve", "value": 90}, ), call.async_call( - "number", - "set_value", - {"entity_id": "number.mock_valve", "value": 98}, + domain="number", + service="set_value", + service_data={"value": 98}, + target={"entity_id": "number.mock_valve"}, + # {"entity_id": "number.mock_valve", "value": 98}, ), ] ) @@ -241,9 +245,10 @@ async def test_over_valve_full_start( mock_service_call.assert_has_calls( [ call.async_call( - "number", - "set_value", - {"entity_id": "number.mock_valve", "value": 10}, + domain="number", + service="set_value", + service_data={"value": 10}, + target={"entity_id": "number.mock_valve"}, ) ] ) @@ -254,20 +259,16 @@ async def test_over_valve_full_start( mock_service_call.assert_has_calls( [ call.async_call( - "number", - "set_value", - { - "entity_id": "number.mock_valve", - "value": 10, - }, # the min allowed value + domain="number", + service="set_value", + service_data={"value": 10}, + target={"entity_id": "number.mock_valve"}, # the min allowed value ), call.async_call( - "number", - "set_value", - { - "entity_id": "number.mock_valve", - "value": 50, - }, # the max allowed value + domain="number", + service="set_value", + service_data={"value": 50}, # the min allowed value + target={"entity_id": "number.mock_valve"}, ), ] ) @@ -466,9 +467,10 @@ async def test_over_valve_regulation( mock_service_call.assert_has_calls( [ call.async_call( - "number", - "set_value", - {"entity_id": "number.mock_valve", "value": 90}, + domain="number", + service="set_value", + service_data={"value": 90}, + target={"entity_id": "number.mock_valve"}, ), ] ) @@ -524,9 +526,10 @@ async def test_over_valve_regulation( mock_service_call.assert_has_calls( [ call.async_call( - "number", - "set_value", - {"entity_id": "number.mock_valve", "value": 96}, + domain="number", + service="set_value", + service_data={"value": 96}, + target={"entity_id": "number.mock_valve"}, ), ] )