Compare commits

..

3 Commits

Author SHA1 Message Date
Jean-Marc Collin
e6ecd100f6 Issue #119 - Set preset target temperature not updating in ac mode 2023-10-15 12:22:15 +02:00
Jean-Marc Collin
7ac49d7864 undo remove other packages. Test don't run into gitlab-ci environment 2023-10-15 10:43:59 +02:00
Jean-Marc Collin
40da04838d Bug #121 loop in over climate
* Issue #121 - loop when underlying is slow
* Issue #121 - try fix
* Release 3.5.3
* Fix tests step
2023-10-15 10:29:54 +02:00
9 changed files with 108 additions and 48 deletions

View File

@@ -114,18 +114,22 @@ climate:
name: Underlying thermostat 4-1 name: Underlying thermostat 4-1
heater: input_boolean.fake_heater_4climate1 heater: input_boolean.fake_heater_4climate1
target_sensor: input_number.fake_temperature_sensor1 target_sensor: input_number.fake_temperature_sensor1
ac_mode: true
- platform: generic_thermostat - platform: generic_thermostat
name: Underlying thermostat 4-2 name: Underlying thermostat 4-2
heater: input_boolean.fake_heater_4climate2 heater: input_boolean.fake_heater_4climate2
target_sensor: input_number.fake_temperature_sensor1 target_sensor: input_number.fake_temperature_sensor1
ac_mode: true
- platform: generic_thermostat - platform: generic_thermostat
name: Underlying thermostat 4-3 name: Underlying thermostat 4-3
heater: input_boolean.fake_heater_4climate3 heater: input_boolean.fake_heater_4climate3
target_sensor: input_number.fake_temperature_sensor1 target_sensor: input_number.fake_temperature_sensor1
ac_mode: true
- platform: generic_thermostat - platform: generic_thermostat
name: Underlying thermostat 4-4 name: Underlying thermostat 4-4
heater: input_boolean.fake_heater_4climate4 heater: input_boolean.fake_heater_4climate4
target_sensor: input_number.fake_temperature_sensor1 target_sensor: input_number.fake_temperature_sensor1
ac_mode: true
- platform: generic_thermostat - platform: generic_thermostat
name: Underlying thermostat9 name: Underlying thermostat9
heater: input_boolean.fake_heater_switch3 heater: input_boolean.fake_heater_switch3

View File

@@ -31,6 +31,8 @@ jobs:
- run: black . - run: black .
tests: tests:
# Tests don't run in Gitlab ci environment
if: 0
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"
name: Run tests name: Run tests
steps: steps:
@@ -41,10 +43,10 @@ jobs:
with: with:
python-version: "3.8" python-version: "3.8"
- name: Install requirements - name: Install requirements
run: python3 -m pip install -r requirements_test.txt run: cd custom_components/versatile_thermostat && python3 -m pip install -r requirements_test.txt
- name: Run tests - name: Run tests
run: | run: |
pytest \ cd custom_components/versatile_thermostat && pytest \
-qq \ -qq \
--timeout=9 \ --timeout=9 \
--durations=10 \ --durations=10 \

View File

@@ -220,6 +220,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_presence_state: bool _presence_state: bool
_window_auto_state: bool _window_auto_state: bool
_underlyings: list[UnderlyingEntity] _underlyings: list[UnderlyingEntity]
_last_change_time: datetime
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the thermostat.""" """Initialize the thermostat."""
@@ -284,6 +285,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone) self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone)
self._last_change_time = None
self._underlyings = [] self._underlyings = []
self.post_init(entry_infos) self.post_init(entry_infos)
@@ -778,6 +781,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
else: else:
self.hass.create_task(self._async_control_heating()) self.hass.create_task(self._async_control_heating())
self.reset_last_change_time()
await self.get_my_previous_state() await self.get_my_previous_state()
if self.hass.state == CoreState.running: if self.hass.state == CoreState.running:
@@ -951,7 +956,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
"""Return current operation.""" """Return current operation."""
# Issue #114 - returns my current hvac_mode and not the underlying hvac_mode which could be different # Issue #114 - returns my current hvac_mode and not the underlying hvac_mode which could be different
# delta will be managed by climate_state_change event. # delta will be managed by climate_state_change event.
# TODO remove this when ok
# if self._is_over_climate: # if self._is_over_climate:
# if one not OFF -> return it # if one not OFF -> return it
# else OFF # else OFF
@@ -1233,6 +1237,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# Ensure we update the current operation after changing the mode # Ensure we update the current operation after changing the mode
self.reset_last_temperature_time() self.reset_last_temperature_time()
self.reset_last_change_time()
self.update_custom_attributes() self.update_custom_attributes()
self.async_write_ha_state() self.async_write_ha_state()
self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode}) self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode})
@@ -1288,6 +1294,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.recalculate() self.recalculate()
self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode}) self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode})
def reset_last_change_time(self, old_preset_mode=None):
"""Reset to now the last change time"""
self._last_change_time = datetime.now(tz=self._current_tz)
_LOGGER.debug("%s - last_change_time is now %s", self, self._last_change_time)
def reset_last_temperature_time(self, old_preset_mode=None): def reset_last_temperature_time(self, old_preset_mode=None):
"""Reset to now the last temperature time if conditions are satisfied""" """Reset to now the last temperature time if conditions are satisfied"""
if ( if (
@@ -1363,6 +1374,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
await self._async_internal_set_temperature(temperature) await self._async_internal_set_temperature(temperature)
self._attr_preset_mode = PRESET_NONE self._attr_preset_mode = PRESET_NONE
self.recalculate() self.recalculate()
self.reset_last_change_time()
await self._async_control_heating(force=True) await self._async_control_heating(force=True)
async def _async_internal_set_temperature(self, temperature): async def _async_internal_set_temperature(self, temperature):
@@ -1585,7 +1597,22 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
@callback @callback
async def _async_climate_changed(self, event): async def _async_climate_changed(self, event):
"""Handle unerdlying climate state changes.""" """Handle unerdlying climate state changes.
This method takes the underlying values and update the VTherm with them.
To avoid loops (issues #121 #101 #95 #99), we discard the event if it is received
less than 10 sec after the last command. What we want here is to take the values
from underlyings ONLY if someone have change directly on the underlying and not
as a return of the command. The only thing we take all the time is the HVACAction
which is important for feedaback and which cannot generates loops.
"""
async def end_climate_changed(changes):
""" To end the event management"""
if changes:
self.async_write_ha_state()
self.update_custom_attributes()
await self._async_control_heating()
new_state = event.data.get("new_state") new_state = event.data.get("new_state")
_LOGGER.debug("%s - _async_climate_changed new_state is %s", self, new_state) _LOGGER.debug("%s - _async_climate_changed new_state is %s", self, new_state)
if not new_state: if not new_state:
@@ -1606,6 +1633,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
else None else None
) )
old_state_date_changed = old_state.last_changed if old_state and old_state.last_changed else None
old_state_date_updated = old_state.last_updated if old_state and old_state.last_updated else None
new_state_date_changed = new_state.last_changed if new_state and new_state.last_changed else None
new_state_date_updated = new_state.last_updated if new_state and new_state.last_updated else None
# Issue 99 - some AC turn hvac_mode=cool and hvac_action=idle when sending a HVACMode_OFF command # Issue 99 - some AC turn hvac_mode=cool and hvac_action=idle when sending a HVACMode_OFF command
# Issue 114 - Remove this because hvac_mode is now managed by local _hvac_mode and use idle action as is # Issue 114 - Remove this because hvac_mode is now managed by local _hvac_mode and use idle action as is
#if self._hvac_mode == HVACMode.OFF and new_hvac_action == HVACAction.IDLE: #if self._hvac_mode == HVACMode.OFF and new_hvac_action == HVACAction.IDLE:
@@ -1621,24 +1653,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
old_hvac_action, old_hvac_action,
) )
if new_hvac_mode in [ _LOGGER.debug("%s - last_change_time=%s old_state_date_changed=%s old_state_date_updated=%s new_state_date_changed=%s new_state_date_updated=%s", self, self._last_change_time, old_state_date_changed, old_state_date_updated, new_state_date_changed, new_state_date_updated)
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.HEAT_COOL,
HVACMode.DRY,
HVACMode.AUTO,
HVACMode.FAN_ONLY,
None
] and self._hvac_mode != new_hvac_mode:
changes = True
self._hvac_mode = new_hvac_mode
# Do not try to update all underlying state, else we will have a loop
if self._is_over_climate:
for under in self._underlyings:
await under.set_hvac_mode(new_hvac_mode)
# Interpretation of hvac # Interpretation of hvac action
HVAC_ACTION_ON = [ # pylint: disable=invalid-name HVAC_ACTION_ON = [ # pylint: disable=invalid-name
HVACAction.COOLING, HVACAction.COOLING,
HVACAction.DRYING, HVACAction.DRYING,
@@ -1677,18 +1694,43 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
) )
changes = True changes = True
# Issue #120 - Some TRV are chaning target temperature a very long time (6 sec) after the change.
# In that case a loop is possible if a user change multiple times during this 6 sec.
if new_state_date_updated and self._last_change_time:
delta = (new_state_date_updated - self._last_change_time).total_seconds()
if delta < 10:
_LOGGER.info("%s - underlying event is received less than 10 sec after command. Forget it to avoid loop", self
)
await end_climate_changed(changes)
return
if new_hvac_mode in [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.HEAT_COOL,
HVACMode.DRY,
HVACMode.AUTO,
HVACMode.FAN_ONLY,
None
] and self._hvac_mode != new_hvac_mode:
changes = True
self._hvac_mode = new_hvac_mode
# Update all underlyings state
if self._is_over_climate:
for under in self._underlyings:
await under.set_hvac_mode(new_hvac_mode)
if not changes: if not changes:
# try to manage new target temperature set if state # try to manage new target temperature set if state
_LOGGER.debug("Do temperature check. temperature is %s, new_state.attributes is %s", self.target_temperature, new_state.attributes) _LOGGER.debug("Do temperature check. temperature is %s, new_state.attributes is %s", self.target_temperature, new_state.attributes)
if self._is_over_climate and new_state.attributes and (new_target_temp := new_state.attributes.get("temperature")) and new_target_temp != self.target_temperature: if self._is_over_climate and new_state.attributes and (new_target_temp := new_state.attributes.get("temperature")) and new_target_temp != self.target_temperature:
_LOGGER.info("%s - Target temp have change to %s", self, new_target_temp) _LOGGER.info("%s - Target temp in underlying have change to %s", self, new_target_temp)
await self.async_set_temperature(temperature = new_target_temp) await self.async_set_temperature(temperature = new_target_temp)
changes = True changes = True
if changes: await end_climate_changed(changes)
self.async_write_ha_state()
self.update_custom_attributes()
await self._async_control_heating()
@callback @callback
async def _async_update_temp(self, state: State): async def _async_update_temp(self, state: State):
@@ -2103,9 +2145,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
now - self._last_ext_temperature_mesure.replace(tzinfo=self._current_tz) now - self._last_ext_temperature_mesure.replace(tzinfo=self._current_tz)
).total_seconds() / 60.0 ).total_seconds() / 60.0
# TODO before change:
# mode_cond = self._is_over_climate or self._hvac_mode != HVACMode.OFF
# fixed into this. Why if _is_over_climate we could into security even if HVACMode is OFF ?
mode_cond = self._hvac_mode != HVACMode.OFF mode_cond = self._hvac_mode != HVACMode.OFF
temp_cond: bool = ( temp_cond: bool = (
@@ -2299,14 +2338,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
"""A utility function to force the calculation of a the algo and """A utility function to force the calculation of a the algo and
update the custom attributes and write the state update the custom attributes and write the state
""" """
if self._is_over_climate:
self.update_custom_attributes()
return
_LOGGER.debug("%s - recalculate all", self) _LOGGER.debug("%s - recalculate all", self)
self._prop_algorithm.calculate( if not self._is_over_climate:
self._target_temp, self._cur_temp, self._cur_ext_temp self._prop_algorithm.calculate(
) self._target_temp, self._cur_temp, self._cur_ext_temp
)
self.update_custom_attributes() self.update_custom_attributes()
self.async_write_ha_state() self.async_write_ha_state()
@@ -2381,13 +2417,18 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
.astimezone(self._current_tz) .astimezone(self._current_tz)
.isoformat(), .isoformat(),
"timezone": str(self._current_tz), "timezone": str(self._current_tz),
"window_sensor_entity_id": self._window_sensor_entity_id,
"window_delay_sec": self._window_delay_sec, "window_delay_sec": self._window_delay_sec,
"window_auto_open_threshold": self._window_auto_open_threshold, "window_auto_open_threshold": self._window_auto_open_threshold,
"window_auto_close_threshold": self._window_auto_close_threshold, "window_auto_close_threshold": self._window_auto_close_threshold,
"window_auto_max_duration": self._window_auto_max_duration, "window_auto_max_duration": self._window_auto_max_duration,
"motion_sensor_entity_id": self._motion_sensor_entity_id,
"presence_sensor_entity_id": self._presence_sensor_entity_id,
"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: if self._is_over_climate:
self._attr_extra_state_attributes["underlying_climate_1"] = self._underlyings[ self._attr_extra_state_attributes["underlying_climate_0"] = self._underlyings[
0 0
].entity_id ].entity_id
self._attr_extra_state_attributes["underlying_climate_1"] = self._underlyings[ self._attr_extra_state_attributes["underlying_climate_1"] = self._underlyings[
@@ -2488,8 +2529,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
) )
# If the changed preset is active, change the current temperature # If the changed preset is active, change the current temperature
if self._attr_preset_mode == preset: # Issue #119 - reload new preset temperature also in ac mode
await self._async_set_preset_mode_internal(preset, force=True) if preset.startswith(self._attr_preset_mode):
await self._async_set_preset_mode_internal(preset.rstrip(PRESET_AC_SUFFIX), force=True)
await self._async_control_heating(force=True) await self._async_control_heating(force=True)
async def service_set_security(self, delay_min, min_on_percent, default_on_percent): async def service_set_security(self, delay_min, min_on_percent, default_on_percent):

View File

@@ -14,6 +14,6 @@
"quality_scale": "silver", "quality_scale": "silver",
"requirements": [], "requirements": [],
"ssdp": [], "ssdp": [],
"version": "3.0.0", "version": "3.5.3",
"zeroconf": [] "zeroconf": []
} }

View File

@@ -1,2 +1,2 @@
homeassistant==2023.10.1 homeassistant==2023.10.3
ffmpeg ffmpeg

View File

@@ -1,4 +1,5 @@
# Warning: For automatic run of test in Gitlab CI, we must not include other things that pytest-homeassistant-custom-component
-r requirements_dev.txt -r requirements_dev.txt
# aiodiscover aiodiscover
ulid_transform ulid_transform
pytest-homeassistant-custom-component pytest-homeassistant-custom-component

View File

@@ -530,9 +530,16 @@ async def test_bug_101(
await entity.async_set_preset_mode(PRESET_COMFORT) await entity.async_set_preset_mode(PRESET_COMFORT)
assert entity.preset_mode == PRESET_COMFORT assert entity.preset_mode == PRESET_COMFORT
# 2. Change the target temp of underlying thermostat # 2. Change the target temp of underlying thermostat at now -> the event will be disgarded because to fast (to avoid loop cf issue 121)
await send_climate_change_event_with_temperature(entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.OFF, HVACAction.OFF, now, 12.75) await send_climate_change_event_with_temperature(entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.OFF, HVACAction.OFF, now, 12.75)
# Should have been switched to Manual preset # Should NOT have been switched to Manual preset
assert entity.target_temperature == 17
assert entity.preset_mode is PRESET_COMFORT
# 2. Change the target temp of underlying thermostat at 11 sec later -> the event will be taken
# Wait 11 sec
event_timestamp = now + timedelta(seconds=11)
await send_climate_change_event_with_temperature(entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.OFF, HVACAction.OFF, event_timestamp, 12.75)
assert entity.target_temperature == 12.75 assert entity.target_temperature == 12.75
assert entity.preset_mode is PRESET_NONE assert entity.preset_mode is PRESET_NONE

View File

@@ -163,7 +163,7 @@ async def test_one_switch_cycle(
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
assert mock_heater_on.call_count == 1 assert mock_heater_on.call_count == 1
# TODO normal ? assert entity.underlying_entity(0)._should_relaunch_control_heating is False # normal ? assert entity.underlying_entity(0)._should_relaunch_control_heating is False
# Simulate the end of heater on cycle # Simulate the end of heater on cycle
event_timestamp = now - timedelta(minutes=3) event_timestamp = now - timedelta(minutes=3)
@@ -522,7 +522,9 @@ async def test_multiple_climates_underlying_changes(
), patch( ), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode: ) as mock_underlying_set_hvac_mode:
await send_climate_change_event(entity, HVACMode.OFF, HVACMode.HEAT, HVACAction.OFF, HVACAction.HEATING, now) # Wait 11 sec so that the event will not be discarded
event_timestamp = now + timedelta(seconds=11)
await send_climate_change_event(entity, HVACMode.OFF, HVACMode.HEAT, HVACAction.OFF, HVACAction.HEATING, event_timestamp)
# Should be call for all Switch # Should be call for all Switch
assert mock_underlying_set_hvac_mode.call_count == 4 assert mock_underlying_set_hvac_mode.call_count == 4
@@ -543,7 +545,9 @@ async def test_multiple_climates_underlying_changes(
# notice that there is no need of return_value=HVACAction.IDLE because this is not a function but a property # notice that there is no need of return_value=HVACAction.IDLE because this is not a function but a property
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.hvac_action", HVACAction.IDLE "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.hvac_action", HVACAction.IDLE
) as mock_underlying_get_hvac_action: ) as mock_underlying_get_hvac_action:
await send_climate_change_event(entity, HVACMode.HEAT, HVACMode.OFF, HVACAction.IDLE, HVACAction.OFF, now) # Wait 11 sec so that the event will not be discarded
event_timestamp = now + timedelta(seconds=11)
await send_climate_change_event(entity, HVACMode.HEAT, HVACMode.OFF, HVACAction.IDLE, HVACAction.OFF, event_timestamp)
# Should be call for all Switch # Should be call for all Switch
assert mock_underlying_set_hvac_mode.call_count == 4 assert mock_underlying_set_hvac_mode.call_count == 4

View File

@@ -3,5 +3,5 @@
"content_in_root": false, "content_in_root": false,
"render_readme": true, "render_readme": true,
"hide_default_branch": false, "hide_default_branch": false,
"homeassistant": "2023.7.3" "homeassistant": "2023.10.3"
} }