mirror of
https://github.com/home-assistant/core.git
synced 2026-01-13 18:48:45 +00:00
Compare commits
7 Commits
setpoint_c
...
condition_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26874335ca | ||
|
|
a6221d16b6 | ||
|
|
51701cab7c | ||
|
|
010e1f2d0d | ||
|
|
66909fc9ca | ||
|
|
90a28c95c8 | ||
|
|
83f2c53e8c |
@@ -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,
|
||||
|
||||
4
.vscode/settings.default.jsonc
vendored
4
.vscode/settings.default.jsonc
vendored
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.16.1"]
|
||||
"requirements": ["opower==0.16.2"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
6
requirements_all.txt
generated
@@ -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
|
||||
|
||||
6
requirements_test_all.txt
generated
6
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
572
tests/components/mill/test_climate.py
Normal file
572
tests/components/mill/test_climate.py
Normal 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
|
||||
Reference in New Issue
Block a user