Compare commits

...

1 Commits

Author SHA1 Message Date
Erik
73f80eae05 Fix generic_thermostat context handling 2026-04-13 08:50:42 +02:00
2 changed files with 208 additions and 12 deletions

View File

@@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from functools import partial
import logging
import math
import time
from typing import Any
import voluptuous as vol
@@ -51,6 +52,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.entity import CONTEXT_RECENT_TIME_SECONDS
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -478,6 +480,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return
self.async_set_context(event.context)
self._async_update_temp(new_state)
await self._async_control_heating()
self.async_write_ha_state()
@@ -531,9 +534,11 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
_LOGGER.error("Unable to update from sensor: %s", ex)
async def _async_control_heating(
self, time: datetime | None = None, force: bool = False
self, _time: datetime | None = None, force: bool = False
) -> None:
"""Check if we need to turn heating on or off."""
called_by_timer = _time is not None
async with self._temp_lock:
if not self._active and None not in (
self._cur_temp,
@@ -552,7 +557,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
if not self._active or self._hvac_mode == HVACMode.OFF:
return
if force and time is not None and self.max_cycle_duration:
if force and called_by_timer and self.max_cycle_duration:
# We were invoked due to `max_cycle_duration`, so turn off
_LOGGER.debug(
"Turning off heater %s due to max cycle time of %s",
@@ -587,7 +592,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
now - self._last_toggled_time + self.min_cycle_duration,
self._async_timer_control_heating,
)
elif time is not None:
elif called_by_timer:
# This is a keep-alive call, so ensure it's on
_LOGGER.debug(
"Keep-alive - Turning on heater %s",
@@ -609,7 +614,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
now - self._last_toggled_time + self.cycle_cooldown,
self._async_timer_control_heating,
)
elif time is not None:
elif called_by_timer:
# This is a keep-alive call, so ensure it's off
_LOGGER.debug(
"Keep-alive - Turning off heater %s", self.heater_entity_id
@@ -624,13 +629,25 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
return self.hass.states.is_state(self.heater_entity_id, STATE_ON)
def _get_current_context(self) -> Context | None:
"""Return the current context if it is still recent, or None."""
if (
self._context_set is not None
and time.time() - self._context_set > CONTEXT_RECENT_TIME_SECONDS
):
self._context = None
self._context_set = None
return self._context
async def _async_heater_turn_on(self, keepalive: bool = False) -> None:
"""Turn heater toggleable device on."""
data = {ATTR_ENTITY_ID: self.heater_entity_id}
# Create a new context for this service call so we can identify
# the resulting state change event as originating from us
new_context = Context(parent_id=self._context.id if self._context else None)
self.async_set_context(new_context)
# Create a child context for the switch service call so we can
# identify the resulting state change event as originating from us.
# Don't set it as our own context — the climate entity's state changes
# should remain attributed to the parent context (e.g., set_hvac_mode).
current_context = self._get_current_context()
new_context = Context(parent_id=current_context.id if current_context else None)
self._last_context_id = new_context.id
await self.hass.services.async_call(
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context
@@ -654,10 +671,12 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
async def _async_heater_turn_off(self, keepalive: bool = False) -> None:
"""Turn heater toggleable device off."""
data = {ATTR_ENTITY_ID: self.heater_entity_id}
# Create a new context for this service call so we can identify
# the resulting state change event as originating from us
new_context = Context(parent_id=self._context.id if self._context else None)
self.async_set_context(new_context)
# Create a child context for the switch service call so we can
# identify the resulting state change event as originating from us.
# Don't set it as our own context — the climate entity's state changes
# should remain attributed to the parent context (e.g., set_hvac_mode).
current_context = self._get_current_context()
new_context = Context(parent_id=current_context.id if current_context else None)
self._last_context_id = new_context.id
await self.hass.services.async_call(
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context

View File

@@ -36,6 +36,7 @@ from homeassistant.const import (
)
from homeassistant.core import (
DOMAIN as HOMEASSISTANT_DOMAIN,
Context,
CoreState,
HomeAssistant,
ServiceCall,
@@ -55,6 +56,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
from tests.common import (
MockConfigEntry,
MockUser,
assert_setup_component,
async_fire_time_changed,
async_mock_service,
@@ -1784,3 +1786,178 @@ async def test_device_id(
helper_entity = entity_registry.async_get("climate.test")
assert helper_entity is not None
assert helper_entity.device_id == source_entity.device_id
@pytest.mark.usefixtures("setup_comp_1")
async def test_hvac_mode_change_user_context(
hass: HomeAssistant, hass_admin_user: MockUser
) -> None:
"""Test user context is preserved through the full chain.
Full chain:
1. User calls set_hvac_mode → parent context (has user_id)
2. Generic thermostat calls homeassistant.turn_on → child context (no user_id)
3. Switch state changes → child context
4. Climate state updates in response → child context
"""
heater_switch = "input_boolean.test"
assert await async_setup_component(
hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}}
)
assert await async_setup_component(
hass,
CLIMATE_DOMAIN,
{
"climate": {
"platform": "generic_thermostat",
"name": "test",
"heater": heater_switch,
"target_sensor": ENT_SENSOR,
"initial_hvac_mode": HVACMode.OFF,
"cold_tolerance": 2,
"hot_tolerance": 4,
}
},
)
await hass.async_block_till_done()
# Set sensor below target so heating triggers on mode change
_setup_sensor(hass, 18)
await hass.async_block_till_done()
await common.async_set_temperature(hass, 23)
await hass.async_block_till_done()
assert hass.states.get(heater_switch).state == STATE_OFF
# Change HVAC mode with a user context
user_context = Context(user_id=hass_admin_user.id)
await hass.services.async_call(
CLIMATE_DOMAIN,
"set_hvac_mode",
{"entity_id": ENTITY, "hvac_mode": HVACMode.HEAT},
blocking=True,
context=user_context,
)
await hass.async_block_till_done()
# Step 2: The heater should have been turned on
assert hass.states.get(heater_switch).state == STATE_ON
# The switch state change should have a child context with the
# user context as parent
switch_state = hass.states.get(heater_switch)
child_context = switch_state.context
assert child_context.id != user_context.id
assert child_context.parent_id == user_context.id
# Step 4: The climate entity should keep the parent (user) context,
# not the child context created for the switch service call
climate_state = hass.states.get(ENTITY)
assert climate_state.context.id == user_context.id
assert climate_state.context.user_id == hass_admin_user.id
@pytest.mark.usefixtures("setup_comp_1")
async def test_sensor_change_inherits_context(hass: HomeAssistant) -> None:
"""Test that sensor changes set the sensor's context on the thermostat.
When the sensor updates, the thermostat should inherit the sensor's
context so the resulting switch toggle has the sensor context as parent.
"""
heater_switch = "input_boolean.test"
assert await async_setup_component(
hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}}
)
assert await async_setup_component(
hass,
CLIMATE_DOMAIN,
{
"climate": {
"platform": "generic_thermostat",
"name": "test",
"heater": heater_switch,
"target_sensor": ENT_SENSOR,
"initial_hvac_mode": HVACMode.HEAT,
"cold_tolerance": 2,
"hot_tolerance": 4,
}
},
)
await hass.async_block_till_done()
await common.async_set_temperature(hass, 30)
await hass.async_block_till_done()
assert hass.states.get(heater_switch).state == STATE_OFF
# Set sensor below target with a specific context
sensor_context = Context()
hass.states.async_set(ENT_SENSOR, "18", context=sensor_context)
await hass.async_block_till_done()
# The heater should have turned on
assert hass.states.get(heater_switch).state == STATE_ON
# The switch state change should have a child context with the
# sensor context as parent
switch_state = hass.states.get(heater_switch)
assert switch_state.context.parent_id == sensor_context.id
assert switch_state.context.id != sensor_context.id
@pytest.mark.usefixtures("setup_comp_1")
async def test_stale_context_not_used_as_parent(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test that an expired context is not used as parent for the switch call.
When a keepalive timer fires long after the last user interaction,
the thermostat should not link the switch service call to the old
user context.
"""
heater_switch = "input_boolean.test"
assert await async_setup_component(
hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}}
)
assert await async_setup_component(
hass,
CLIMATE_DOMAIN,
{
"climate": {
"platform": "generic_thermostat",
"name": "test",
"heater": heater_switch,
"target_sensor": ENT_SENSOR,
"initial_hvac_mode": HVACMode.HEAT,
"cold_tolerance": 2,
"hot_tolerance": 4,
"keep_alive": datetime.timedelta(minutes=10),
}
},
)
await hass.async_block_till_done()
# Set temperature and sensor so heater is on
await common.async_set_temperature(hass, 30)
_setup_sensor(hass, 18)
await hass.async_block_till_done()
assert hass.states.get(heater_switch).state == STATE_ON
# Capture service calls to check the keepalive context
calls = async_mock_service(hass, ha.DOMAIN, SERVICE_TURN_ON)
# Advance time past keepalive interval (10 min) and context expiry (5s)
freezer.tick(datetime.timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# The keepalive should have sent a turn_on call
assert len(calls) == 1
assert calls[0].data["entity_id"] == heater_switch
# The keepalive call's context should have no parent,
# because the original context expired long ago
assert calls[0].context.parent_id is None