diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index b84cce7..14cb6fa 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -44,6 +44,12 @@ input_number: step: 10 icon: mdi:flash unit_of_measurement: kW + fake_valve1: + name: The valve 1 + min: 0 + max: 100 + icon: mdi:pipe-valve + unit_of_measurement: percentage input_boolean: # input_boolean to simulate the windows entity. Only for development environment. diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index a623131..704245b 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -852,12 +852,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity): Need to be one of CURRENT_HVAC_*. """ if self._hvac_mode == HVACMode.OFF: - return HVACAction.OFF - if not self.is_device_active: - return HVACAction.IDLE - if self._hvac_mode == HVACMode.COOL: - return HVACAction.COOLING - return HVACAction.HEATING + action = HVACAction.OFF + elif not self.is_device_active: + action = HVACAction.IDLE + elif self._hvac_mode == HVACMode.COOL: + action = HVACAction.COOLING + else: + action = HVACAction.HEATING + return action @property def target_temperature(self): @@ -2329,11 +2331,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._total_energy, ) - # TODO implement this with overrides def update_custom_attributes(self): """Update the custom extra attributes for the entity""" self._attr_extra_state_attributes: dict(str, str) = { + "hvac_action": self.hvac_action, "hvac_mode": self.hvac_mode, "preset_mode": self.preset_mode, "type": self._thermostat_type, @@ -2392,56 +2394,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): "power_sensor_entity_id": self._power_sensor_entity_id, "max_power_sensor_entity_id": self._max_power_sensor_entity_id, } - if self.is_over_climate: - self._attr_extra_state_attributes[ - "underlying_climate_0" - ] = self._underlyings[0].entity_id - self._attr_extra_state_attributes["underlying_climate_1"] = ( - self._underlyings[1].entity_id if len(self._underlyings) > 1 else None - ) - self._attr_extra_state_attributes["underlying_climate_2"] = ( - self._underlyings[2].entity_id if len(self._underlyings) > 2 else None - ) - self._attr_extra_state_attributes["underlying_climate_3"] = ( - self._underlyings[3].entity_id if len(self._underlyings) > 3 else None - ) - - self._attr_extra_state_attributes[ - "start_hvac_action_date" - ] = self._underlying_climate_start_hvac_action_date - else: - self._attr_extra_state_attributes[ - "underlying_switch_1" - ] = self._underlyings[0].entity_id - self._attr_extra_state_attributes["underlying_switch_2"] = ( - self._underlyings[1].entity_id if len(self._underlyings) > 1 else None - ) - self._attr_extra_state_attributes["underlying_switch_3"] = ( - self._underlyings[2].entity_id if len(self._underlyings) > 2 else None - ) - self._attr_extra_state_attributes["underlying_switch_4"] = ( - self._underlyings[3].entity_id if len(self._underlyings) > 3 else None - ) - self._attr_extra_state_attributes[ - "on_percent" - ] = self._prop_algorithm.on_percent - self._attr_extra_state_attributes[ - "on_time_sec" - ] = self._prop_algorithm.on_time_sec - self._attr_extra_state_attributes[ - "off_time_sec" - ] = self._prop_algorithm.off_time_sec - self._attr_extra_state_attributes["cycle_min"] = self._cycle_min - self._attr_extra_state_attributes["function"] = self._proportional_function - self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int - self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext - - self.async_write_ha_state() - _LOGGER.debug( - "%s - Calling update_custom_attributes: %s", - self, - self._attr_extra_state_attributes, - ) @callback def async_registry_entry_updated(self): diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index f3226de..13cc18b 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -1,3 +1,4 @@ +# pylint: disable=unused-argument """ Implements the VersatileThermostat sensors component """ import logging import math @@ -22,6 +23,7 @@ from .const import ( CONF_PROP_FUNCTION, PROPORTIONAL_FUNCTION_TPI, CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_VALVE, CONF_THERMOSTAT_TYPE, ) @@ -50,7 +52,7 @@ async def async_setup_entry( ] if entry.data.get(CONF_DEVICE_POWER): entities.append(EnergySensor(hass, unique_id, name, entry.data)) - if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_SWITCH: + if entry.data.get(CONF_THERMOSTAT_TYPE) in [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_VALVE]: entities.append(MeanPowerSensor(hass, unique_id, name, entry.data)) if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI: @@ -58,6 +60,9 @@ async def async_setup_entry( entities.append(OnTimeSensor(hass, unique_id, name, entry.data)) entities.append(OffTimeSensor(hass, unique_id, name, entry.data)) + if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE: + entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data)) + async_add_entities(entities, True) @@ -224,6 +229,47 @@ class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity): """Return the suggested number of decimal digits for display.""" return 1 +class ValveOpenPercentSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a on percent sensor which exposes the on_percent in a cycle""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the energy sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Vave open percent" + self._attr_unique_id = f"{self._device_name}_valve_open_percent" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + old_state = self._attr_native_value + self._attr_native_value = self.my_climate.valve_open_percent + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:pipe-valve" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.POWER_FACTOR + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + return PERCENTAGE + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 0 + class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity): """Representation of a on time sensor which exposes the on_time_sec in a cycle""" diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 280657e..0ab19ed 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -97,3 +97,37 @@ class ThermostatOverClimate(BaseThermostat): interval=timedelta(minutes=self._cycle_min), ) ) + + def update_custom_attributes(self): + """ Custom attributes """ + super().update_custom_attributes() + + self._attr_extra_state_attributes["is_over_climate"] = self.is_over_climate + self._attr_extra_state_attributes["start_hvac_action_date"] = ( + self._underlying_climate_start_hvac_action_date) + self._attr_extra_state_attributes["underlying_climate_0"] = ( + self._underlyings[0].entity_id) + self._attr_extra_state_attributes["underlying_climate_1"] = ( + self._underlyings[1].entity_id if len(self._underlyings) > 1 else None + ) + self._attr_extra_state_attributes["underlying_climate_2"] = ( + self._underlyings[2].entity_id if len(self._underlyings) > 2 else None + ) + self._attr_extra_state_attributes["underlying_climate_3"] = ( + self._underlyings[3].entity_id if len(self._underlyings) > 3 else None + ) + + self.async_write_ha_state() + _LOGGER.debug( + "%s - Calling update_custom_attributes: %s", + self, + self._attr_extra_state_attributes, + ) + + def recalculate(self): + """A utility function to force the calculation of a the algo and + update the custom attributes and write the state + """ + _LOGGER.debug("%s - recalculate all", self) + self.update_custom_attributes() + self.async_write_ha_state() diff --git a/custom_components/versatile_thermostat/thermostat_switch.py b/custom_components/versatile_thermostat/thermostat_switch.py index c3f2946..6597103 100644 --- a/custom_components/versatile_thermostat/thermostat_switch.py +++ b/custom_components/versatile_thermostat/thermostat_switch.py @@ -2,6 +2,7 @@ import logging from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.components.climate import HVACMode from .const import ( CONF_HEATER, @@ -66,3 +67,55 @@ class ThermostatOverSwitch(BaseThermostat): ) self.hass.create_task(self.async_control_heating()) + + def update_custom_attributes(self): + """ Custom attributes """ + super().update_custom_attributes() + + self._attr_extra_state_attributes["is_over_switch"] = self.is_over_switch + self._attr_extra_state_attributes["underlying_switch_0"] = ( + self._underlyings[0].entity_id) + self._attr_extra_state_attributes["underlying_switch_1"] = ( + self._underlyings[1].entity_id if len(self._underlyings) > 1 else None + ) + self._attr_extra_state_attributes["underlying_switch_2"] = ( + self._underlyings[2].entity_id if len(self._underlyings) > 2 else None + ) + self._attr_extra_state_attributes["underlying_switch_3"] = ( + self._underlyings[3].entity_id if len(self._underlyings) > 3 else None + ) + + self._attr_extra_state_attributes[ + "on_percent" + ] = self._prop_algorithm.on_percent + self._attr_extra_state_attributes[ + "on_time_sec" + ] = self._prop_algorithm.on_time_sec + self._attr_extra_state_attributes[ + "off_time_sec" + ] = self._prop_algorithm.off_time_sec + self._attr_extra_state_attributes["cycle_min"] = self._cycle_min + self._attr_extra_state_attributes["function"] = self._proportional_function + self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int + self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext + + self.async_write_ha_state() + _LOGGER.debug( + "%s - Calling update_custom_attributes: %s", + self, + self._attr_extra_state_attributes, + ) + + def recalculate(self): + """A utility function to force the calculation of a the algo and + update the custom attributes and write the state + """ + _LOGGER.debug("%s - recalculate all", self) + self._prop_algorithm.calculate( + self._target_temp, + self._cur_temp, + self._cur_ext_temp, + self._hvac_mode == HVACMode.COOL, + ) + self.update_custom_attributes() + self.async_write_ha_state() diff --git a/custom_components/versatile_thermostat/thermostat_valve.py b/custom_components/versatile_thermostat/thermostat_valve.py index 09d45a9..6a2a642 100644 --- a/custom_components/versatile_thermostat/thermostat_valve.py +++ b/custom_components/versatile_thermostat/thermostat_valve.py @@ -81,7 +81,6 @@ class ThermostatOverValve(BaseThermostat): ) ) - @callback async def _async_valve_changed(self, event): """Handle unerdlying valve state changes. @@ -252,3 +251,61 @@ class ThermostatOverValve(BaseThermostat): changes = True await end_climate_changed(changes) + + def update_custom_attributes(self): + """ Custom attributes """ + super().update_custom_attributes() + self._attr_extra_state_attributes["valve_open_percent"] = self.valve_open_percent + self._attr_extra_state_attributes["is_over_valve"] = self.is_over_valve + self._attr_extra_state_attributes["underlying_valve_0"] = ( + self._underlyings[0].entity_id) + self._attr_extra_state_attributes["underlying_valve_1"] = ( + self._underlyings[1].entity_id if len(self._underlyings) > 1 else None + ) + self._attr_extra_state_attributes["underlying_valve_2"] = ( + self._underlyings[2].entity_id if len(self._underlyings) > 2 else None + ) + self._attr_extra_state_attributes["underlying_valve_3"] = ( + self._underlyings[3].entity_id if len(self._underlyings) > 3 else None + ) + + self._attr_extra_state_attributes[ + "on_percent" + ] = self._prop_algorithm.on_percent + self._attr_extra_state_attributes[ + "on_time_sec" + ] = self._prop_algorithm.on_time_sec + self._attr_extra_state_attributes[ + "off_time_sec" + ] = self._prop_algorithm.off_time_sec + self._attr_extra_state_attributes["cycle_min"] = self._cycle_min + self._attr_extra_state_attributes["function"] = self._proportional_function + self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int + self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext + + self.async_write_ha_state() + _LOGGER.debug( + "%s - Calling update_custom_attributes: %s", + self, + self._attr_extra_state_attributes, + ) + + def recalculate(self): + """A utility function to force the calculation of a the algo and + update the custom attributes and write the state + """ + _LOGGER.debug("%s - recalculate all", self) + self._prop_algorithm.calculate( + self._target_temp, + self._cur_temp, + self._cur_ext_temp, + self._hvac_mode == HVACMode.COOL, + ) + + for under in self._underlyings: + under.set_valve_open_percent( + self._prop_algorithm.on_percent + ) + + self.update_custom_attributes() + self.async_write_ha_state() diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index 5d51314..b9868c8 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -662,9 +662,10 @@ class UnderlyingValve(UnderlyingEntity): """ Send the percent open to the underlying valve """ # This may fails if called after shutdown try: - data = { "value": self._percent_open } + data = { ATTR_ENTITY_ID: self._entity_id, "value": self._percent_open } + domain = self._entity_id.split('.')[0] await self._hass.services.async_call( - HA_DOMAIN, + domain, SERVICE_SET_VALUE, data, ) @@ -697,7 +698,10 @@ class UnderlyingValve(UnderlyingEntity): def is_device_active(self): """If the toggleable device is currently active.""" try: - return float(self._hass.states.get(self._entity_id)) > 0 + return self._percent_open > 0 + # To test if real device is open but this is causing some side effect + # because the activation can be deferred - + # or float(self._hass.states.get(self._entity_id).state) > 0 except Exception: # pylint: disable=broad-exception-caught return False @@ -711,20 +715,22 @@ class UnderlyingValve(UnderlyingEntity): force=False, ): """We use this function to change the on_percent""" + if force: + await self.send_percent_open() + + def set_valve_open_percent(self, percent): + """ Update the valve open percent """ caped_val = self._thermostat.valve_open_percent - if not force and self._percent_open == caped_val: + if self._percent_open == caped_val: # No changes return self._percent_open = caped_val # Send the new command to valve via a service call - try: - _LOGGER.info("%s - Setting valve ouverture percent to %s", self, self._percent_open) - - await self.send_percent_open() - except ServiceNotFound as err: - _LOGGER.error(err) + _LOGGER.info("%s - Setting valve ouverture percent to %s", self, self._percent_open) + # Send the change to the valve, in background + self._hass.create_task(self.send_percent_open()) def remove_entity(self): """Remove the entity after stopping its cycle""" diff --git a/tests/test_valve.py b/tests/test_valve.py index 138c942..e3a0a7b 100644 --- a/tests/test_valve.py +++ b/tests/test_valve.py @@ -17,7 +17,6 @@ from custom_components.versatile_thermostat.thermostat_valve import ThermostatOv from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import -@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_over_valve_full_start(hass: HomeAssistant, skip_hass_states_is_state): # pylint: disable=unused-argument """Test the normal full start of a thermostat in thermostat_over_switch type""" @@ -160,7 +159,7 @@ async def test_over_valve_full_start(hass: HomeAssistant, skip_hass_states_is_st ) as mock_send_event, patch( "homeassistant.core.ServiceRegistry.async_call" ) as mock_service_call, patch( - "homeassistant.core.StateMachine.get", return_value=90 + "homeassistant.core.StateMachine.get", return_value=State(entity_id="number.mock_valve", state="90") ): # Change temperature event_timestamp = now - timedelta(minutes=10) @@ -174,20 +173,8 @@ async def test_over_valve_full_start(hass: HomeAssistant, skip_hass_states_is_st assert mock_service_call.call_count == 2 mock_service_call.assert_has_calls([ - call.async_call( - HA_DOMAIN, - SERVICE_SET_VALUE, - { - "value": 90 - } - ), - call.async_call( - HA_DOMAIN, - SERVICE_SET_VALUE, - { - "value": 98 - } - ) + call.async_call('number', 'set_value', {'entity_id': 'number.mock_valve', 'value': 90}), + call.async_call('number', 'set_value', {'entity_id': 'number.mock_valve', 'value': 98}) ]) assert mock_send_event.call_count == 0 @@ -238,8 +225,8 @@ async def test_over_valve_full_start(hass: HomeAssistant, skip_hass_states_is_st assert entity.presence_state == STATE_OFF # pylint: disable=protected-access assert entity.valve_open_percent == 10 assert entity.target_temperature == 17.1 # eco_away - assert entity.is_device_active is False - assert entity.hvac_action == HVACAction.IDLE + assert entity.is_device_active is True + assert entity.hvac_action == HVACAction.HEATING # Open a window with patch(