diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index dd52dfa..4a8368f 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -1031,6 +1031,10 @@ def save_state(): return + # Remove eventual overpoering if we want to turn-off + if hvac_mode == HVACMode.OFF and self.power_manager.is_overpowering_detected: + await self.power_manager.set_overpowering(False) + self._hvac_mode = hvac_mode # Delegate to all underlying diff --git a/custom_components/versatile_thermostat/manifest.json b/custom_components/versatile_thermostat/manifest.json index 3edcbf0..34d066d 100644 --- a/custom_components/versatile_thermostat/manifest.json +++ b/custom_components/versatile_thermostat/manifest.json @@ -14,6 +14,6 @@ "quality_scale": "silver", "requirements": [], "ssdp": [], - "version": "7.1.3", + "version": "7.1.4", "zeroconf": [] } \ No newline at end of file diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index d23acc4..ad14da5 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -195,6 +195,16 @@ async def turn_off_and_cancel_cycle(self): self._cancel_cycle() await self.turn_off() + async def check_overpowering(self) -> bool: + """Check that a underlying can be turned on, else + activate the overpowering state of the VTherm associated. + Returns True if the check is ok (no overpowering needed)""" + if not await self._thermostat.power_manager.check_power_available(): + _LOGGER.debug("%s - overpowering is detected", self) + await self._thermostat.power_manager.set_overpowering(True) + return False + return True + class UnderlyingSwitch(UnderlyingEntity): """Represent a underlying switch""" @@ -318,6 +328,10 @@ async def turn_on(self): """Turn heater toggleable device on.""" self._keep_alive.cancel() # Cancel early to avoid a turn_on/turn_off race condition _LOGGER.debug("%s - Starting underlying entity %s", self, self._entity_id) + + if not await self.check_overpowering(): + return False + command = SERVICE_TURN_ON if not self.is_inversed else SERVICE_TURN_OFF domain = self._entity_id.split(".")[0] try: @@ -325,6 +339,7 @@ async def turn_on(self): data = {ATTR_ENTITY_ID: self._entity_id} await self._hass.services.async_call(domain, command, data) self._keep_alive.set_async_action(self._keep_alive_callback) + return True except Exception: self._keep_alive.cancel() raise @@ -414,10 +429,6 @@ async def _turn_on_later(self, _): await self.turn_off() return - # if await self._thermostat.power_manager.check_overpowering(): - # _LOGGER.debug("%s - End of cycle (3)", self) - # return - # safety mode could have change the on_time percent await self._thermostat.safety_manager.refresh_state() time = self._on_time_sec @@ -432,7 +443,8 @@ async def _turn_on_later(self, _): time // 60, time % 60, ) - await self.turn_on() + if not await self.turn_on(): + return else: _LOGGER.debug("%s - No action on heater cause duration is 0", self) self._async_cancel_cycle = self.call_later( @@ -557,6 +569,10 @@ async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool: ) return False + # When turning on a climate, check that power is available + if hvac_mode in (HVACMode.HEAT, HVACMode.COOL) and not await self.check_overpowering(): + return False + data = {ATTR_ENTITY_ID: self._entity_id, "hvac_mode": hvac_mode} await self._hass.services.async_call( CLIMATE_DOMAIN, diff --git a/documentation/en/feature-power.md b/documentation/en/feature-power.md index 1b05c08..d2cc9f2 100644 --- a/documentation/en/feature-power.md +++ b/documentation/en/feature-power.md @@ -11,6 +11,7 @@ The behavior of this feature is as follows: 1. When a new measurement of the home's power consumption or the maximum allowed power is received, 2. If the maximum power is exceeded, the central command will shed the load of all active devices starting with those closest to the setpoint. This continues until enough _VTherms_ are shed, 3. If there is available power reserve and some _VTherms_ are shed, the central command will re-enable as many devices as possible, starting with those furthest from the setpoint (at the time they were shed). +4. When a _VTherm_ starts, a check is performed to determine if the declared power is available. If not, the _VTherm_ is put into shed mode. **WARNING:** This is **not a safety feature** but an optimization function to manage consumption at the expense of some heating degradation. Overconsumption is still possible depending on the frequency of your consumption sensor updates and the actual power used by your equipment. Always maintain a safety margin. diff --git a/documentation/fr/feature-power.md b/documentation/fr/feature-power.md index f4d0c92..167c5c3 100644 --- a/documentation/fr/feature-power.md +++ b/documentation/fr/feature-power.md @@ -10,6 +10,7 @@ Le comportement de cette fonction est le suivant : 1. lorsqu'une nouvelle mesure de la puissance consommée du logement ou de la puissance maximale autorisée est reçue, 2. si la puissance max est dépassée, la commande centrale va mettre en délestage tous les équipements actifs en commençant par ceux qui sont le plus près de la consigne. Il fait ça jusqu'à ce que suffisament de _VTherm_ soient délestés, 3. si une réserve de puissance est disponible et que des _VTherms_ sont délestés, alors la commande centrale va délester autant d'équipements que possible en commençant par les plus loin de la consigne (au moment où il a été mis en délestage), +4. au démarrage d'un _VTherm_, une vérification est effectuée pour savoir si la puissance déclarée est disponible. Si non, le _VTherm_ est passé en délestage. ATTENTION: ce fonctionnement **n'est pas une fonction de sécurité** mais plus une fonction permettant une optimisation de la consommation au prix d'une dégradation du chauffage. Des dépassements sont possibles selon la fréquence de remontée de vos capteurs de consommation, la puissance réellement utilisée par votre équipements. Vous devez donc toujours garder une marge de sécurité. diff --git a/tests/test_bugs.py b/tests/test_bugs.py index 8a1e075..68afe9d 100644 --- a/tests/test_bugs.py +++ b/tests/test_bugs.py @@ -398,7 +398,8 @@ async def test_bug_407( assert entity.target_temperature == 19 assert mock_service_call.call_count >= 1 - # 3. if heater is stopped (is_device_active==False) and power is over max, then overpowering should be started + # 3. Evenif heater is stopped (is_device_active==False) and power is over max, then overpowering should be started + # due to check before start heating side_effects.add_or_update_side_effect("sensor.the_power_sensor", State("sensor.the_power_sensor", 150)) with patch( "homeassistant.core.ServiceRegistry.async_call" @@ -413,18 +414,18 @@ async def test_bug_407( now = now + timedelta(seconds=30) VersatileThermostatAPI.get_vtherm_api()._set_now(now) - # change preset to Boost + # change preset to Comfort await entity.async_set_preset_mode(PRESET_COMFORT) - # waits that the heater starts + # waits the eventual heater starts await asyncio.sleep(0.1) - # simulate a refresh for central power (not necessary) - await do_central_power_refresh(hass) + # simulate a refresh for central power (not necessary because it is checked before start) + # await do_central_power_refresh(hass) - assert entity.power_manager.is_overpowering_detected is False + assert entity.power_manager.is_overpowering_detected is True assert entity.hvac_mode is HVACMode.HEAT - assert entity.preset_mode is PRESET_COMFORT - assert entity.power_manager.overpowering_state is STATE_OFF + assert entity.preset_mode is PRESET_POWER + assert entity.power_manager.overpowering_state is STATE_ON @pytest.mark.parametrize("expected_lingering_tasks", [True]) diff --git a/tests/test_power.py b/tests/test_power.py index 8f5ee28..8c38493 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -825,3 +825,132 @@ async def test_power_management_energy_over_climate( # Test the re-increment entity.incremente_energy() assert entity.total_energy == 2 * 100 * 3.0 / 60 + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_power_management_turn_off_while_shedding(hass: HomeAssistant, skip_hass_states_is_state, init_central_power_manager): + """Test the Power management and that we can turn off a Vtherm that + is in overpowering state""" + + temps = { + "eco": 17, + "comfort": 18, + "boost": 19, + } + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: True, + CONF_USE_PRESENCE_FEATURE: False, + CONF_UNDERLYING_LIST: ["switch.mock_switch"], + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SAFETY_DELAY_MIN: 5, + CONF_SAFETY_MIN_ON_PERCENT: 0.3, + CONF_DEVICE_POWER: 100, + CONF_PRESET_POWER: 12, + }, + ) + + entity: ThermostatOverSwitch = await create_thermostat(hass, entry, "climate.theoverswitchmockname", temps) + assert entity + + now: datetime = NowClass.get_now(hass) + VersatileThermostatAPI.get_vtherm_api()._set_now(now) + + tpi_algo = entity._prop_algorithm + assert tpi_algo + + await entity.async_set_hvac_mode(HVACMode.HEAT) + await entity.async_set_preset_mode(PRESET_BOOST) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.power_manager.overpowering_state is STATE_UNKNOWN + assert entity.target_temperature == 19 + + # make the heater heats + await send_temperature_change_event(entity, 15, now) + await send_ext_temperature_change_event(entity, 1, now) + await hass.async_block_till_done() + + assert entity.power_percent > 0 + + side_effects = SideEffects( + { + "sensor.the_power_sensor": State("sensor.the_power_sensor", 50), + "sensor.the_max_power_sensor": State("sensor.the_max_power_sensor", 49), + }, + State("unknown.entity_id", "unknown"), + ) + # # fmt:off + # with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()): + # # fmt: on + # await send_power_change_event(entity, 50, datetime.now()) + # # Send power max mesurement + # now = now + timedelta(seconds=30) + # VersatileThermostatAPI.get_vtherm_api()._set_now(now) + # await send_max_power_change_event(entity, 300, datetime.now()) + # + # assert entity.power_manager.is_overpowering_detected is False + # # All configuration is complete and power is < power_max + # assert entity.preset_mode is PRESET_BOOST + # assert entity.power_manager.overpowering_state is STATE_OFF + + # 1. Set VTherm to overpowering + # Send power max mesurement too low and HVACMode is on and device is active + # fmt:off + with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()), \ + patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"), \ + patch("custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on") as mock_heater_on, \ + patch("custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off") as mock_heater_off, \ + patch("custom_components.versatile_thermostat.thermostat_switch.ThermostatOverSwitch.is_device_active", return_value="True"): + # fmt: on + now = now + timedelta(seconds=30) + VersatileThermostatAPI.get_vtherm_api()._set_now(now) + + await send_max_power_change_event(entity, 49, now) + assert entity.power_manager.is_overpowering_detected is True + # All configuration is complete and power is > power_max we switch to POWER preset + assert entity.preset_mode is PRESET_POWER + assert entity.power_manager.overpowering_state is STATE_ON + assert entity.target_temperature == 12 + + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count == 1 + + # 2. Turn-off Vtherm + # fmt:off + with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()), \ + patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \ + patch("custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on") as mock_heater_on, \ + patch("custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off") as mock_heater_off, \ + patch("custom_components.versatile_thermostat.thermostat_switch.ThermostatOverSwitch.is_device_active", return_value="True"): + # fmt: on + now = now + timedelta(seconds=30) + VersatileThermostatAPI.get_vtherm_api()._set_now(now) + + await entity.async_set_hvac_mode(HVACMode.OFF) + await VersatileThermostatAPI.get_vtherm_api().central_power_manager._do_immediate_shedding() + await hass.async_block_till_done() + + # VTherm is off and overpowering if off also + assert entity.hvac_mode == HVACMode.OFF + assert entity.power_manager.is_overpowering_detected is False + assert entity.preset_mode is PRESET_BOOST + assert entity.power_manager.overpowering_state is STATE_OFF + assert entity.target_temperature == 19