mirror of
https://github.com/home-assistant/core.git
synced 2026-04-13 13:16:15 +02:00
Compare commits
1 Commits
dev
...
correct_ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73f80eae05 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user