Repository: zigpy/zha-device-handlers

Issue:
[Device Support Request] Aqara E1 thermostat schedule
**Is your feature request related to a problem? Please describe.**
Cannot set schedule settings on Aqara E1 thermostat.

**Describe the solution you'd like**
I would like schedule settings to be exposed in zigbee attributes, similar to what zigbee2mqtt does:
https://www.zigbee2mqtt.io/devices/SRTS-A01.html#schedule-settings-text
In reality any way to set the schedules would be good.

<details>
<!-- Device signature can be acquired by clicking on the "Zigbee Device Signature" button in the device settings view -->
<summary>Device signature</summary>

```yaml
{
  "node_descriptor": "NodeDescriptor(logical_type=<LogicalType.EndDevice: 2>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress: 128>, manufacturer_code=4447, maximum_buffer_size=127, maximum_incoming_transfer_size=100, server_mask=11264, maximum_outgoing_transfer_size=100, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)",
  "endpoints": {
    "1": {
      "profile_id": 260,
      "device_type": "0x0301",
      "in_clusters": [
        "0x0000",
        "0x0001",
        "0x0003",
        "0x000a",
        "0x0201",
        "0xfcc0"
      ],
      "out_clusters": [
        "0x0003",
        "0x0019",
        "0x0201",
        "0xfcc0"
      ]
    }
  },
  "manufacturer": "LUMI",
  "model": "lumi.airrtc.agl001",
  "class": "zhaquirks.xiaomi.aqara.thermostat_agl001.AGL001"
}
```

</details>

<details>
<!-- Diagnostic information can be acquired by clicking on the "Download Diagnostics" button in the device settings view -->
<summary>Diagnostic information</summary>
Cannot get diagnostic information, this is what I get when I click it:

```yaml
2023-04-27 17:42:30.941 ERROR (MainThread) [aiohttp.server] Error handling request
Traceback (most recent call last):
  File "/usr/local/lib/python3.10/site-packages/aiohttp/web_protocol.py", line 433, in _handle_request
    resp = await request_handler(request)
  File "/usr/local/lib/python3.10/site-packages/aiohttp/web_app.py", line 504, in _handle
    resp = await handler(request)
  File "/usr/local/lib/python3.10/site-packages/aiohttp/web_middlewares.py", line 117, in impl
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/security_filter.py", line 85, in security_filter_middleware
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/forwarded.py", line 100, in forwarded_middleware
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/request_context.py", line 28, in request_context_middleware
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/ban.py", line 80, in ban_middleware
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/auth.py", line 235, in auth_middleware
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/view.py", line 146, in handle
    result = await result
  File "/usr/src/homeassistant/homeassistant/components/diagnostics/__init__.py", line 265, in get
    data = await info.device_diagnostics(hass, config_entry, device)
  File "/usr/src/homeassistant/homeassistant/components/zha/diagnostics.py", line 104, in async_get_device_diagnostics
    device_info[CLUSTER_DETAILS] = get_endpoint_cluster_attr_data(zha_device)
  File "/usr/src/homeassistant/homeassistant/components/zha/diagnostics.py", line 126, in get_endpoint_cluster_attr_data
    ATTR_IN_CLUSTERS: {
  File "/usr/src/homeassistant/homeassistant/components/zha/diagnostics.py", line 129, in <dictcomp>
    **get_cluster_attr_data(cluster),
  File "/usr/src/homeassistant/homeassistant/components/zha/diagnostics.py", line 155, in get_cluster_attr_data
    UNSUPPORTED_ATTRIBUTES: {
  File "/usr/src/homeassistant/homeassistant/components/zha/diagnostics.py", line 156, in <dictcomp>
    f"0x{cluster.find_attribute(u_attr).id:04x}": {
  File "/usr/local/lib/python3.10/site-packages/zigpy/zcl/__init__.py", line 181, in find_attribute
    return self.attributes[name_or_id]
KeyError: 900
```

</details>


Discussion:
How about using this for even more flexibility:
https://github.com/nielsfaber/scheduler-card
With this:
https://github.com/nielsfaber/scheduler-component
As far as I know this would only work when Homeassistant is online?
And it would interfere with built in scheduler in TRV?
Well, one of the main points with HA is keeping it online, at least on the LAN to be able to do these things.
Do you ever turn it off?
As for the built in scheduler in the TRV, I just set mine to manual and let HA take care of everything.

I still would like to find a solution for the built in scheduler.
An initial attempt for adding the native scheduling option of the device was made here:
- https://github.com/zigpy/zha-device-handlers/pull/2109

At some point in the future, I might get to updating that PR, adding tests, and so on. The [author mentioned that the code can be taken as-is](https://github.com/zigpy/zha-device-handlers/pull/2109#issuecomment-1448713374), so if anyone wants to update it, add tests, and then PR again, feel free to do so.

The goal would be to have a fake attribute which can be written to using the ZHA service call.
In the even further future, it might be nice to have a ZHA configuration text entity to select the schedule too.
Also, the issue with not being able to download diagnostic for your device is a ZHA/HA Core issue (which we shouldn't forget about 😄).
How did you implement some functionality for this TRV before? I mean how did you find what "register" in device did what? Did you look at zigbee2mqtt code or something else?
is it possible to add an external temp. sensor to the TRV? There is a value in Diagnose but how to set this up?
> is it possible to add an external temp. sensor to the TRV? There is a value in Diagnose but how to set this up?

There are several ways to do this. Create a virtual thermostat in config.yaml, or you could probably use this:
https://community.home-assistant.io/t/target-temperature-adjustment-depending-on-second-temperature-sensor/297287
> > is it possible to add an external temp. sensor to the TRV? There is a value in Diagnose but how to set this up?
> 
> There are several ways to do this. Create a virtual thermostat in config.yaml, or you could probably use this: https://community.home-assistant.io/t/target-temperature-adjustment-depending-on-second-temperature-sensor/297287

but this is only a possibilty to set this on a "software" level. I hope for a solution more on the hardware level so that the TRV is directly connected to the temp. sensor
@Mar1usW3 Do you have the Aqara Temperature and humidity sensor. This is the only sensor supported directly from the TRV.
@ShogunMan yes
Well, then you link the sensor with the TRV in the Aqara/smart life app and it will report the temp read by that sensor.

but this will only work with an Aqara Hub. In this PR https://github.com/zigpy/zha-device-handlers/pull/2109 there is also the attribute for a temp. sensor included. I ask myself if it will be then possible to link a temp. sensor to the TRV.
You will need to link the sensor using the hub & app. It is still a software solution, no matter how you look at it.
When the sensor has been linked, you can probably disconnect the hub. It should be possible to send/write the zigbee ID of the external sensor to the appropriate cluster of the TRV using a custom command, but this would need more documentation.
Even when linking the sensor, the Aqara hub is still required I'm pretty sure.
It's sending a special message to the E1 thermostat. ZHA could implement this as a "fake attribute".
> Even when linking the sensor, the Aqara hub is still required I'm pretty sure. It's sending a special message to the E1 thermostat. ZHA could implement this as a "fake attribute".

I have a floor heating termostat that I needed the Tuya hub to configure, as all config options was not available in ZHA. These configurations stuck afterwards when removing the hub.

If the external sensor of the TRV is not stored on the device, but is applied through the hub/app then it is a pure software solution anyway and one might just use the blueprint for thermostat with external sensor. Same outcome, no difference.
> An initial attempt for adding the native scheduling option of the device was made here:
> 
>     * [Expand support for Aqara E1 Thermostat #2109](https://github.com/zigpy/zha-device-handlers/pull/2109)
> 
> 
> At some point in the future, I might get to updating that PR, adding tests, and so on. The [author mentioned that the code can be taken as-is](https://github.com/zigpy/zha-device-handlers/pull/2109#issuecomment-1448713374), so if anyone wants to update it, add tests, and then PR again, feel free to do so.
> 
> The goal would be to have a fake attribute which can be written to using the ZHA service call. In the even further future, it might be nice to have a ZHA configuration text entity to select the schedule too.

Are you sure it's that pull request? Since there seems to be nothing about schedule there (only external temp sensor).
Unless of course you meant that some preparatory work was there only.

Resolving patch:
diff --git a/zhaquirks/xiaomi/aqara/thermostat_agl001.py b/zhaquirks/xiaomi/aqara/thermostat_agl001.py
--- a/zhaquirks/xiaomi/aqara/thermostat_agl001.py
+++ b/zhaquirks/xiaomi/aqara/thermostat_agl001.py
@@ -1,6 +1,9 @@
 """Aqara E1 Radiator Thermostat Quirk."""
 from __future__ import annotations
 
+from functools import reduce
+import math
+import struct
 from typing import Any
 
 from zigpy.profiles import zha
@@ -41,11 +44,23 @@
 WINDOW_OPEN = 0x027A
 CALIBRATED = 0x027B
 SCHEDULE = 0x027D
+SCHEDULE_SETTINGS = 0x0276
 SENSOR = 0x027E
 BATTERY_PERCENTAGE = 0x040A
 
 XIAOMI_CLUSTER_ID = 0xFCC0
 
+DAYS_MAP = {
+    "mon": 0x02,
+    "tue": 0x04,
+    "wed": 0x08,
+    "thu": 0x10,
+    "fri": 0x20,
+    "sat": 0x40,
+    "sun": 0x80,
+}
+NEXT_DAY_FLAG = 1 << 15
+
 
 class ThermostatCluster(CustomCluster, Thermostat):
     """Thermostat cluster."""
@@ -123,6 +138,228 @@ async def write_attributes(
         return result
 
 
+class ScheduleEvent:
+    """Schedule event object"""
+
+    _is_next_day = False
+
+    def __init__(self, value, is_next_day=False):
+        if isinstance(value, bytes):
+            self._verify_buffer_len(value)
+            self._time = self._read_time_from_buf(value)
+            self._temp = self._read_temp_from_buf(value)
+            self._validate_time(self._time)
+            self._validate_temp(self._temp)
+        elif isinstance(value, str):
+            groups = value.split(",")
+            if len(groups) != 2:
+                raise ValueError("Time and temperature must contain ',' separator")
+            self._time = self._parse_time(groups[0])
+            self._temp = self._parse_temp(groups[1])
+            self._validate_time(self._time)
+            self._validate_temp(self._temp)
+        else:
+            raise TypeError(
+                f"Cannot create ScheduleEvent object from type: {type(value)}"
+            )
+        self._is_next_day = is_next_day
+
+    @staticmethod
+    def _verify_buffer_len(buf):
+        if len(buf) != 6:
+            raise ValueError("Buffer size must equal 6")
+
+    @staticmethod
+    def _read_time_from_buf(buf):
+        time = struct.unpack_from(">H", buf, offset=0)[0]
+        time &= ~NEXT_DAY_FLAG
+        return time
+
+    @staticmethod
+    def _parse_time(str):
+        parts = str.split(":")
+        if len(parts) != 2:
+            raise ValueError("Time must contain ':' separator")
+
+        hours = int(parts[0])
+        minutes = int(parts[1])
+
+        return hours * 60 + minutes
+
+    @staticmethod
+    def _read_temp_from_buf(buf):
+        return struct.unpack_from(">H", buf, offset=4)[0] / 100
+
+    @staticmethod
+    def _parse_temp(str):
+        return float(str)
+
+    @staticmethod
+    def _validate_time(time):
+        if time <= 0:
+            raise ValueError("Time must be between 00:00 and 23:59")
+        if time > 24 * 60:
+            raise ValueError("Time must be between 00:00 and 23:59")
+
+    @staticmethod
+    def _validate_temp(temp):
+        if temp < 5:
+            raise ValueError("Temperature must be between 5 and 30 °C")
+        if temp > 30:
+            raise ValueError("Temperature must be between 5 and 30 °C")
+        if (temp * 10) % 5 != 0:
+            raise ValueError("Temperature must be whole or half degrees")
+
+    def _write_time_to_buf(self, buf):
+        time = self._time
+        if self._is_next_day:
+            time |= NEXT_DAY_FLAG
+        struct.pack_into(">H", buf, 0, time)
+
+    def _write_temp_to_buf(self, buf):
+        struct.pack_into(">H", buf, 4, int(self._temp * 100))
+
+    def is_next_day(self):
+        return self._is_next_day
+
+    def set_next_day(self, is_next_day):
+        self._is_next_day = is_next_day
+
+    def get_time(self):
+        return self._time
+
+    def __str__(self):
+        return f"{math.floor(self._time / 60)}:{f'{self._time % 60:0>2}'},{f'{self._temp:.1f}'}"
+
+    def serialize(self):
+        result = bytearray(6)
+        self._write_time_to_buf(result)
+        self._write_temp_to_buf(result)
+        return result
+
+
+class ScheduleSettings(t.LVBytes):
+    """Schedule settings object"""
+
+    def __new__(cls, value):
+        day_selection = None
+        events = [None] * 4
+        if isinstance(value, bytes):
+            ScheduleSettings._verify_buffer_len(value)
+            ScheduleSettings._verify_magic_byte(value)
+            day_selection = ScheduleSettings._read_day_selection(value)
+            for i in range(4):
+                events[i] = ScheduleSettings._read_event(value, i)
+        elif isinstance(value, str):
+            groups = value.split("|")
+            ScheduleSettings._verify_string(groups)
+            day_selection = ScheduleSettings._read_day_selection(groups[0])
+            for i in range(4):
+                events[i] = ScheduleSettings._read_event(groups[i + 1], i)
+        else:
+            raise TypeError(
+                f"Cannot create ScheduleSettings object from type: {type(value)}"
+            )
+
+        for i in range(1, 4):
+            if events[i].get_time() < events[i - 1].get_time():
+                events[i].set_next_day(True)
+        ScheduleSettings._verify_event_durations(events)
+
+        result = bytearray(b"\x04")
+        result.append(ScheduleSettings._get_day_selection_byte(day_selection))
+        for e in events:
+            result.extend(e.serialize())
+        return super().__new__(cls, bytes(result))
+
+    @staticmethod
+    def _verify_buffer_len(buf):
+        if len(buf) != 26:
+            raise ValueError("Buffer size must equal 26")
+
+    @staticmethod
+    def _verify_magic_byte(buf):
+        if struct.unpack_from("c", buf, offset=0)[0][0] != 0x04:
+            raise ValueError("Magic byte must be equal to 0x04")
+
+    @staticmethod
+    def _verify_string(groups):
+        if len(groups) != 5:
+            raise ValueError("There must be 5 groups in a string")
+        days = groups[0].split(",")
+        ScheduleSettings._verify_day_selection_in_str(days)
+
+    @staticmethod
+    def _verify_day_selection_in_str(days):
+        if len(days) == 0 or len(days) > 7:
+            raise ValueError("Number of days selected must be between 1 and 7")
+        if len(days) != len(set(days)):
+            raise ValueError("Duplicate day names present")
+        for d in days:
+            if d not in DAYS_MAP.keys():
+                raise ValueError(
+                    f"String: {d} is not a valid day name, valid names: mon, tue, wed, thu, fri, sat, sun"
+                )
+
+    @staticmethod
+    def _read_day_selection(value):
+        day_selection = []
+        if isinstance(value, bytes):
+            byte = struct.unpack_from("c", value, offset=1)[0][0]
+            if byte & 0x01:
+                raise ValueError("Incorrect day selected")
+            for i in DAYS_MAP:
+                if byte & DAYS_MAP[i]:
+                    day_selection.append(i)
+            ScheduleSettings._verify_day_selection_in_str(day_selection)
+        elif isinstance(value, str):
+            day_selection = value.split(",")
+            ScheduleSettings._verify_day_selection_in_str(day_selection)
+        return day_selection
+
+    @staticmethod
+    def _read_event(value, index):
+        if isinstance(value, bytes):
+            event_buf = value[2 + index * 6 : 8 + index * 6]
+            return ScheduleEvent(event_buf)
+        elif isinstance(value, str):
+            return ScheduleEvent(value)
+
+    @staticmethod
+    def _verify_event_durations(events):
+        full_day = 24 * 60
+        prev_time = events[0].get_time()
+        durations = []
+        for i in range(1, 4):
+            event = events[i]
+            if event.is_next_day():
+                durations.append(full_day - prev_time + event.get_time())
+            else:
+                durations.append(event.get_time() - prev_time)
+            prev_time = event.get_time()
+        if any(d < 60 for d in durations):
+            raise ValueError("The individual times must be at least 1 hour apart")
+        if reduce((lambda x, y: x + y), durations) > full_day:
+            raise ValueError("The start and end times must be at most 24 hours apart")
+
+    @staticmethod
+    def _get_day_selection_byte(day_selection):
+        byte = 0x00
+        for d in day_selection:
+            byte |= DAYS_MAP[d]
+        return byte
+
+    def __str__(self):
+        day_selection = ScheduleSettings._read_day_selection(self)
+        events = [None] * 4
+        for i in range(4):
+            events[i] = ScheduleSettings._read_event(self, i)
+        result = ",".join(day_selection)
+        for e in events:
+            result += f"|{e}"
+        return result
+
+
 class AqaraThermostatSpecificCluster(XiaomiAqaraE1Cluster):
     """Aqara manufacturer specific settings."""
 
@@ -139,6 +376,7 @@ class AqaraThermostatSpecificCluster(XiaomiAqaraE1Cluster):
             WINDOW_OPEN: ("window_open", t.uint8_t, True),
             CALIBRATED: ("calibrated", t.uint8_t, True),
             SCHEDULE: ("schedule", t.uint8_t, True),
+            SCHEDULE_SETTINGS: ("schedule_settings", ScheduleSettings, True),
             SENSOR: ("sensor", t.uint8_t, True),
             BATTERY_PERCENTAGE: ("battery_percentage", t.uint8_t, True),
         }


Test patch:
diff --git a/tests/test_xiaomi.py b/tests/test_xiaomi.py
--- a/tests/test_xiaomi.py
+++ b/tests/test_xiaomi.py
@@ -81,6 +81,7 @@
 import zhaquirks.xiaomi.aqara.sensor_ht_agl02
 import zhaquirks.xiaomi.aqara.smoke
 import zhaquirks.xiaomi.aqara.switch_t1
+from zhaquirks.xiaomi.aqara.thermostat_agl001 import ScheduleEvent, ScheduleSettings
 import zhaquirks.xiaomi.aqara.weather
 import zhaquirks.xiaomi.mija.motion
 
@@ -1020,6 +1021,106 @@ async def test_xiaomi_e1_thermostat_attribute_update(zigpy_device_from_quirk, qu
     assert power_config_listener.attribute_updates[0][1] == 100  # ZCL is doubled
 
 
+@pytest.mark.parametrize(
+    "schedule_settings",
+    [
+        "mon,tue,wed,thu,fri|8:00,24.0|18:00,17.0|23:00,22.0|8:00,22.0",
+        "mon,tue,wed,thu,fri,sat,sun|8:00,24.0|18:00,17.0|23:00,22.0|8:00,22.0",
+        "mon|8:00,21.5|18:30,17.5|23:00,22.0|8:00,22.5",
+    ],
+)
+async def test_xiaomi_e1_thermostat_schedule_settings_string_representation(
+    schedule_settings,
+):
+    """Test creation of ScheduleSettings from str and converting back to same str"""
+
+    s = ScheduleSettings(schedule_settings)
+    assert str(s) == schedule_settings
+
+
+@pytest.mark.parametrize(
+    "schedule_settings",
+    [
+        "invalid|8:00,24.0|18:00,17.0|23:00,22.0|8:00,22.0",
+        "mon,tue,wed,thu,fri|8:00,24.0|18:00,17.0|23:00,22.0",
+        "mon,tue,wed,thu,fri|8:00,24.0|18:00,17.0|23:00,22.0|8:00,22.0|9:00,25.0",
+        "mon,tue,wed,thu,fri,sat,sun,some_day|8:00,24.0|18:00,17.0|23:00,22.0|8:00,22.0",
+        "mon|some_time,21.5|18:30,17.5|23:00,22.0|8:00,22.5",
+        "mon|8:00,some_temp|18:30,17.5|23:00,22.0|8:00,22.5",
+        "mon,tue,wed,thu,fri|8:00,24.0|8:30,17.0|23:00,22.0|8:00,22.0",
+        "mon,tue,wed,thu,fri|8:00,24.0|18:00,17.0|23:00,22.0|9:00,22.0",
+        "mon,tue,wed,thu,fri|8:00.24.0|18:00,17.0|23:00,22.0|9:00,22.0",
+        "mon,tue,wed,thu,fri|-8:00,24.0|18:00,17.0|23:00,22.0|9:00,22.0",
+        "mon,tue,wed,thu,fri|8:00,24.0|18:00,17.0|23:00,22.0|25:00,22.0",
+        "mon,tue,wed,thu,fri|8:00,03.0|18:00,17.0|23:00,22.0|9:00,22.0",
+        "mon,tue,wed,thu,fri|8:00,31.0|18:00,17.0|23:00,22.0|9:00,22.0",
+        "mon,mon|8:00,24.0|18:00,17.0|23:00,22.0|8:00,22.0",
+        "mon,tue,wed,thu,fri|8:00,24.1|18:00,17.0|23:00,22.0|9:00,22.0",
+        b"\x04>\x01\xe0\x00\x00\t`\x048\x00\x00\x06\xa4\x05d\x00\x00\x08\x98\x81\xe0\x00\x00\x08\x98\x00",
+        b"\x00>\x01\xe0\x00\x00\t`\x048\x00\x00\x06\xa4\x05d\x00\x00\x08\x98\x81\xe0\x00\x00\x08\x98",
+        b"\x04\x01\x01\xe0\x00\x00\t`\x048\x00\x00\x06\xa4\x05d\x00\x00\x08\x98\x81\xe0\x00\x00\x08\x98",
+        None,
+    ],
+)
+async def test_xiaomi_e1_thermostat_schedule_settings_data_validation(
+    schedule_settings,
+):
+    """Test data validation of ScheduleSettings class"""
+
+    with pytest.raises(Exception):
+        ScheduleSettings(schedule_settings)
+
+
+@pytest.mark.parametrize(
+    "schedule_event",
+    [
+        b"\x01\xe0\x00\x00",
+        None,
+    ],
+)
+async def test_xiaomi_e1_thermostat_schedule_event_data_validation(schedule_event):
+    """Test data validation of ScheduleEvent class"""
+
+    with pytest.raises(Exception):
+        ScheduleEvent(schedule_event)
+
+
+@pytest.mark.parametrize(
+    "schedule_settings, expected_bytes",
+    [
+        (
+            "mon,tue,wed,thu,fri|8:00,24.0|18:00,17.0|23:00,22.0|8:00,22.0",
+            b"\x1a\x04>\x01\xe0\x00\x00\t`\x048\x00\x00\x06\xa4\x05d\x00\x00\x08\x98\x81\xe0\x00\x00\x08\x98",
+        )
+    ],
+)
+async def test_xiaomi_e1_thermostat_schedule_settings_serialization(
+    schedule_settings, expected_bytes
+):
+    """Test that serialization works correctly."""
+
+    s = ScheduleSettings(schedule_settings)
+    assert s.serialize() == expected_bytes
+
+
+@pytest.mark.parametrize(
+    "schedule_settings, expected_string",
+    [
+        (
+            b"\x04>\x01\xe0\x00\x00\t`\x048\x00\x00\x06\xa4\x05d\x00\x00\x08\x98\x81\xe0\x00\x00\x08\x98",
+            "mon,tue,wed,thu,fri|8:00,24.0|18:00,17.0|23:00,22.0|8:00,22.0",
+        )
+    ],
+)
+async def test_xiaomi_e1_thermostat_schedule_settings_deserialization(
+    schedule_settings, expected_string
+):
+    """Test that deserialization works correctly."""
+
+    s = ScheduleSettings(schedule_settings)
+    assert str(s) == expected_string
+
+
 @pytest.mark.parametrize(
     "quirk, invalid_iilluminance_report",
     (