From 6861bbed79cd61b848e7f6741cf6a552aa6d7616 Mon Sep 17 00:00:00 2001
From: myztillx <33730898+myztillx@users.noreply.github.com>
Date: Mon, 21 Oct 2024 10:21:56 -0400
Subject: [PATCH] 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>
---
 homeassistant/components/ecobee/climate.py    | 197 +++++++++++++-
 homeassistant/components/ecobee/const.py      |   2 +
 homeassistant/components/ecobee/icons.json    |   3 +
 homeassistant/components/ecobee/services.yaml |  20 ++
 homeassistant/components/ecobee/strings.json  |  29 ++
 tests/components/ecobee/common.py             |   6 +-
 .../ecobee/fixtures/ecobee-data.json          |  62 ++++-
 tests/components/ecobee/test_climate.py       | 257 +++++++++++++++++-
 8 files changed, 560 insertions(+), 16 deletions(-)

diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py
index e6801998e0d..6a9ec0d5db9 100644
--- a/homeassistant/components/ecobee/climate.py
+++ b/homeassistant/components/ecobee/climate.py
@@ -32,7 +32,8 @@ from homeassistant.const import (
     UnitOfTemperature,
 )
 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
 from homeassistant.helpers.device_registry import DeviceInfo
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -41,6 +42,8 @@ from homeassistant.util.unit_conversion import TemperatureConverter
 from . import EcobeeData
 from .const import (
     _LOGGER,
+    ATTR_ACTIVE_SENSORS,
+    ATTR_AVAILABLE_SENSORS,
     DOMAIN,
     ECOBEE_AUX_HEAT_ONLY,
     ECOBEE_MODEL_TO_NAME,
@@ -62,6 +65,8 @@ ATTR_DST_ENABLED = "dst_enabled"
 ATTR_MIC_ENABLED = "mic_enabled"
 ATTR_AUTO_AWAY = "auto_away"
 ATTR_FOLLOW_ME = "follow_me"
+ATTR_SENSOR_LIST = "device_ids"
+ATTR_PRESET_MODE = "preset_mode"
 
 DEFAULT_RESUME_ALL = False
 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_MIC_MODE = "set_mic_mode"
 SERVICE_SET_OCCUPANCY_MODES = "set_occupancy_modes"
+SERVICE_SET_SENSORS_USED_IN_CLIMATE = "set_sensors_used_in_climate"
 
 DTGROUP_START_INCLUSIVE_MSG = (
     f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together"
@@ -217,7 +223,7 @@ async def async_setup_entry(
                 thermostat["name"],
                 thermostat["modelNumber"],
             )
-        entities.append(Thermostat(data, index, thermostat))
+        entities.append(Thermostat(data, index, thermostat, hass))
 
     async_add_entities(entities, True)
 
@@ -327,6 +333,15 @@ async def async_setup_entry(
         "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):
     """A thermostat class for Ecobee."""
@@ -342,7 +357,11 @@ class Thermostat(ClimateEntity):
     _attr_translation_key = "ecobee"
 
     def __init__(
-        self, data: EcobeeData, thermostat_index: int, thermostat: dict
+        self,
+        data: EcobeeData,
+        thermostat_index: int,
+        thermostat: dict,
+        hass: HomeAssistant,
     ) -> None:
         """Initialize the thermostat."""
         self.data = data
@@ -352,6 +371,7 @@ class Thermostat(ClimateEntity):
         self.vacation = None
         self._last_active_hvac_mode = HVACMode.HEAT_COOL
         self._last_hvac_mode_before_aux_heat = HVACMode.HEAT_COOL
+        self._hass = hass
 
         self._attr_hvac_modes = []
         if self.settings["heatStages"] or self.settings["hasHeatPump"]:
@@ -361,7 +381,11 @@ class Thermostat(ClimateEntity):
         if len(self._attr_hvac_modes) == 2:
             self._attr_hvac_modes.insert(0, HVACMode.HEAT_COOL)
         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
 
     async def async_update(self) -> None:
@@ -552,6 +576,8 @@ class Thermostat(ClimateEntity):
 
         return HVACAction.IDLE
 
+    _unrecorded_attributes = frozenset({ATTR_AVAILABLE_SENSORS, ATTR_ACTIVE_SENSORS})
+
     @property
     def extra_state_attributes(self) -> dict[str, Any] | None:
         """Return device specific state attributes."""
@@ -563,8 +589,62 @@ class Thermostat(ClimateEntity):
             ),
             "equipment_running": status,
             "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:
         """Activate a preset."""
         preset_mode = HASS_TO_ECOBEE_PRESET.get(preset_mode, preset_mode)
@@ -741,6 +821,115 @@ class Thermostat(ClimateEntity):
         )
         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):
         """Return user preference setting for hold time."""
         # Values returned from thermostat are:
diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py
index 85a332f3c87..d0e9ba8e8e9 100644
--- a/homeassistant/components/ecobee/const.py
+++ b/homeassistant/components/ecobee/const.py
@@ -23,6 +23,8 @@ DOMAIN = "ecobee"
 DATA_ECOBEE_CONFIG = "ecobee_config"
 DATA_HASS_CONFIG = "ecobee_hass_config"
 ATTR_CONFIG_ENTRY_ID = "entry_id"
+ATTR_AVAILABLE_SENSORS = "available_sensors"
+ATTR_ACTIVE_SENSORS = "active_sensors"
 
 CONF_REFRESH_TOKEN = "refresh_token"
 
diff --git a/homeassistant/components/ecobee/icons.json b/homeassistant/components/ecobee/icons.json
index f24f1f7cfe5..647a14dc5d5 100644
--- a/homeassistant/components/ecobee/icons.json
+++ b/homeassistant/components/ecobee/icons.json
@@ -20,6 +20,9 @@
     },
     "set_occupancy_modes": {
       "service": "mdi:eye-settings"
+    },
+    "set_sensors_used_in_climate": {
+      "service": "mdi:home-thermometer"
     }
   }
 }
diff --git a/homeassistant/components/ecobee/services.yaml b/homeassistant/components/ecobee/services.yaml
index a184f422725..d58ae81d552 100644
--- a/homeassistant/components/ecobee/services.yaml
+++ b/homeassistant/components/ecobee/services.yaml
@@ -134,3 +134,23 @@ set_occupancy_modes:
     follow_me:
       selector:
         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
diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json
index 2af6e5a90f9..18929cb45de 100644
--- a/homeassistant/components/ecobee/strings.json
+++ b/homeassistant/components/ecobee/strings.json
@@ -167,6 +167,35 @@
           "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": {
diff --git a/tests/components/ecobee/common.py b/tests/components/ecobee/common.py
index e320a08673a..69d576ce2b5 100644
--- a/tests/components/ecobee/common.py
+++ b/tests/components/ecobee/common.py
@@ -11,7 +11,7 @@ from tests.common import MockConfigEntry
 
 async def setup_platform(
     hass: HomeAssistant,
-    platform: str,
+    platforms: str | list[str],
 ) -> MockConfigEntry:
     """Set up the ecobee platform."""
     mock_entry = MockConfigEntry(
@@ -24,7 +24,9 @@ async def setup_platform(
     )
     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.async_block_till_done()
     return mock_entry
diff --git a/tests/components/ecobee/fixtures/ecobee-data.json b/tests/components/ecobee/fixtures/ecobee-data.json
index b2f336e064d..1573484795f 100644
--- a/tests/components/ecobee/fixtures/ecobee-data.json
+++ b/tests/components/ecobee/fixtures/ecobee-data.json
@@ -1,7 +1,7 @@
 {
   "thermostatList": [
     {
-      "identifier": 8675309,
+      "identifier": "8675309",
       "name": "ecobee",
       "modelNumber": "athenaSmart",
       "utcTime": "2022-01-01 10:00:00",
@@ -11,13 +11,32 @@
       },
       "program": {
         "climates": [
+          {
+            "name": "Home",
+            "climateRef": "home",
+            "sensors": [
+              {
+                "name": "ecobee"
+              }
+            ]
+          },
           {
             "name": "Climate1",
-            "climateRef": "c1"
+            "climateRef": "c1",
+            "sensors": [
+              {
+                "name": "ecobee"
+              }
+            ]
           },
           {
             "name": "Climate2",
-            "climateRef": "c2"
+            "climateRef": "c2",
+            "sensors": [
+              {
+                "name": "ecobee"
+              }
+            ]
           }
         ],
         "currentClimateRef": "c1"
@@ -62,6 +81,24 @@
         }
       ],
       "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",
           "name": "Remote Sensor 1",
@@ -157,6 +194,25 @@
               "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"
+            }
+          ]
         }
       ]
     },
diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py
index 559153874a5..403ac4a01ad 100644
--- a/tests/components/ecobee/test_climate.py
+++ b/tests/components/ecobee/test_climate.py
@@ -3,16 +3,27 @@
 from http import HTTPStatus
 from unittest import mock
 
+from freezegun.api import FrozenDateTimeFactory
 import pytest
 
 from homeassistant import const
 from homeassistant.components.climate import ClimateEntityFeature
-from homeassistant.components.ecobee.climate import PRESET_AWAY_INDEFINITELY, Thermostat
-from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF
+from homeassistant.components.ecobee.climate import (
+    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.exceptions import ServiceValidationError
+from homeassistant.helpers import device_registry as dr
 
 from .common import setup_platform
 
+from tests.common import MockConfigEntry, async_fire_time_changed
+
 ENTITY_ID = "climate.ecobee"
 
 
@@ -25,9 +36,18 @@ def ecobee_fixture():
         "identifier": "abc",
         "program": {
             "climates": [
-                {"name": "Climate1", "climateRef": "c1"},
-                {"name": "Climate2", "climateRef": "c2"},
-                {"name": "Away", "climateRef": "away"},
+                {
+                    "name": "Climate1",
+                    "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",
         },
@@ -60,8 +80,19 @@ def ecobee_fixture():
                 "endTime": "10:00:00",
             }
         ],
+        "remoteSensors": [
+            {
+                "id": "ei:0",
+                "name": "Ecobee",
+            },
+            {
+                "id": "rs2:100",
+                "name": "Remote Sensor 1",
+            },
+        ],
     }
     mock_ecobee = mock.Mock()
+    mock_ecobee.get = mock.Mock(side_effect=vals.get)
     mock_ecobee.__getitem__ = mock.Mock(side_effect=vals.__getitem__)
     mock_ecobee.__setitem__ = mock.Mock(side_effect=vals.__setitem__)
     return mock_ecobee
@@ -76,10 +107,10 @@ def data_fixture(ecobee_fixture):
 
 
 @pytest.fixture(name="thermostat")
-def thermostat_fixture(data):
+def thermostat_fixture(data, hass: HomeAssistant):
     """Set up ecobee thermostat object."""
     thermostat = data.ecobee.get_thermostat(1)
-    return Thermostat(data, 1, thermostat)
+    return Thermostat(data, 1, thermostat, hass)
 
 
 async def test_name(thermostat) -> None:
@@ -186,6 +217,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
         "climate_mode": "Climate1",
         "fan_min_on_time": 10,
         "equipment_running": "heatPump2",
+        "available_sensors": [],
+        "active_sensors": [],
     }
 
     ecobee_fixture["equipmentStatus"] = "auxHeat2"
@@ -194,6 +227,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
         "climate_mode": "Climate1",
         "fan_min_on_time": 10,
         "equipment_running": "auxHeat2",
+        "available_sensors": [],
+        "active_sensors": [],
     }
 
     ecobee_fixture["equipmentStatus"] = "compCool1"
@@ -202,6 +237,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
         "climate_mode": "Climate1",
         "fan_min_on_time": 10,
         "equipment_running": "compCool1",
+        "available_sensors": [],
+        "active_sensors": [],
     }
     ecobee_fixture["equipmentStatus"] = ""
     assert thermostat.extra_state_attributes == {
@@ -209,6 +246,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
         "climate_mode": "Climate1",
         "fan_min_on_time": 10,
         "equipment_running": "",
+        "available_sensors": [],
+        "active_sensors": [],
     }
 
     ecobee_fixture["equipmentStatus"] = "Unknown"
@@ -217,6 +256,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
         "climate_mode": "Climate1",
         "fan_min_on_time": 10,
         "equipment_running": "Unknown",
+        "available_sensors": [],
+        "active_sensors": [],
     }
 
     ecobee_fixture["program"]["currentClimateRef"] = "c2"
@@ -225,6 +266,8 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None:
         "climate_mode": "Climate2",
         "fan_min_on_time": 10,
         "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(
         [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"
-- 
GitLab