Add ecobee set_sensors_used_in_climate service (#102871)

* Add set_active_sensors Service

* Remove version bump from service addition commit

* Reviewer suggested changes

* Changed naming to be more clear of functionality

* Adjusted additional naming to follow new convention

* Updated to pass failing CI tests

* Fix typo

* Fix to pass CI

* Changed argument from climate_name to preset_mode and changed service error

* Made loop more clear and changed raised error to log msg

* Fix typo

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Removed code that was accidentally added back in and fixed mypy errors

* Add icon for service

* Added sensors as attributes and updated tests

* Revert changes made in #126587

* Added tests for remote_sensors and set_sensors_used_in_climate

* Changed back to load multiplatforms (#126587)

* Check for empty sensor list and negative tests for errors raised

* Added tests and fixed errors

* Add hass to class init to allow for device_registry lookup at startup and check for name changed by user

* Added tests to test the new functions

* Simplified code and fixed testing error for simplification

* Added freeze in test

* Fixed device filtering

* Simplified code section

* Maintains the ability to call `set_sensors_used_in_climate` function even is the user changes the device name from the ecobee app or thermostat without needing to reload home assistant.

* Update tests with new functionality. Changed thermostat identifier to a string, since that is what is provided via the ecobee api

* Changed function parameter

* Search for specific ecobee identifier

* Moved errors to strings.json

* Added test for sensor not on thermostat

* Improved tests and updated device check

* Added attributes to _unrecoreded_attributes

* Changed name to be more clear

* Improve error message and add test for added property

* Renamed variables for clarity

* Added device_id to available_sensors to make it easier on user to find it

---------

Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
myztillx
2024-10-21 10:21:56 -04:00
committed by GitHub
parent 25f66e6ac0
commit 6861bbed79
8 changed files with 560 additions and 16 deletions

View File

@ -32,7 +32,8 @@ from homeassistant.const import (
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import entity_platform from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr, entity_platform
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -41,6 +42,8 @@ from homeassistant.util.unit_conversion import TemperatureConverter
from . import EcobeeData from . import EcobeeData
from .const import ( from .const import (
_LOGGER, _LOGGER,
ATTR_ACTIVE_SENSORS,
ATTR_AVAILABLE_SENSORS,
DOMAIN, DOMAIN,
ECOBEE_AUX_HEAT_ONLY, ECOBEE_AUX_HEAT_ONLY,
ECOBEE_MODEL_TO_NAME, ECOBEE_MODEL_TO_NAME,
@ -62,6 +65,8 @@ ATTR_DST_ENABLED = "dst_enabled"
ATTR_MIC_ENABLED = "mic_enabled" ATTR_MIC_ENABLED = "mic_enabled"
ATTR_AUTO_AWAY = "auto_away" ATTR_AUTO_AWAY = "auto_away"
ATTR_FOLLOW_ME = "follow_me" ATTR_FOLLOW_ME = "follow_me"
ATTR_SENSOR_LIST = "device_ids"
ATTR_PRESET_MODE = "preset_mode"
DEFAULT_RESUME_ALL = False DEFAULT_RESUME_ALL = False
PRESET_AWAY_INDEFINITELY = "away_indefinitely" PRESET_AWAY_INDEFINITELY = "away_indefinitely"
@ -129,6 +134,7 @@ SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time"
SERVICE_SET_DST_MODE = "set_dst_mode" SERVICE_SET_DST_MODE = "set_dst_mode"
SERVICE_SET_MIC_MODE = "set_mic_mode" SERVICE_SET_MIC_MODE = "set_mic_mode"
SERVICE_SET_OCCUPANCY_MODES = "set_occupancy_modes" SERVICE_SET_OCCUPANCY_MODES = "set_occupancy_modes"
SERVICE_SET_SENSORS_USED_IN_CLIMATE = "set_sensors_used_in_climate"
DTGROUP_START_INCLUSIVE_MSG = ( DTGROUP_START_INCLUSIVE_MSG = (
f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together" f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together"
@ -217,7 +223,7 @@ async def async_setup_entry(
thermostat["name"], thermostat["name"],
thermostat["modelNumber"], thermostat["modelNumber"],
) )
entities.append(Thermostat(data, index, thermostat)) entities.append(Thermostat(data, index, thermostat, hass))
async_add_entities(entities, True) async_add_entities(entities, True)
@ -327,6 +333,15 @@ async def async_setup_entry(
"set_occupancy_modes", "set_occupancy_modes",
) )
platform.async_register_entity_service(
SERVICE_SET_SENSORS_USED_IN_CLIMATE,
{
vol.Optional(ATTR_PRESET_MODE): cv.string,
vol.Required(ATTR_SENSOR_LIST): cv.ensure_list,
},
"set_sensors_used_in_climate",
)
class Thermostat(ClimateEntity): class Thermostat(ClimateEntity):
"""A thermostat class for Ecobee.""" """A thermostat class for Ecobee."""
@ -342,7 +357,11 @@ class Thermostat(ClimateEntity):
_attr_translation_key = "ecobee" _attr_translation_key = "ecobee"
def __init__( def __init__(
self, data: EcobeeData, thermostat_index: int, thermostat: dict self,
data: EcobeeData,
thermostat_index: int,
thermostat: dict,
hass: HomeAssistant,
) -> None: ) -> None:
"""Initialize the thermostat.""" """Initialize the thermostat."""
self.data = data self.data = data
@ -352,6 +371,7 @@ class Thermostat(ClimateEntity):
self.vacation = None self.vacation = None
self._last_active_hvac_mode = HVACMode.HEAT_COOL self._last_active_hvac_mode = HVACMode.HEAT_COOL
self._last_hvac_mode_before_aux_heat = HVACMode.HEAT_COOL self._last_hvac_mode_before_aux_heat = HVACMode.HEAT_COOL
self._hass = hass
self._attr_hvac_modes = [] self._attr_hvac_modes = []
if self.settings["heatStages"] or self.settings["hasHeatPump"]: if self.settings["heatStages"] or self.settings["hasHeatPump"]:
@ -361,7 +381,11 @@ class Thermostat(ClimateEntity):
if len(self._attr_hvac_modes) == 2: if len(self._attr_hvac_modes) == 2:
self._attr_hvac_modes.insert(0, HVACMode.HEAT_COOL) self._attr_hvac_modes.insert(0, HVACMode.HEAT_COOL)
self._attr_hvac_modes.append(HVACMode.OFF) self._attr_hvac_modes.append(HVACMode.OFF)
self._sensors = self.remote_sensors
self._preset_modes = {
comfort["climateRef"]: comfort["name"]
for comfort in self.thermostat["program"]["climates"]
}
self.update_without_throttle = False self.update_without_throttle = False
async def async_update(self) -> None: async def async_update(self) -> None:
@ -552,6 +576,8 @@ class Thermostat(ClimateEntity):
return HVACAction.IDLE return HVACAction.IDLE
_unrecorded_attributes = frozenset({ATTR_AVAILABLE_SENSORS, ATTR_ACTIVE_SENSORS})
@property @property
def extra_state_attributes(self) -> dict[str, Any] | None: def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return device specific state attributes.""" """Return device specific state attributes."""
@ -563,8 +589,62 @@ class Thermostat(ClimateEntity):
), ),
"equipment_running": status, "equipment_running": status,
"fan_min_on_time": self.settings["fanMinOnTime"], "fan_min_on_time": self.settings["fanMinOnTime"],
ATTR_AVAILABLE_SENSORS: self.remote_sensor_devices,
ATTR_ACTIVE_SENSORS: self.active_sensor_devices_in_preset_mode,
} }
@property
def remote_sensors(self) -> list:
"""Return the remote sensor names of the thermostat."""
sensors_info = self.thermostat.get("remoteSensors", [])
return [sensor["name"] for sensor in sensors_info if sensor.get("name")]
@property
def remote_sensor_devices(self) -> list:
"""Return the remote sensor device name_by_user or name for the thermostat."""
return sorted(
[
f'{item["name_by_user"]} ({item["id"]})'
for item in self.remote_sensor_ids_names
]
)
@property
def remote_sensor_ids_names(self) -> list:
"""Return the remote sensor device id and name_by_user for the thermostat."""
sensors_info = self.thermostat.get("remoteSensors", [])
device_registry = dr.async_get(self._hass)
return [
{
"id": device.id,
"name_by_user": device.name_by_user
if device.name_by_user
else device.name,
}
for device in device_registry.devices.values()
for sensor_info in sensors_info
if device.name == sensor_info["name"]
]
@property
def active_sensors_in_preset_mode(self) -> list:
"""Return the currently active/participating sensors."""
# https://support.ecobee.com/s/articles/SmartSensors-Sensor-Participation
# During a manual hold, the ecobee will follow the Sensor Participation
# rules for the Home Comfort Settings
mode = self._preset_modes.get(self.preset_mode, "Home")
return self._sensors_in_preset_mode(mode)
@property
def active_sensor_devices_in_preset_mode(self) -> list:
"""Return the currently active/participating sensor devices."""
# https://support.ecobee.com/s/articles/SmartSensors-Sensor-Participation
# During a manual hold, the ecobee will follow the Sensor Participation
# rules for the Home Comfort Settings
mode = self._preset_modes.get(self.preset_mode, "Home")
return self._sensor_devices_in_preset_mode(mode)
def set_preset_mode(self, preset_mode: str) -> None: def set_preset_mode(self, preset_mode: str) -> None:
"""Activate a preset.""" """Activate a preset."""
preset_mode = HASS_TO_ECOBEE_PRESET.get(preset_mode, preset_mode) preset_mode = HASS_TO_ECOBEE_PRESET.get(preset_mode, preset_mode)
@ -741,6 +821,115 @@ class Thermostat(ClimateEntity):
) )
self.update_without_throttle = True self.update_without_throttle = True
def set_sensors_used_in_climate(
self, device_ids: list[str], preset_mode: str | None = None
) -> None:
"""Set the sensors used on a climate for a thermostat."""
if preset_mode is None:
preset_mode = self.preset_mode
# Check if climate is an available preset option.
elif preset_mode not in self._preset_modes.values():
if self.preset_modes:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_preset",
translation_placeholders={
"options": ", ".join(self._preset_modes.values())
},
)
# Get device name from device id.
device_registry = dr.async_get(self.hass)
sensor_names: list[str] = []
sensor_ids: list[str] = []
for device_id in device_ids:
device = device_registry.async_get(device_id)
if device and device.name:
r_sensors = self.thermostat.get("remoteSensors", [])
ecobee_identifier = next(
(
identifier
for identifier in device.identifiers
if identifier[0] == "ecobee"
),
None,
)
if ecobee_identifier:
code = ecobee_identifier[1]
for r_sensor in r_sensors:
if ( # occurs if remote sensor
len(code) == 4 and r_sensor.get("code") == code
) or ( # occurs if thermostat
len(code) != 4 and r_sensor.get("type") == "thermostat"
):
sensor_ids.append(r_sensor.get("id")) # noqa: PERF401
sensor_names.append(device.name)
# Ensure sensors provided are available for thermostat or not empty.
if not set(sensor_names).issubset(set(self._sensors)) or not sensor_names:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_sensor",
translation_placeholders={
"options": ", ".join(
[
f'{item["name_by_user"]} ({item["id"]})'
for item in self.remote_sensor_ids_names
]
)
},
)
# Check that an id was found for each sensor
if len(device_ids) != len(sensor_ids):
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="sensor_lookup_failed"
)
# Check if sensors are currently used on the climate for the thermostat.
current_sensors_in_climate = self._sensors_in_preset_mode(preset_mode)
if set(sensor_names) == set(current_sensors_in_climate):
_LOGGER.debug(
"This action would not be an update, current sensors on climate (%s) are: %s",
preset_mode,
", ".join(current_sensors_in_climate),
)
return
_LOGGER.debug(
"Setting sensors %s to be used on thermostat %s for program %s",
sensor_names,
self.device_info.get("name"),
preset_mode,
)
self.data.ecobee.update_climate_sensors(
self.thermostat_index, preset_mode, sensor_ids=sensor_ids
)
self.update_without_throttle = True
def _sensors_in_preset_mode(self, preset_mode: str | None) -> list[str]:
"""Return current sensors used in climate."""
climates = self.thermostat["program"]["climates"]
for climate in climates:
if climate.get("name") == preset_mode:
return [sensor["name"] for sensor in climate["sensors"]]
return []
def _sensor_devices_in_preset_mode(self, preset_mode: str | None) -> list[str]:
"""Return current sensor device name_by_user or name used in climate."""
device_registry = dr.async_get(self._hass)
sensor_names = self._sensors_in_preset_mode(preset_mode)
return sorted(
[
device.name_by_user if device.name_by_user else device.name
for device in device_registry.devices.values()
for sensor_name in sensor_names
if device.name == sensor_name
]
)
def hold_preference(self): def hold_preference(self):
"""Return user preference setting for hold time.""" """Return user preference setting for hold time."""
# Values returned from thermostat are: # Values returned from thermostat are:

View File

@ -23,6 +23,8 @@ DOMAIN = "ecobee"
DATA_ECOBEE_CONFIG = "ecobee_config" DATA_ECOBEE_CONFIG = "ecobee_config"
DATA_HASS_CONFIG = "ecobee_hass_config" DATA_HASS_CONFIG = "ecobee_hass_config"
ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_CONFIG_ENTRY_ID = "entry_id"
ATTR_AVAILABLE_SENSORS = "available_sensors"
ATTR_ACTIVE_SENSORS = "active_sensors"
CONF_REFRESH_TOKEN = "refresh_token" CONF_REFRESH_TOKEN = "refresh_token"

View File

@ -20,6 +20,9 @@
}, },
"set_occupancy_modes": { "set_occupancy_modes": {
"service": "mdi:eye-settings" "service": "mdi:eye-settings"
},
"set_sensors_used_in_climate": {
"service": "mdi:home-thermometer"
} }
} }
} }

View File

@ -134,3 +134,23 @@ set_occupancy_modes:
follow_me: follow_me:
selector: selector:
boolean: boolean:
set_sensors_used_in_climate:
target:
entity:
integration: ecobee
domain: climate
fields:
preset_mode:
example: "Home"
selector:
text:
device_ids:
required: true
selector:
device:
multiple: true
integration: ecobee
entity:
- domain: climate
- domain: sensor

View File

@ -167,6 +167,35 @@
"description": "Enable Follow Me mode." "description": "Enable Follow Me mode."
} }
} }
},
"set_sensors_used_in_climate": {
"name": "Set Sensors Used in Climate",
"description": "Sets the participating sensors for a climate.",
"fields": {
"entity_id": {
"name": "Entity",
"description": "Ecobee thermostat on which to set active sensors."
},
"preset_mode": {
"name": "Climate Name",
"description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program."
},
"device_ids": {
"name": "Sensors",
"description": "Sensors to set as participating sensors."
}
}
}
},
"exceptions": {
"invalid_preset": {
"message": "Invalid climate name, available options are: {options}"
},
"invalid_sensor": {
"message": "Invalid sensor for thermostat, available options are: {options}"
},
"sensor_lookup_failed": {
"message": "There was an error getting the sensor ids from sensor names. Try reloading the ecobee integration."
} }
}, },
"issues": { "issues": {

View File

@ -11,7 +11,7 @@ from tests.common import MockConfigEntry
async def setup_platform( async def setup_platform(
hass: HomeAssistant, hass: HomeAssistant,
platform: str, platforms: str | list[str],
) -> MockConfigEntry: ) -> MockConfigEntry:
"""Set up the ecobee platform.""" """Set up the ecobee platform."""
mock_entry = MockConfigEntry( mock_entry = MockConfigEntry(
@ -24,7 +24,9 @@ async def setup_platform(
) )
mock_entry.add_to_hass(hass) mock_entry.add_to_hass(hass)
with patch("homeassistant.components.ecobee.PLATFORMS", [platform]): platforms = [platforms] if isinstance(platforms, str) else platforms
with patch("homeassistant.components.ecobee.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_entry.entry_id) await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
return mock_entry return mock_entry

View File

@ -1,7 +1,7 @@
{ {
"thermostatList": [ "thermostatList": [
{ {
"identifier": 8675309, "identifier": "8675309",
"name": "ecobee", "name": "ecobee",
"modelNumber": "athenaSmart", "modelNumber": "athenaSmart",
"utcTime": "2022-01-01 10:00:00", "utcTime": "2022-01-01 10:00:00",
@ -11,13 +11,32 @@
}, },
"program": { "program": {
"climates": [ "climates": [
{
"name": "Home",
"climateRef": "home",
"sensors": [
{
"name": "ecobee"
}
]
},
{ {
"name": "Climate1", "name": "Climate1",
"climateRef": "c1" "climateRef": "c1",
"sensors": [
{
"name": "ecobee"
}
]
}, },
{ {
"name": "Climate2", "name": "Climate2",
"climateRef": "c2" "climateRef": "c2",
"sensors": [
{
"name": "ecobee"
}
]
} }
], ],
"currentClimateRef": "c1" "currentClimateRef": "c1"
@ -62,6 +81,24 @@
} }
], ],
"remoteSensors": [ "remoteSensors": [
{
"id": "ei:0",
"name": "ecobee",
"type": "thermostat",
"inUse": true,
"capability": [
{
"id": "1",
"type": "temperature",
"value": "782"
},
{
"id": "2",
"type": "humidity",
"value": "54"
}
]
},
{ {
"id": "rs:100", "id": "rs:100",
"name": "Remote Sensor 1", "name": "Remote Sensor 1",
@ -157,6 +194,25 @@
"value": "false" "value": "false"
} }
] ]
},
{
"id": "rs:101",
"name": "Remote Sensor 2",
"type": "ecobee3_remote_sensor",
"code": "VTRK",
"inUse": false,
"capability": [
{
"id": "1",
"type": "temperature",
"value": "782"
},
{
"id": "2",
"type": "occupancy",
"value": "false"
}
]
} }
] ]
}, },

View File

@ -3,16 +3,27 @@
from http import HTTPStatus from http import HTTPStatus
from unittest import mock from unittest import mock
from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from homeassistant import const from homeassistant import const
from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.climate import ClimateEntityFeature
from homeassistant.components.ecobee.climate import PRESET_AWAY_INDEFINITELY, Thermostat from homeassistant.components.ecobee.climate import (
from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF ATTR_PRESET_MODE,
ATTR_SENSOR_LIST,
PRESET_AWAY_INDEFINITELY,
Thermostat,
)
from homeassistant.components.ecobee.const import DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from .common import setup_platform from .common import setup_platform
from tests.common import MockConfigEntry, async_fire_time_changed
ENTITY_ID = "climate.ecobee" ENTITY_ID = "climate.ecobee"
@ -25,9 +36,18 @@ def ecobee_fixture():
"identifier": "abc", "identifier": "abc",
"program": { "program": {
"climates": [ "climates": [
{"name": "Climate1", "climateRef": "c1"}, {
{"name": "Climate2", "climateRef": "c2"}, "name": "Climate1",
{"name": "Away", "climateRef": "away"}, "climateRef": "c1",
"sensors": [{"name": "Ecobee"}],
},
{
"name": "Climate2",
"climateRef": "c2",
"sensors": [{"name": "Ecobee"}],
},
{"name": "Away", "climateRef": "away", "sensors": [{"name": "Ecobee"}]},
{"name": "Home", "climateRef": "home", "sensors": [{"name": "Ecobee"}]},
], ],
"currentClimateRef": "c1", "currentClimateRef": "c1",
}, },
@ -60,8 +80,19 @@ def ecobee_fixture():
"endTime": "10:00:00", "endTime": "10:00:00",
} }
], ],
"remoteSensors": [
{
"id": "ei:0",
"name": "Ecobee",
},
{
"id": "rs2:100",
"name": "Remote Sensor 1",
},
],
} }
mock_ecobee = mock.Mock() mock_ecobee = mock.Mock()
mock_ecobee.get = mock.Mock(side_effect=vals.get)
mock_ecobee.__getitem__ = mock.Mock(side_effect=vals.__getitem__) mock_ecobee.__getitem__ = mock.Mock(side_effect=vals.__getitem__)
mock_ecobee.__setitem__ = mock.Mock(side_effect=vals.__setitem__) mock_ecobee.__setitem__ = mock.Mock(side_effect=vals.__setitem__)
return mock_ecobee return mock_ecobee
@ -76,10 +107,10 @@ def data_fixture(ecobee_fixture):
@pytest.fixture(name="thermostat") @pytest.fixture(name="thermostat")
def thermostat_fixture(data): def thermostat_fixture(data, hass: HomeAssistant):
"""Set up ecobee thermostat object.""" """Set up ecobee thermostat object."""
thermostat = data.ecobee.get_thermostat(1) thermostat = data.ecobee.get_thermostat(1)
return Thermostat(data, 1, thermostat) return Thermostat(data, 1, thermostat, hass)
async def test_name(thermostat) -> None: async def test_name(thermostat) -> None:
@ -186,6 +217,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
"climate_mode": "Climate1", "climate_mode": "Climate1",
"fan_min_on_time": 10, "fan_min_on_time": 10,
"equipment_running": "heatPump2", "equipment_running": "heatPump2",
"available_sensors": [],
"active_sensors": [],
} }
ecobee_fixture["equipmentStatus"] = "auxHeat2" ecobee_fixture["equipmentStatus"] = "auxHeat2"
@ -194,6 +227,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
"climate_mode": "Climate1", "climate_mode": "Climate1",
"fan_min_on_time": 10, "fan_min_on_time": 10,
"equipment_running": "auxHeat2", "equipment_running": "auxHeat2",
"available_sensors": [],
"active_sensors": [],
} }
ecobee_fixture["equipmentStatus"] = "compCool1" ecobee_fixture["equipmentStatus"] = "compCool1"
@ -202,6 +237,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
"climate_mode": "Climate1", "climate_mode": "Climate1",
"fan_min_on_time": 10, "fan_min_on_time": 10,
"equipment_running": "compCool1", "equipment_running": "compCool1",
"available_sensors": [],
"active_sensors": [],
} }
ecobee_fixture["equipmentStatus"] = "" ecobee_fixture["equipmentStatus"] = ""
assert thermostat.extra_state_attributes == { assert thermostat.extra_state_attributes == {
@ -209,6 +246,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
"climate_mode": "Climate1", "climate_mode": "Climate1",
"fan_min_on_time": 10, "fan_min_on_time": 10,
"equipment_running": "", "equipment_running": "",
"available_sensors": [],
"active_sensors": [],
} }
ecobee_fixture["equipmentStatus"] = "Unknown" ecobee_fixture["equipmentStatus"] = "Unknown"
@ -217,6 +256,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
"climate_mode": "Climate1", "climate_mode": "Climate1",
"fan_min_on_time": 10, "fan_min_on_time": 10,
"equipment_running": "Unknown", "equipment_running": "Unknown",
"available_sensors": [],
"active_sensors": [],
} }
ecobee_fixture["program"]["currentClimateRef"] = "c2" ecobee_fixture["program"]["currentClimateRef"] = "c2"
@ -225,6 +266,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
"climate_mode": "Climate2", "climate_mode": "Climate2",
"fan_min_on_time": 10, "fan_min_on_time": 10,
"equipment_running": "Unknown", "equipment_running": "Unknown",
"available_sensors": [],
"active_sensors": [],
} }
@ -375,3 +418,203 @@ async def test_set_preset_mode(ecobee_fixture, thermostat, data) -> None:
data.ecobee.set_climate_hold.assert_has_calls( data.ecobee.set_climate_hold.assert_has_calls(
[mock.call(1, "away", "indefinite", thermostat.hold_hours())] [mock.call(1, "away", "indefinite", thermostat.hold_hours())]
) )
async def test_remote_sensors(hass: HomeAssistant) -> None:
"""Test remote sensors."""
await setup_platform(hass, [const.Platform.CLIMATE, const.Platform.SENSOR])
platform = hass.data[const.Platform.CLIMATE].entities
for entity in platform:
if entity.entity_id == "climate.ecobee":
thermostat = entity
break
assert thermostat is not None
remote_sensors = thermostat.remote_sensors
assert sorted(remote_sensors) == sorted(["ecobee", "Remote Sensor 1"])
async def test_remote_sensor_devices(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test remote sensor devices."""
await setup_platform(hass, [const.Platform.CLIMATE, const.Platform.SENSOR])
freezer.tick(100)
async_fire_time_changed(hass)
state = hass.states.get(ENTITY_ID)
device_registry = dr.async_get(hass)
for device in device_registry.devices.values():
if device.name == "Remote Sensor 1":
remote_sensor_1_id = device.id
if device.name == "ecobee":
ecobee_id = device.id
assert sorted(state.attributes.get("available_sensors")) == sorted(
[f"Remote Sensor 1 ({remote_sensor_1_id})", f"ecobee ({ecobee_id})"]
)
async def test_active_sensors_in_preset_mode(hass: HomeAssistant) -> None:
"""Test active sensors in preset mode property."""
await setup_platform(hass, [const.Platform.CLIMATE, const.Platform.SENSOR])
platform = hass.data[const.Platform.CLIMATE].entities
for entity in platform:
if entity.entity_id == "climate.ecobee":
thermostat = entity
break
assert thermostat is not None
remote_sensors = thermostat.active_sensors_in_preset_mode
assert sorted(remote_sensors) == sorted(["ecobee"])
async def test_active_sensor_devices_in_preset_mode(hass: HomeAssistant) -> None:
"""Test active sensor devices in preset mode."""
await setup_platform(hass, [const.Platform.CLIMATE, const.Platform.SENSOR])
state = hass.states.get(ENTITY_ID)
assert state.attributes.get("active_sensors") == ["ecobee"]
async def test_remote_sensor_ids_names(hass: HomeAssistant) -> None:
"""Test getting ids and names_by_user for thermostat."""
await setup_platform(hass, [const.Platform.CLIMATE, const.Platform.SENSOR])
platform = hass.data[const.Platform.CLIMATE].entities
for entity in platform:
if entity.entity_id == "climate.ecobee":
thermostat = entity
break
assert thermostat is not None
remote_sensor_ids_names = thermostat.remote_sensor_ids_names
for id_name in remote_sensor_ids_names:
assert id_name.get("id") is not None
name_by_user_list = [item["name_by_user"] for item in remote_sensor_ids_names]
assert sorted(name_by_user_list) == sorted(["Remote Sensor 1", "ecobee"])
async def test_set_sensors_used_in_climate(hass: HomeAssistant) -> None:
"""Test set sensors used in climate."""
# Get device_id of remote sensor from the device registry.
await setup_platform(hass, [const.Platform.CLIMATE, const.Platform.SENSOR])
device_registry = dr.async_get(hass)
for device in device_registry.devices.values():
if device.name == "Remote Sensor 1":
remote_sensor_1_id = device.id
if device.name == "ecobee":
ecobee_id = device.id
if device.name == "Remote Sensor 2":
remote_sensor_2_id = device.id
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device_from_other_integration = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("test", "unique")}
)
# Test that the function call works in its entirety.
with mock.patch("pyecobee.Ecobee.update_climate_sensors") as mock_sensors:
await hass.services.async_call(
DOMAIN,
"set_sensors_used_in_climate",
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_PRESET_MODE: "Climate1",
ATTR_SENSOR_LIST: [remote_sensor_1_id],
},
blocking=True,
)
await hass.async_block_till_done()
mock_sensors.assert_called_once_with(0, "Climate1", sensor_ids=["rs:100"])
# Update sensors without preset mode.
with mock.patch("pyecobee.Ecobee.update_climate_sensors") as mock_sensors:
await hass.services.async_call(
DOMAIN,
"set_sensors_used_in_climate",
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_SENSOR_LIST: [remote_sensor_1_id],
},
blocking=True,
)
await hass.async_block_till_done()
# `temp` is the preset running because of a hold.
mock_sensors.assert_called_once_with(0, "temp", sensor_ids=["rs:100"])
# Check that sensors are not updated when the sent sensors are the currently set sensors.
with mock.patch("pyecobee.Ecobee.update_climate_sensors") as mock_sensors:
await hass.services.async_call(
DOMAIN,
"set_sensors_used_in_climate",
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_PRESET_MODE: "Climate1",
ATTR_SENSOR_LIST: [ecobee_id],
},
blocking=True,
)
mock_sensors.assert_not_called()
# Error raised because invalid climate name.
with pytest.raises(ServiceValidationError) as execinfo:
await hass.services.async_call(
DOMAIN,
"set_sensors_used_in_climate",
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_PRESET_MODE: "InvalidClimate",
ATTR_SENSOR_LIST: [remote_sensor_1_id],
},
blocking=True,
)
assert execinfo.value.translation_domain == "ecobee"
assert execinfo.value.translation_key == "invalid_preset"
## Error raised because invalid sensor.
with pytest.raises(ServiceValidationError) as execinfo:
await hass.services.async_call(
DOMAIN,
"set_sensors_used_in_climate",
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_PRESET_MODE: "Climate1",
ATTR_SENSOR_LIST: ["abcd"],
},
blocking=True,
)
assert execinfo.value.translation_domain == "ecobee"
assert execinfo.value.translation_key == "invalid_sensor"
## Error raised because sensor not available on device.
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
DOMAIN,
"set_sensors_used_in_climate",
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_PRESET_MODE: "Climate1",
ATTR_SENSOR_LIST: [remote_sensor_2_id],
},
blocking=True,
)
with pytest.raises(ServiceValidationError) as execinfo:
await hass.services.async_call(
DOMAIN,
"set_sensors_used_in_climate",
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_PRESET_MODE: "Climate1",
ATTR_SENSOR_LIST: [
remote_sensor_1_id,
device_from_other_integration.id,
],
},
blocking=True,
)
assert execinfo.value.translation_domain == "ecobee"
assert execinfo.value.translation_key == "sensor_lookup_failed"