Compare commits

..

7 Commits

Author SHA1 Message Date
Erik
26874335ca Improve comments in trigger and condition test helpers 2026-01-13 08:29:04 +01:00
Erik Montnemery
a6221d16b6 Add helper for creating entity condition tests (#160425) 2026-01-13 08:25:41 +01:00
tronikos
51701cab7c Bump opower to 0.16.2 (#160822) 2026-01-12 19:20:06 -08:00
Raphael Hehl
010e1f2d0d Bump uiprotect to 8.1.1 (#160816) 2026-01-12 23:06:50 +01:00
Jonathan de Jong
66909fc9ca Support HVAC mode in set temperature calls in Mill (#155416)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-12 21:46:20 +01:00
Lukas
90a28c95c8 Bump python-pooldose to 0.8.2 (#160800) 2026-01-12 20:20:33 +01:00
Erik Montnemery
83f2c53e8c Disable pyright type checking in VS Code (#160528) 2026-01-12 20:19:19 +01:00
15 changed files with 754 additions and 601 deletions

View File

@@ -40,7 +40,8 @@
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment",
"python.analysis.typeCheckingMode": "basic",
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
"python.analysis.typeCheckingMode": "off",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,

View File

@@ -7,8 +7,8 @@
"python.testing.pytestEnabled": false,
// https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment",
// Pyright is too pedantic for Home Assistant
"python.analysis.typeCheckingMode": "basic",
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
"python.analysis.typeCheckingMode": "off",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
},

View File

@@ -173,13 +173,6 @@ EVSE_FAULT_STATE_MAP = {
clusters.EnergyEvse.Enums.FaultStateEnum.kOther: "other",
}
SETPOINT_CHANGE_SOURCE_MAP = {
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kManual: "manual",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kSchedule: "schedule",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kExternal: "external",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kUnknownEnumValue: None,
}
PUMP_CONTROL_MODE_MAP = {
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantSpeed: "constant_speed",
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantPressure: "constant_pressure",
@@ -1548,48 +1541,4 @@ DISCOVERY_SCHEMAS = [
required_attributes=(clusters.DoorLock.Attributes.DoorClosedEvents,),
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSource",
translation_key="setpoint_change_source",
device_class=SensorDeviceClass.ENUM,
state_class=None,
options=[x for x in SETPOINT_CHANGE_SOURCE_MAP.values() if x is not None],
device_to_ha=lambda x: SETPOINT_CHANGE_SOURCE_MAP[x],
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeSource,),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSourceTimestamp",
translation_key="setpoint_change_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=None,
device_to_ha=matter_epoch_seconds_to_utc,
),
entity_class=MatterSensor,
required_attributes=(
clusters.Thermostat.Attributes.SetpointChangeSourceTimestamp,
),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ThermostatSetpointChangeAmount",
translation_key="setpoint_change_amount",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
device_class=SensorDeviceClass.TEMPERATURE,
device_to_ha=lambda x: x / TEMPERATURE_SCALING_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeAmount,),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
),
]

View File

@@ -549,20 +549,6 @@
"rms_voltage": {
"name": "Effective voltage"
},
"setpoint_change_amount": {
"name": "Last change amount"
},
"setpoint_change_source": {
"name": "Last change source",
"state": {
"external": "External",
"manual": "Manual",
"schedule": "Schedule"
}
},
"setpoint_change_timestamp": {
"name": "Last change"
},
"switch_current_position": {
"name": "Current switch position"
},

View File

@@ -7,6 +7,7 @@ from mill_local import OperationMode
import voluptuous as vol
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -111,13 +112,16 @@ class MillHeater(MillBaseEntity, ClimateEntity):
super().__init__(coordinator, device)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
"""Set new target temperature and optionally HVAC mode."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.mill_data_connection.set_heater_temp(
self._id, float(temperature)
)
await self.coordinator.async_request_refresh()
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
await self.async_handle_set_hvac_mode_service(hvac_mode)
else:
await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -125,12 +129,11 @@ class MillHeater(MillBaseEntity, ClimateEntity):
await self.coordinator.mill_data_connection.heater_control(
self._id, power_status=True
)
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.heater_control(
self._id, power_status=False
)
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
@callback
def _update_attr(self, device: mill.Heater) -> None:
@@ -189,25 +192,26 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit
self._update_attr()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
"""Set new target temperature and optionally HVAC mode."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.mill_data_connection.set_target_temperature(
float(temperature)
)
await self.coordinator.async_request_refresh()
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
await self.async_handle_set_hvac_mode_service(hvac_mode)
else:
await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.HEAT:
await self.coordinator.mill_data_connection.set_operation_mode_control_individually()
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.set_operation_mode_off()
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.AUTO:
await self.coordinator.mill_data_connection.set_operation_mode_weekly_program()
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
@callback
def _handle_coordinator_update(self) -> None:

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.16.1"]
"requirements": ["opower==0.16.2"]
}

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "gold",
"requirements": ["python-pooldose==0.8.1"]
"requirements": ["python-pooldose==0.8.2"]
}

View File

@@ -41,7 +41,7 @@
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"quality_scale": "platinum",
"requirements": ["uiprotect==8.0.0", "unifi-discovery==1.2.0"],
"requirements": ["uiprotect==8.1.1", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

6
requirements_all.txt generated
View File

@@ -1684,7 +1684,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
opower==0.16.1
opower==0.16.2
# homeassistant.components.oralb
oralb-ble==1.0.2
@@ -2575,7 +2575,7 @@ python-overseerr==0.8.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.8.1
python-pooldose==0.8.2
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -3081,7 +3081,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==8.0.0
uiprotect==8.1.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7

View File

@@ -1458,7 +1458,7 @@ openrgb-python==0.3.6
openwebifpy==4.3.1
# homeassistant.components.opower
opower==0.16.1
opower==0.16.2
# homeassistant.components.oralb
oralb-ble==1.0.2
@@ -2165,7 +2165,7 @@ python-overseerr==0.8.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.8.1
python-pooldose==0.8.2
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2575,7 +2575,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==8.0.0
uiprotect==8.1.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7

View File

@@ -167,9 +167,93 @@ class _StateDescription(TypedDict):
class StateDescription(TypedDict):
"""Test state and expected service call count."""
included: _StateDescription
excluded: _StateDescription
count: int
included: _StateDescription # State for entities meant to be targeted
excluded: _StateDescription # State for entities not meant to be targeted
count: int # Expected service call count
class ConditionStateDescription(TypedDict):
"""Test state and expected service call count."""
included: _StateDescription # State for entities meant to be targeted
excluded: _StateDescription # State for entities not meant to be targeted
condition_true: bool # Whether the condition is expected to evaluate to true
state_valid: bool # Whether the state is valid (not None, unavailable or unknown)
def parametrize_condition_states(
*,
condition: str,
condition_options: dict[str, Any] | None = None,
target_states: list[str | None | tuple[str | None, dict]],
other_states: list[str | None | tuple[str | None, dict]],
additional_attributes: dict | None = None,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
"""Parametrize states and expected service call counts.
The target_states and other_states iterables are either iterables of
states or iterables of (state, attributes) tuples.
Returns a list of tuples with (condition, condition options, list of states),
where states is a list of ConditionStateDescription dicts.
"""
additional_attributes = additional_attributes or {}
condition_options = condition_options or {}
def state_with_attributes(
state: str | None | tuple[str | None, dict],
condition_true: bool,
state_valid: bool,
) -> ConditionStateDescription:
"""Return ConditionStateDescription dict."""
if isinstance(state, str) or state is None:
return {
"included": {
"state": state,
"attributes": additional_attributes,
},
"excluded": {
"state": state,
"attributes": {},
},
"condition_true": condition_true,
"state_valid": state_valid,
}
return {
"included": {
"state": state[0],
"attributes": state[1] | additional_attributes,
},
"excluded": {
"state": state[0],
"attributes": state[1],
},
"condition_true": condition_true,
"state_valid": state_valid,
}
return [
(
condition,
condition_options,
list(
itertools.chain(
(state_with_attributes(None, False, False),),
(state_with_attributes(STATE_UNAVAILABLE, False, False),),
(state_with_attributes(STATE_UNKNOWN, False, False),),
(
state_with_attributes(other_state, False, True)
for other_state in other_states
),
(
state_with_attributes(target_state, True, True)
for target_state in target_states
),
)
),
),
]
def parametrize_trigger_states(
@@ -202,8 +286,8 @@ def parametrize_trigger_states(
def state_with_attributes(
state: str | None | tuple[str | None, dict], count: int
) -> dict:
"""Return (state, attributes) dict."""
) -> StateDescription:
"""Return StateDescription dict."""
if isinstance(state, str) or state is None:
return {
"included": {

View File

@@ -1,6 +1,7 @@
"""Test light conditions."""
from collections.abc import Generator
from typing import Any
from unittest.mock import patch
import pytest
@@ -13,24 +14,18 @@ from homeassistant.const import (
CONF_TARGET,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
ConditionStateDescription,
parametrize_condition_states,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
INVALID_STATES = [
{"state": STATE_UNAVAILABLE, "attributes": {}},
{"state": STATE_UNKNOWN, "attributes": {}},
{"state": None, "attributes": {}},
]
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
@@ -76,15 +71,15 @@ async def setup_automation_with_light_condition(
)
async def has_call_after_trigger(
async def has_single_call_after_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> bool:
"""Check if there are service calls after the trigger event."""
"""Check if there is a single service call after the trigger event."""
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
has_calls = len(service_calls) == 1
num_calls = len(service_calls)
service_calls.clear()
return has_calls
return num_calls == 1
@pytest.fixture(name="enable_experimental_triggers_conditions")
@@ -125,17 +120,17 @@ async def test_light_conditions_gated_by_labs_flag(
parametrize_target_entities("light"),
)
@pytest.mark.parametrize(
("condition", "target_state", "other_state"),
("condition", "condition_options", "states"),
[
(
"light.is_on",
{"state": STATE_ON, "attributes": {}},
{"state": STATE_OFF, "attributes": {}},
*parametrize_condition_states(
condition="light.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
(
"light.is_off",
{"state": STATE_OFF, "attributes": {}},
{"state": STATE_ON, "attributes": {}},
*parametrize_condition_states(
condition="light.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
@@ -148,15 +143,15 @@ async def test_light_state_condition_behavior_any(
entity_id: str,
entities_in_target: int,
condition: str,
target_state: str,
other_state: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the light state condition with the 'any' behavior."""
other_entity_ids = set(target_lights) - {entity_id}
# Set all lights, including the tested light, to the initial state
for eid in target_lights:
set_or_remove_state(hass, eid, other_state)
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await setup_automation_with_light_condition(
@@ -167,38 +162,29 @@ async def test_light_state_condition_behavior_any(
)
# Set state for switches to ensure that they don't impact the condition
for eid in target_switches:
set_or_remove_state(hass, eid, other_state)
await hass.async_block_till_done()
assert not await has_call_after_trigger(hass, service_calls)
for state in states:
for eid in target_switches:
set_or_remove_state(hass, eid, state["included"])
await hass.async_block_till_done()
assert not await has_single_call_after_trigger(hass, service_calls)
for eid in target_switches:
set_or_remove_state(hass, eid, target_state)
await hass.async_block_till_done()
assert not await has_call_after_trigger(hass, service_calls)
# Set one light to the condition state -> condition pass
set_or_remove_state(hass, entity_id, target_state)
assert await has_call_after_trigger(hass, service_calls)
# Set all remaining lights to the condition state -> condition pass
for eid in other_entity_ids:
set_or_remove_state(hass, eid, target_state)
assert await has_call_after_trigger(hass, service_calls)
for invalid_state in INVALID_STATES:
# Set one light to the invalid state -> condition pass if there are
# other lights in the condition state
set_or_remove_state(hass, entity_id, invalid_state)
assert await has_call_after_trigger(hass, service_calls) == bool(
entities_in_target - 1
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert (
await has_single_call_after_trigger(hass, service_calls)
== state["condition_true"]
)
for invalid_state in INVALID_STATES:
# Set all lights to invalid state -> condition fail
for eid in other_entity_ids:
set_or_remove_state(hass, eid, invalid_state)
assert not await has_call_after_trigger(hass, service_calls)
# Check if changing other lights also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert (
await has_single_call_after_trigger(hass, service_calls)
== state["condition_true"]
)
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@@ -207,17 +193,17 @@ async def test_light_state_condition_behavior_any(
parametrize_target_entities("light"),
)
@pytest.mark.parametrize(
("condition", "target_state", "other_state"),
("condition", "condition_options", "states"),
[
(
"light.is_on",
{"state": STATE_ON, "attributes": {}},
{"state": STATE_OFF, "attributes": {}},
*parametrize_condition_states(
condition="light.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
(
"light.is_off",
{"state": STATE_OFF, "attributes": {}},
{"state": STATE_ON, "attributes": {}},
*parametrize_condition_states(
condition="light.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
@@ -229,8 +215,8 @@ async def test_light_state_condition_behavior_all(
entity_id: str,
entities_in_target: int,
condition: str,
target_state: str,
other_state: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the light state condition with the 'all' behavior."""
# Set state for two switches to ensure that they don't impact the condition
@@ -241,7 +227,7 @@ async def test_light_state_condition_behavior_all(
# Set all lights, including the tested light, to the initial state
for eid in target_lights:
set_or_remove_state(hass, eid, other_state)
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await setup_automation_with_light_condition(
@@ -251,27 +237,22 @@ async def test_light_state_condition_behavior_all(
behavior="all",
)
# No lights on the condition state
assert not await has_call_after_trigger(hass, service_calls)
for state in states:
included_state = state["included"]
# Set one light to the condition state -> condition fail
set_or_remove_state(hass, entity_id, target_state)
assert await has_call_after_trigger(hass, service_calls) == (
entities_in_target == 1
)
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
# The condition passes if all entities are either in a target state or invalid
assert await has_single_call_after_trigger(hass, service_calls) == (
(not state["state_valid"])
or (state["condition_true"] and entities_in_target == 1)
)
# Set all remaining lights to the condition state -> condition pass
for eid in other_entity_ids:
set_or_remove_state(hass, eid, target_state)
assert await has_call_after_trigger(hass, service_calls)
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for invalid_state in INVALID_STATES:
# Set one light to the invalid state -> condition still pass
set_or_remove_state(hass, entity_id, invalid_state)
assert await has_call_after_trigger(hass, service_calls)
for invalid_state in INVALID_STATES:
# Set all lights to unavailable -> condition passes
for eid in other_entity_ids:
set_or_remove_state(hass, eid, invalid_state)
assert await has_call_after_trigger(hass, service_calls)
# The condition passes if all entities are either in a target state or invalid
assert await has_single_call_after_trigger(hass, service_calls) == (
(not state["state_valid"]) or state["condition_true"]
)

View File

@@ -5174,171 +5174,6 @@
'state': '100',
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last change',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_timestamp',
'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-SetpointChangeSourceTimestamp-513-50',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Eve Thermo 20ECD1701 Last change',
}),
'context': <ANY>,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change_amount-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change_amount',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Last change amount',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_amount',
'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-ThermostatSetpointChangeAmount-513-49',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change_amount-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Eve Thermo 20ECD1701 Last change amount',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change_amount',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change_source-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'manual',
'schedule',
'external',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change_source',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Last change source',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_source',
'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-SetpointChangeSource-513-48',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change_source-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Eve Thermo 20ECD1701 Last change source',
'options': list([
'manual',
'schedule',
'external',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change_source',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'manual',
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -8906,171 +8741,6 @@
'state': '25',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_last_change',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last change',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_timestamp',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-SetpointChangeSourceTimestamp-513-50',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Mock Thermostat Last change',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-01-01T00:00:00+00:00',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_amount-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_last_change_amount',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Last change amount',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_amount',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatSetpointChangeAmount-513-49',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_amount-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Thermostat Last change amount',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change_amount',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.5',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_source-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'manual',
'schedule',
'external',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_last_change_source',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Last change source',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_source',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-SetpointChangeSource-513-48',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_source-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Mock Thermostat Last change source',
'options': list([
'manual',
'schedule',
'external',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change_source',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'manual',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_outdoor_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -233,100 +233,6 @@ async def test_eve_thermo_sensor(
assert state.state == "18.0"
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"])
async def test_eve_thermo_v5_setpoint_change_source(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Eve Thermo v5 SetpointChangeSource sensor."""
entity_id = "sensor.eve_thermo_20ecd1701_last_change_source"
# Initial state and options
state = hass.states.get(entity_id)
assert state
assert state.state == "manual"
assert state.attributes["options"] == ["manual", "schedule", "external"]
# Change to schedule
set_node_attribute(matter_node, 1, 513, 48, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "schedule"
# Change to external
set_node_attribute(matter_node, 1, 513, 48, 2)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "external"
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"])
async def test_eve_thermo_v5_setpoint_change_timestamp(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Eve Thermo v5 SetpointChangeSourceTimestamp sensor."""
entity_id = "sensor.eve_thermo_20ecd1701_last_change"
# Initial is unknown per snapshot
state = hass.states.get(entity_id)
assert state
assert state.state == "unknown"
# Update to 2024-01-01 00:00:00+00:00 (Matter epoch seconds since 2000)
set_node_attribute(matter_node, 1, 513, 50, 757382400)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "2024-01-01T00:00:00+00:00"
# Set to zero should yield unknown
set_node_attribute(matter_node, 1, 513, 50, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "unknown"
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"])
async def test_eve_thermo_v5_setpoint_change_amount(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Eve Thermo v5 SetpointChangeAmount sensor."""
entity_id = "sensor.eve_thermo_20ecd1701_last_change_amount"
# Initial per snapshot
state = hass.states.get(entity_id)
assert state
assert state.state == "0.0"
# Update to 2.0°C (200 in Matter units)
set_node_attribute(matter_node, 1, 513, 49, 200)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "2.0"
# Update to -0.5°C (-50 in Matter units)
set_node_attribute(matter_node, 1, 513, 49, -50)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "-0.5"
@pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"])
async def test_thermostat_outdoor(
hass: HomeAssistant,

View File

@@ -0,0 +1,572 @@
"""Tests for Mill climate."""
import contextlib
from contextlib import nullcontext
from unittest.mock import MagicMock, call, patch
from mill import Heater
from mill_local import OperationMode
import pytest
from homeassistant.components import mill
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
)
from homeassistant.components.mill.const import DOMAIN
from homeassistant.components.recorder import Recorder
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.common import MockConfigEntry
HEATER_ID = "dev_id"
HEATER_NAME = "heater_name"
ENTITY_CLIMATE = f"climate.{HEATER_NAME}"
TEST_SET_TEMPERATURE = 25
TEST_AMBIENT_TEMPERATURE = 20
NULL_EFFECT = nullcontext()
## MILL AND LOCAL MILL FIXTURES
@pytest.fixture
async def mock_mill():
"""Mock the mill.Mill object.
It is imported and initialized only in /homeassistant/components/mill/__init__.py
"""
with (
patch(
"homeassistant.components.mill.Mill",
autospec=True,
) as mock_mill_class,
):
mill = mock_mill_class.return_value
mill.connect.return_value = True
mill.fetch_heater_and_sensor_data.return_value = {}
mill.fetch_historic_energy_usage.return_value = {}
yield mill
@pytest.fixture
async def mock_mill_local():
"""Mock the mill_local.Mill object."""
with (
patch(
"homeassistant.components.mill.MillLocal",
autospec=True,
) as mock_mill_local_class,
):
milllocal = mock_mill_local_class.return_value
milllocal.url = "http://dummy.url"
milllocal.name = HEATER_NAME
milllocal.mac_address = "dead:beef"
milllocal.version = "0x210927"
milllocal.connect.return_value = {
"name": milllocal.name,
"mac_address": milllocal.mac_address,
"version": milllocal.version,
"operation_key": "",
"status": "ok",
}
status = {
"ambient_temperature": TEST_AMBIENT_TEMPERATURE,
"set_temperature": TEST_AMBIENT_TEMPERATURE,
"current_power": 0,
"control_signal": 0,
"raw_ambient_temperature": TEST_AMBIENT_TEMPERATURE,
"operation_mode": OperationMode.OFF.value,
}
milllocal.fetch_heater_and_sensor_data.return_value = status
milllocal._status = status
yield milllocal
## CLOUD HEATER INTEGRATION
@pytest.fixture
async def cloud_heater(hass: HomeAssistant, mock_mill: MagicMock) -> Heater:
"""Load Mill integration and creates one cloud heater."""
heater = Heater(
name=HEATER_NAME,
device_id=HEATER_ID,
available=True,
is_heating=False,
power_status=False,
current_temp=float(TEST_AMBIENT_TEMPERATURE),
set_temp=float(TEST_AMBIENT_TEMPERATURE),
)
devices = {HEATER_ID: heater}
mock_mill.fetch_heater_and_sensor_data.return_value = devices
mock_mill.devices = devices
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
mill.CONF_USERNAME: "user",
mill.CONF_PASSWORD: "pswd",
mill.CONNECTION_TYPE: mill.CLOUD,
},
)
config_entry.add_to_hass(hass)
# We just need to load the climate component.
with patch("homeassistant.components.mill.PLATFORMS", [Platform.CLIMATE]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return heater
@pytest.fixture
async def cloud_heater_set_temp(mock_mill: MagicMock, cloud_heater: MagicMock):
"""Gets mock for the cloud heater `set_heater_temp` method."""
return mock_mill.set_heater_temp
@pytest.fixture
async def cloud_heater_control(mock_mill: MagicMock, cloud_heater: MagicMock):
"""Gets mock for the cloud heater `heater_control` method."""
return mock_mill.heater_control
@pytest.fixture
async def functional_cloud_heater(
cloud_heater: MagicMock,
cloud_heater_set_temp: MagicMock,
cloud_heater_control: MagicMock,
) -> Heater:
"""Make sure the cloud heater is "functional".
This will create a pseudo-functional cloud heater,
meaning that function calls will edit the original cloud heater
in a similar way that the API would.
"""
def calculate_heating():
if (
cloud_heater.power_status
and cloud_heater.set_temp > cloud_heater.current_temp
):
cloud_heater.is_heating = True
def set_temperature(device_id: str, set_temp: float):
assert device_id == HEATER_ID, "set_temperature called with wrong device_id"
cloud_heater.set_temp = set_temp
calculate_heating()
def heater_control(device_id: str, power_status: bool):
assert device_id == HEATER_ID, "set_temperature called with wrong device_id"
# power_status gives the "do we want to heat, Y/N", while is_heating is based on temperature and internal state and whatnot.
cloud_heater.power_status = power_status
calculate_heating()
cloud_heater_set_temp.side_effect = set_temperature
cloud_heater_control.side_effect = heater_control
return cloud_heater
## LOCAL HEATER INTEGRATION
@pytest.fixture
async def local_heater(hass: HomeAssistant, mock_mill_local: MagicMock) -> dict:
"""Local Mill Heater.
This returns a by-reference status dict
with which this heater's information is organised and updated.
"""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
mill.CONF_IP_ADDRESS: "192.168.1.59",
mill.CONNECTION_TYPE: mill.LOCAL,
},
)
config_entry.add_to_hass(hass)
# We just need to load the climate component.
with patch("homeassistant.components.mill.PLATFORMS", [Platform.CLIMATE]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return mock_mill_local._status
@pytest.fixture
async def local_heater_set_target_temperature(
mock_mill_local: MagicMock, local_heater: MagicMock
):
"""Gets mock for the local heater `set_target_temperature` method."""
return mock_mill_local.set_target_temperature
@pytest.fixture
async def local_heater_set_mode_control_individually(
mock_mill_local: MagicMock, local_heater: MagicMock
):
"""Gets mock for the local heater `set_operation_mode_control_individually` method."""
return mock_mill_local.set_operation_mode_control_individually
@pytest.fixture
async def local_heater_set_mode_off(
mock_mill_local: MagicMock, local_heater: MagicMock
):
"""Gets mock for the local heater `set_operation_mode_off` method."""
return mock_mill_local.set_operation_mode_off
@pytest.fixture
async def functional_local_heater(
mock_mill_local: MagicMock,
local_heater_set_target_temperature: MagicMock,
local_heater_set_mode_control_individually: MagicMock,
local_heater_set_mode_off: MagicMock,
local_heater: MagicMock,
) -> None:
"""Make sure the local heater is "functional".
This will create a pseudo-functional local heater,
meaning that function calls will edit the original local heater
in a similar way that the API would.
"""
def set_temperature(target_temperature: float):
local_heater["set_temperature"] = target_temperature
def set_operation_mode(operation_mode: OperationMode):
local_heater["operation_mode"] = operation_mode.value
def mode_control_individually():
set_operation_mode(OperationMode.CONTROL_INDIVIDUALLY)
def mode_off():
set_operation_mode(OperationMode.OFF)
local_heater_set_target_temperature.side_effect = set_temperature
local_heater_set_mode_control_individually.side_effect = mode_control_individually
local_heater_set_mode_off.side_effect = mode_off
### CLOUD
@pytest.mark.parametrize(
(
"before_state",
"before_attrs",
"service_name",
"service_params",
"effect",
"heater_control_calls",
"heater_set_temp_calls",
"after_state",
"after_attrs",
),
[
# set_hvac_mode
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.HEAT},
NULL_EFFECT,
[call(HEATER_ID, power_status=True)],
[],
HVACMode.HEAT,
{},
),
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.OFF},
NULL_EFFECT,
[call(HEATER_ID, power_status=False)],
[],
HVACMode.OFF,
{},
),
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.COOL},
pytest.raises(HomeAssistantError),
[],
[],
HVACMode.OFF,
{},
),
# set_temperature (with hvac mode)
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.HEAT},
NULL_EFFECT,
[call(HEATER_ID, power_status=True)],
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
HVACMode.HEAT,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.OFF},
NULL_EFFECT,
[call(HEATER_ID, power_status=False)],
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
NULL_EFFECT,
[],
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.COOL},
pytest.raises(HomeAssistantError),
# MillHeater will set the temperature before calling async_handle_set_hvac_mode,
# meaning an invalid HVAC mode will raise only after the temperature is set.
[],
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
# likewise, in this test, it hasn't had the chance to update its ambient temperature,
# because the exception is raised before a refresh can be requested from the coordinator
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
),
],
)
async def test_cloud_heater(
recorder_mock: Recorder,
hass: HomeAssistant,
functional_cloud_heater: MagicMock,
cloud_heater_control: MagicMock,
cloud_heater_set_temp: MagicMock,
before_state: HVACMode,
before_attrs: dict,
service_name: str,
service_params: dict,
effect: "contextlib.AbstractContextManager",
heater_control_calls: list,
heater_set_temp_calls: list,
after_state: HVACMode,
after_attrs: dict,
) -> None:
"""Tests setting HVAC mode (directly or through set_temperature) for a cloud heater."""
state = hass.states.get(ENTITY_CLIMATE)
assert state is not None
assert state.state == before_state
for attr, value in before_attrs.items():
assert state.attributes.get(attr) == value
with effect:
await hass.services.async_call(
CLIMATE_DOMAIN,
service_name,
service_params | {ATTR_ENTITY_ID: ENTITY_CLIMATE},
blocking=True,
)
await hass.async_block_till_done()
cloud_heater_control.assert_has_calls(heater_control_calls)
cloud_heater_set_temp.assert_has_calls(heater_set_temp_calls)
state = hass.states.get(ENTITY_CLIMATE)
assert state is not None
assert state.state == after_state
for attr, value in after_attrs.items():
assert state.attributes.get(attr) == value
### LOCAL
@pytest.mark.parametrize(
(
"before_state",
"before_attrs",
"service_name",
"service_params",
"effect",
"heater_mode_set_individually_calls",
"heater_mode_set_off_calls",
"heater_set_target_temperature_calls",
"after_state",
"after_attrs",
),
[
# set_hvac_mode
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.HEAT},
NULL_EFFECT,
[call()],
[],
[],
HVACMode.HEAT,
{},
),
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.OFF},
NULL_EFFECT,
[],
[call()],
[],
HVACMode.OFF,
{},
),
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.COOL},
pytest.raises(HomeAssistantError),
[],
[],
[],
HVACMode.OFF,
{},
),
# set_temperature (with hvac mode)
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.HEAT},
NULL_EFFECT,
[call()],
[],
[call(float(TEST_SET_TEMPERATURE))],
HVACMode.HEAT,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.OFF},
NULL_EFFECT,
[],
[call()],
[call(float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
NULL_EFFECT,
[],
[],
[call(float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.COOL},
pytest.raises(HomeAssistantError),
# LocalMillHeater will set the temperature before calling async_handle_set_hvac_mode,
# meaning an invalid HVAC mode will raise only after the temperature is set.
[],
[],
[call(float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
# likewise, in this test, it hasn't had the chance to update its ambient temperature,
# because the exception is raised before a refresh can be requested from the coordinator
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
),
],
)
async def test_local_heater(
hass: HomeAssistant,
functional_local_heater: MagicMock,
local_heater_set_mode_control_individually: MagicMock,
local_heater_set_mode_off: MagicMock,
local_heater_set_target_temperature: MagicMock,
before_state: HVACMode,
before_attrs: dict,
service_name: str,
service_params: dict,
effect: "contextlib.AbstractContextManager",
heater_mode_set_individually_calls: list,
heater_mode_set_off_calls: list,
heater_set_target_temperature_calls: list,
after_state: HVACMode,
after_attrs: dict,
) -> None:
"""Tests setting HVAC mode (directly or through set_temperature) for a local heater."""
state = hass.states.get(ENTITY_CLIMATE)
assert state is not None
assert state.state == before_state
for attr, value in before_attrs.items():
assert state.attributes.get(attr) == value
with effect:
await hass.services.async_call(
CLIMATE_DOMAIN,
service_name,
service_params | {ATTR_ENTITY_ID: ENTITY_CLIMATE},
blocking=True,
)
await hass.async_block_till_done()
local_heater_set_mode_control_individually.assert_has_calls(
heater_mode_set_individually_calls
)
local_heater_set_mode_off.assert_has_calls(heater_mode_set_off_calls)
local_heater_set_target_temperature.assert_has_calls(
heater_set_target_temperature_calls
)
state = hass.states.get(ENTITY_CLIMATE)
assert state is not None
assert state.state == after_state
for attr, value in after_attrs.items():
assert state.attributes.get(attr) == value