Browse Source

Add support for Smart Thermostat Radiator (#402)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Retha Runolfsson 1 day ago
parent
commit
1d6fe628b9

+ 8 - 0
switchbot/__init__.py

@@ -13,12 +13,15 @@ from .const import (
     AirPurifierMode,
     BulbColorMode,
     CeilingLightColorMode,
+    ClimateAction,
+    ClimateMode,
     ColorMode,
     FanMode,
     HumidifierAction,
     HumidifierMode,
     HumidifierWaterLevel,
     LockStatus,
+    SmartThermostatRadiatorMode,
     StripLightColorMode,
     SwitchbotAccountConnectionError,
     SwitchbotApiError,
@@ -54,6 +57,7 @@ from .devices.relay_switch import (
     SwitchbotRelaySwitch2PM,
 )
 from .devices.roller_shade import SwitchbotRollerShade
+from .devices.smart_thermostat_radiator import SwitchbotSmartThermostatRadiator
 from .devices.vacuum import SwitchbotVacuum
 from .discovery import GetSwitchbotDevices
 from .models import SwitchBotAdvertisement
@@ -62,6 +66,8 @@ __all__ = [
     "AirPurifierMode",
     "BulbColorMode",
     "CeilingLightColorMode",
+    "ClimateAction",
+    "ClimateMode",
     "ColorMode",
     "FanMode",
     "GetSwitchbotDevices",
@@ -69,6 +75,7 @@ __all__ = [
     "HumidifierMode",
     "HumidifierWaterLevel",
     "LockStatus",
+    "SmartThermostatRadiatorMode",
     "StripLightColorMode",
     "SwitchBotAdvertisement",
     "Switchbot",
@@ -99,6 +106,7 @@ __all__ = [
     "SwitchbotRelaySwitch2PM",
     "SwitchbotRgbicLight",
     "SwitchbotRollerShade",
+    "SwitchbotSmartThermostatRadiator",
     "SwitchbotStripLight3",
     "SwitchbotSupportedType",
     "SwitchbotSupportedType",

+ 7 - 0
switchbot/adv_parser.py

@@ -43,6 +43,7 @@ from .adv_parsers.relay_switch import (
 )
 from .adv_parsers.remote import process_woremote
 from .adv_parsers.roller_shade import process_worollershade
+from .adv_parsers.smart_thermostat_radiator import process_smart_thermostat_radiator
 from .adv_parsers.vacuum import process_vacuum, process_vacuum_k
 from .const import SwitchbotModel
 from .models import SwitchBotAdvertisement
@@ -377,6 +378,12 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_climate_panel,
         "manufacturer_id": 2409,
     },
+    b"\x00\x116@": {
+        "modelName": SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
+        "modelFriendlyName": "Smart Thermostat Radiator",
+        "func": process_smart_thermostat_radiator,
+        "manufacturer_id": 2409,
+    },
 }
 
 _SWITCHBOT_MODEL_TO_CHAR = {

+ 58 - 0
switchbot/adv_parsers/smart_thermostat_radiator.py

@@ -0,0 +1,58 @@
+"""Smart Thermostat Radiator"""
+
+import logging
+
+from ..const.climate import SmartThermostatRadiatorMode
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def process_smart_thermostat_radiator(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int | str]:
+    """Process Smart Thermostat Radiator data."""
+    if mfr_data is None:
+        return {}
+
+    _seq_num = mfr_data[6]
+    _isOn = bool(mfr_data[7] & 0b10000000)
+    _battery = mfr_data[7] & 0b01111111
+
+    temp_data = mfr_data[8:11]
+    target_decimal = (temp_data[0] >> 4) & 0x0F
+    local_decimal = temp_data[0] & 0x0F
+
+    local_sign = 1 if (temp_data[1] & 0x80) else -1
+    local_int = temp_data[1] & 0x7F
+    local_temp = local_sign * (local_int + (local_decimal / 10))
+
+    target_sign = 1 if (temp_data[2] & 0x80) else -1
+    target_int = temp_data[2] & 0x7F
+    target_temp = target_sign * (target_int + (target_decimal / 10))
+
+    last_mode = SmartThermostatRadiatorMode.get_mode_name((mfr_data[11] >> 4) & 0x0F)
+    mode = SmartThermostatRadiatorMode.get_mode_name(mfr_data[11] & 0x07)
+
+    need_update_temp = bool((mfr_data[12] >> 5) & 0x01)
+    restarted = bool((mfr_data[12] >> 4) & 0x01)
+    fault_code = (mfr_data[12] >> 1) & 0x07
+    door_open = bool(mfr_data[12] & 0x01)
+
+    result = {
+        "sequence_number": _seq_num,
+        "isOn": _isOn,
+        "battery": _battery,
+        "temperature": local_temp,
+        "target_temperature": target_temp,
+        "mode": mode,
+        "last_mode": last_mode,
+        "need_update_temp": need_update_temp,
+        "restarted": restarted,
+        "fault_code": fault_code,
+        "door_open": door_open,
+    }
+
+    _LOGGER.debug(
+        "Smart Thermostat Radiator mfr data: %s, result: %s", mfr_data.hex(), result
+    )
+    return result

+ 5 - 0
switchbot/const/__init__.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 from ..enum import StrEnum
 from .air_purifier import AirPurifierMode
+from .climate import ClimateAction, ClimateMode, SmartThermostatRadiatorMode
 from .evaporative_humidifier import (
     HumidifierAction,
     HumidifierMode,
@@ -98,6 +99,7 @@ class SwitchbotModel(StrEnum):
     RGBICWW_FLOOR_LAMP = "RGBICWW Floor Lamp"
     K11_VACUUM = "K11+ Vacuum"
     CLIMATE_PANEL = "Climate Panel"
+    SMART_THERMOSTAT_RADIATOR = "Smart Thermostat Radiator"
 
 
 __all__ = [
@@ -107,12 +109,15 @@ __all__ = [
     "AirPurifierMode",
     "BulbColorMode",
     "CeilingLightColorMode",
+    "ClimateAction",
+    "ClimateMode",
     "ColorMode",
     "FanMode",
     "HumidifierAction",
     "HumidifierMode",
     "HumidifierWaterLevel",
     "LockStatus",
+    "SmartThermostatRadiatorMode",
     "StripLightColorMode",
     "SwitchbotAccountConnectionError",
     "SwitchbotApiError",

+ 50 - 0
switchbot/const/climate.py

@@ -0,0 +1,50 @@
+"""Representation of climate-related constants."""
+
+from enum import Enum
+
+
+class ClimateMode(Enum):
+    """Climate Modes."""
+
+    OFF = 0
+    HEAT = 1
+    COOL = 2
+    HEAT_COOL = 3
+    AUTO = 4
+    DRY = 5
+    FAN_ONLY = 6
+
+
+class ClimateAction(Enum):
+    """Climate Actions."""
+
+    OFF = 0
+    IDLE = 1
+    HEATING = 2
+
+
+class SmartThermostatRadiatorMode(Enum):
+    """Smart Thermostat Radiator Modes."""
+
+    SCHEDULE = 0
+    MANUAL = 1
+    OFF = 2
+    ECONOMIC = 3
+    COMFORT = 4
+    FAST_HEATING = 5
+
+    @property
+    def lname(self) -> str:
+        return self.name.lower()
+
+    @classmethod
+    def get_modes(cls) -> list[str]:
+        return [mode.lname for mode in cls]
+
+    @classmethod
+    def get_mode_name(cls, mode_value: int) -> str:
+        return cls(mode_value).lname
+
+    @classmethod
+    def get_valid_modes(cls) -> list[str]:
+        return [mode.lname for mode in cls if mode != cls.OFF]

+ 223 - 0
switchbot/devices/smart_thermostat_radiator.py

@@ -0,0 +1,223 @@
+"""Smart Thermostat Radiator Device."""
+
+import logging
+from typing import Any
+
+from bleak.backends.device import BLEDevice
+
+from ..const import SwitchbotModel
+from ..const.climate import ClimateAction, ClimateMode
+from ..const.climate import SmartThermostatRadiatorMode as STRMode
+from .device import (
+    SwitchbotEncryptedDevice,
+    SwitchbotOperationError,
+    SwitchbotSequenceDevice,
+    update_after_operation,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEVICE_GET_BASIC_SETTINGS_KEY = "5702"
+
+_modes = STRMode.get_valid_modes()
+SMART_THERMOSTAT_TO_HA_HVAC_MODE = {
+    "off": ClimateMode.OFF,
+    **dict.fromkeys(_modes, ClimateMode.HEAT),
+}
+
+COMMAND_SET_MODE = {
+    mode.lname: f"570F7800{index:02X}" for index, mode in enumerate(STRMode)
+}
+
+# fast heating default use max temperature
+COMMAND_SET_TEMP = {
+    STRMode.MANUAL.lname: "570F7801{temp:04X}",
+    STRMode.ECONOMIC.lname: "570F7802{temp:02X}",
+    STRMode.COMFORT.lname: "570F7803{temp:02X}",
+    STRMode.SCHEDULE.lname: "570F7806{temp:04X}",
+}
+
+MODE_TEMP_RANGE = {
+    STRMode.ECONOMIC.lname: (10.0, 20.0),
+    STRMode.COMFORT.lname: (10.0, 25.0),
+}
+
+DEFAULT_TEMP_RANGE = (5.0, 35.0)
+
+
+class SwitchbotSmartThermostatRadiator(
+    SwitchbotSequenceDevice, SwitchbotEncryptedDevice
+):
+    """Representation of a Switchbot Smart Thermostat Radiator."""
+
+    _turn_off_command = "570100"
+    _turn_on_command = "570101"
+
+    def __init__(
+        self,
+        device: BLEDevice,
+        key_id: str,
+        encryption_key: str,
+        interface: int = 0,
+        model: SwitchbotModel = SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
+        **kwargs: Any,
+    ) -> None:
+        super().__init__(device, key_id, encryption_key, model, interface, **kwargs)
+
+    @classmethod
+    async def verify_encryption_key(
+        cls,
+        device: BLEDevice,
+        key_id: str,
+        encryption_key: str,
+        model: SwitchbotModel = SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
+        **kwargs: Any,
+    ) -> bool:
+        return await super().verify_encryption_key(
+            device, key_id, encryption_key, model, **kwargs
+        )
+
+    @property
+    def min_temperature(self) -> float:
+        """Return the minimum target temperature."""
+        return MODE_TEMP_RANGE.get(self.preset_mode, DEFAULT_TEMP_RANGE)[0]
+
+    @property
+    def max_temperature(self) -> float:
+        """Return the maximum target temperature."""
+        return MODE_TEMP_RANGE.get(self.preset_mode, DEFAULT_TEMP_RANGE)[1]
+
+    @property
+    def preset_modes(self) -> list[str]:
+        """Return the supported preset modes."""
+        return STRMode.get_modes()
+
+    @property
+    def preset_mode(self) -> str | None:
+        """Return the current preset mode."""
+        return self.get_current_mode()
+
+    @property
+    def hvac_modes(self) -> set[ClimateMode]:
+        """Return the supported hvac modes."""
+        return {ClimateMode.HEAT, ClimateMode.OFF}
+
+    @property
+    def hvac_mode(self) -> ClimateMode | None:
+        """Return the current hvac mode."""
+        return SMART_THERMOSTAT_TO_HA_HVAC_MODE.get(self.preset_mode, ClimateMode.OFF)
+
+    @property
+    def hvac_action(self) -> ClimateAction | None:
+        """Return current action."""
+        return self.get_action()
+
+    @property
+    def current_temperature(self) -> float | None:
+        """Return the current temperature."""
+        return self.get_current_temperature()
+
+    @property
+    def target_temperature(self) -> float | None:
+        """Return the target temperature."""
+        return self.get_target_temperature()
+
+    @update_after_operation
+    async def set_hvac_mode(self, hvac_mode: ClimateMode) -> None:
+        """Set the hvac mode."""
+        if hvac_mode == ClimateMode.OFF:
+            return await self.turn_off()
+        return await self.set_preset_mode("comfort")
+
+    @update_after_operation
+    async def set_preset_mode(self, preset_mode: str) -> bool:
+        """Send command to set thermostat preset_mode."""
+        return await self._send_command(COMMAND_SET_MODE[preset_mode])
+
+    @update_after_operation
+    async def set_target_temperature(self, temperature: float) -> bool:
+        """Send command to set target temperature."""
+        if self.preset_mode == STRMode.OFF.lname:
+            raise SwitchbotOperationError("Cannot set temperature when mode is OFF.")
+        if self.preset_mode == STRMode.FAST_HEATING.lname:
+            raise SwitchbotOperationError(
+                "Fast Heating mode defaults to max temperature."
+            )
+
+        temp_value = int(temperature * 10)
+        cmd = COMMAND_SET_TEMP[self.preset_mode].format(temp=temp_value)
+
+        _LOGGER.debug(
+            "Setting temperature %.1f°C in mode %s → cmd=%s",
+            temperature,
+            self.preset_mode,
+            cmd,
+        )
+        return await self._send_command(cmd)
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (_data := await self._get_basic_info()):
+            return None
+        _LOGGER.debug("data: %s", _data)
+
+        battery = _data[1]
+        firmware = _data[2] / 10.0
+        hardware = _data[3]
+        last_mode = STRMode.get_mode_name((_data[4] >> 3) & 0x07)
+        mode = STRMode.get_mode_name(_data[4] & 0x07)
+        temp_raw_value = _data[5] << 8 | _data[6]
+        temp_sign = 1 if temp_raw_value >> 15 else -1
+        temperature = temp_sign * (temp_raw_value & 0x7FFF) / 10.0
+        manual_target_temp = (_data[7] << 8 | _data[8]) / 10.0
+        comfort_target_temp = _data[9] / 10.0
+        economic_target_temp = _data[10] / 10.0
+        fast_heat_time = _data[11]
+        child_lock = bool(_data[12] & 0x03)
+        target_temp = (_data[13] << 8 | _data[14]) / 10.0
+        door_open = bool(_data[14] & 0x01)
+
+        result = {
+            "battery": battery,
+            "firmware": firmware,
+            "hardware": hardware,
+            "last_mode": last_mode,
+            "mode": mode,
+            "temperature": temperature,
+            "manual_target_temp": manual_target_temp,
+            "comfort_target_temp": comfort_target_temp,
+            "economic_target_temp": economic_target_temp,
+            "fast_heat_time": fast_heat_time,
+            "child_lock": child_lock,
+            "target_temp": target_temp,
+            "door_open": door_open,
+        }
+
+        _LOGGER.debug("Smart Thermostat Radiator basic info: %s", result)
+        return result
+
+    def is_on(self) -> bool | None:
+        """Return true if the thermostat is on."""
+        return self._get_adv_value("isOn")
+
+    def get_current_mode(self) -> str | None:
+        """Return the current mode of the thermostat."""
+        return self._get_adv_value("mode")
+
+    def door_open(self) -> bool | None:
+        """Return true if the door is open."""
+        return self._get_adv_value("door_open")
+
+    def get_current_temperature(self) -> float | None:
+        """Return the current temperature."""
+        return self._get_adv_value("temperature")
+
+    def get_target_temperature(self) -> float | None:
+        """Return the target temperature."""
+        return self._get_adv_value("target_temperature")
+
+    def get_action(self) -> ClimateAction:
+        """Return current action from cache."""
+        if not self.is_on():
+            return ClimateAction.OFF
+        return ClimateAction.HEATING

+ 22 - 0
tests/__init__.py

@@ -80,3 +80,25 @@ RGBICWW_FLOOR_LAMP_INFO = AdvTestCase(
     "Rgbic Floor Lamp",
     SwitchbotModel.RGBICWW_FLOOR_LAMP,
 )
+
+
+SMART_THERMOSTAT_RADIATOR_INFO = AdvTestCase(
+    b"\xb0\xe9\xfe\xa2T|6\xe4\x00\x9c\xa3A\x00",
+    b"\x00 d\x00\x116@",
+    {
+        "battery": 100,
+        "door_open": False,
+        "fault_code": 0,
+        "isOn": True,
+        "last_mode": "comfort",
+        "mode": "manual",
+        "sequence_number": 54,
+        "need_update_temp": False,
+        "restarted": False,
+        "target_temperature": 35.0,
+        "temperature": 28.0,
+    },
+    b"\x00\x116@",
+    "Smart Thermostat Radiator",
+    SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
+)

+ 48 - 0
tests/test_adv_parser.py

@@ -3448,6 +3448,26 @@ def test_humidifer_with_empty_data() -> None:
             "Climate Panel",
             SwitchbotModel.CLIMATE_PANEL,
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\xa2T|6\xe4\x00\x9c\xa3A\x00",
+            b"\x00 d\x00\x116@",
+            {
+                "battery": 100,
+                "door_open": False,
+                "fault_code": 0,
+                "isOn": True,
+                "last_mode": "comfort",
+                "mode": "manual",
+                "sequence_number": 54,
+                "need_update_temp": False,
+                "restarted": False,
+                "target_temperature": 35.0,
+                "temperature": 28.0,
+            },
+            b"\x00\x116@",
+            "Smart Thermostat Radiator",
+            SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
+        ),
     ],
 )
 def test_adv_active(test_case: AdvTestCase) -> None:
@@ -3680,6 +3700,26 @@ def test_adv_active(test_case: AdvTestCase) -> None:
             "Climate Panel",
             SwitchbotModel.CLIMATE_PANEL,
         ),
+        AdvTestCase(
+            b"\xb0\xe9\xfe\xa2T|6\xe4\x00\x9c\xa3A\x00",
+            None,
+            {
+                "battery": 100,
+                "door_open": False,
+                "fault_code": 0,
+                "isOn": True,
+                "last_mode": "comfort",
+                "mode": "manual",
+                "sequence_number": 54,
+                "need_update_temp": False,
+                "restarted": False,
+                "target_temperature": 35.0,
+                "temperature": 28.0,
+            },
+            b"\x00\x116@",
+            "Smart Thermostat Radiator",
+            SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
+        ),
     ],
 )
 def test_adv_passive(test_case: AdvTestCase) -> None:
@@ -3853,6 +3893,14 @@ def test_adv_passive(test_case: AdvTestCase) -> None:
             "Climate Panel",
             SwitchbotModel.CLIMATE_PANEL,
         ),
+        AdvTestCase(
+            None,
+            b"\x00 d\x00\x116@",
+            {},
+            b"\x00\x116@",
+            "Smart Thermostat Radiator",
+            SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
+        ),
     ],
 )
 def test_adv_with_empty_data(test_case: AdvTestCase) -> None:

+ 239 - 0
tests/test_smart_thermostat_radiator.py

@@ -0,0 +1,239 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from bleak.backends.device import BLEDevice
+
+from switchbot import SwitchBotAdvertisement
+from switchbot.const.climate import ClimateAction, ClimateMode
+from switchbot.const.climate import SmartThermostatRadiatorMode as STRMode
+from switchbot.devices.device import SwitchbotEncryptedDevice, SwitchbotOperationError
+from switchbot.devices.smart_thermostat_radiator import (
+    COMMAND_SET_MODE,
+    COMMAND_SET_TEMP,
+    SwitchbotSmartThermostatRadiator,
+)
+
+from . import SMART_THERMOSTAT_RADIATOR_INFO
+from .test_adv_parser import AdvTestCase, generate_ble_device
+
+
+def create_device_for_command_testing(
+    adv_info: AdvTestCase,
+    init_data: dict | None = None,
+):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    device = SwitchbotSmartThermostatRadiator(
+        ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=adv_info.modelName
+    )
+    device.update_from_advertisement(
+        make_advertisement_data(ble_device, adv_info, init_data)
+    )
+    device._send_command = AsyncMock()
+    device._check_command_result = MagicMock()
+    device.update = AsyncMock()
+    return device
+
+
+def make_advertisement_data(
+    ble_device: BLEDevice, adv_info: AdvTestCase, init_data: dict | None = None
+):
+    """Set advertisement data with defaults."""
+    if init_data is None:
+        init_data = {}
+
+    return SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": adv_info.service_data,
+            "data": adv_info.data | init_data,
+            "isEncrypted": False,
+            "model": adv_info.model,
+            "modelFriendlyName": adv_info.modelFriendlyName,
+            "modelName": adv_info.modelName,
+        }
+        | init_data,
+        device=ble_device,
+        rssi=-80,
+        active=True,
+    )
+
+
+@pytest.mark.asyncio
+async def test_default_info() -> None:
+    device = create_device_for_command_testing(SMART_THERMOSTAT_RADIATOR_INFO)
+
+    assert device.min_temperature == 5.0
+    assert device.max_temperature == 35.0
+    assert device.preset_mode == STRMode.MANUAL.lname
+    assert device.preset_modes == STRMode.get_modes()
+    assert device.hvac_mode == ClimateMode.HEAT
+    assert device.hvac_modes == {ClimateMode.OFF, ClimateMode.HEAT}
+    assert device.hvac_action == ClimateAction.HEATING
+    assert device.target_temperature == 35.0
+    assert device.current_temperature == 28.0
+    assert device.door_open() is False
+
+
+@pytest.mark.asyncio
+async def test_default_info_with_off_mode() -> None:
+    device = create_device_for_command_testing(
+        SMART_THERMOSTAT_RADIATOR_INFO, {"mode": STRMode.OFF.lname, "isOn": False}
+    )
+    assert device.hvac_action == ClimateAction.OFF
+
+
+@pytest.mark.parametrize(
+    ("mode", "expected_command"),
+    [
+        (ClimateMode.OFF, "570100"),
+        (ClimateMode.HEAT, COMMAND_SET_MODE[STRMode.COMFORT.lname]),
+    ],
+)
+@pytest.mark.asyncio
+async def test_set_hvac_mode_commands(mode, expected_command) -> None:
+    device = create_device_for_command_testing(SMART_THERMOSTAT_RADIATOR_INFO)
+
+    await device.set_hvac_mode(mode)
+    device._send_command.assert_awaited_with(expected_command)
+
+
+@pytest.mark.parametrize(
+    ("preset_mode", "expected_command"),
+    [
+        (STRMode.SCHEDULE.lname, COMMAND_SET_MODE[STRMode.SCHEDULE.lname]),
+        (STRMode.MANUAL.lname, COMMAND_SET_MODE[STRMode.MANUAL.lname]),
+        (STRMode.OFF.lname, COMMAND_SET_MODE[STRMode.OFF.lname]),
+        (STRMode.ECONOMIC.lname, COMMAND_SET_MODE[STRMode.ECONOMIC.lname]),
+        (STRMode.COMFORT.lname, COMMAND_SET_MODE[STRMode.COMFORT.lname]),
+        (STRMode.FAST_HEATING.lname, COMMAND_SET_MODE[STRMode.FAST_HEATING.lname]),
+    ],
+)
+@pytest.mark.asyncio
+async def test_set_preset_mode_commands(preset_mode, expected_command) -> None:
+    device = create_device_for_command_testing(SMART_THERMOSTAT_RADIATOR_INFO)
+
+    await device.set_preset_mode(preset_mode)
+    device._send_command.assert_awaited_with(expected_command)
+
+
+@pytest.mark.asyncio
+async def test_set_target_temperature_command() -> None:
+    device = create_device_for_command_testing(SMART_THERMOSTAT_RADIATOR_INFO)
+
+    await device.set_target_temperature(22.5)
+    device._send_command.assert_awaited_with(
+        COMMAND_SET_TEMP[STRMode.MANUAL.lname].format(temp=225)
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("mode", "match"),
+    [
+        (STRMode.OFF.lname, "Cannot set temperature when mode is OFF."),
+        (STRMode.FAST_HEATING.lname, "Fast Heating mode defaults to max temperature."),
+    ],
+)
+async def test_set_target_temperature_with_invalid_mode(mode, match) -> None:
+    device = create_device_for_command_testing(
+        SMART_THERMOSTAT_RADIATOR_INFO, {"mode": mode}
+    )
+
+    with pytest.raises(SwitchbotOperationError, match=match):
+        await device.set_target_temperature(22.5)
+
+
+@pytest.mark.asyncio
+async def test_get_basic_info_none() -> None:
+    device = create_device_for_command_testing(SMART_THERMOSTAT_RADIATOR_INFO)
+    device._get_basic_info = AsyncMock(return_value=None)
+
+    assert await device.get_basic_info() is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("basic_info", "result"),
+    [
+        (
+            b"\x01d\x08>\x14\x80\xe6\x00(\x82\xbe\x00T\x00\x82\x00\x00",
+            [
+                100,
+                0.8,
+                62,
+                "off",
+                "comfort",
+                23.0,
+                4.0,
+                13.0,
+                19.0,
+                0,
+                False,
+                13.0,
+                False,
+            ],
+        ),
+        (
+            b"\x01d\x08>#\x80\xf0\x00(\x82\xbe\x00T\x00\x82\x00\x00",
+            [
+                100,
+                0.8,
+                62,
+                "comfort",
+                "economic",
+                24.0,
+                4.0,
+                13.0,
+                19.0,
+                0,
+                False,
+                13.0,
+                False,
+            ],
+        ),
+    ],
+)
+async def test_get_basic_info_parsing(basic_info, result) -> None:
+    device = create_device_for_command_testing(SMART_THERMOSTAT_RADIATOR_INFO)
+    device._get_basic_info = AsyncMock(return_value=basic_info)
+
+    info = await device.get_basic_info()
+    assert info["battery"] == result[0]
+    assert info["firmware"] == result[1]
+    assert info["hardware"] == result[2]
+    assert info["last_mode"] == result[3]
+    assert info["mode"] == result[4]
+    assert info["temperature"] == result[5]
+    assert info["manual_target_temp"] == result[6]
+    assert info["comfort_target_temp"] == result[7]
+    assert info["economic_target_temp"] == result[8]
+    assert info["fast_heat_time"] == result[9]
+    assert info["child_lock"] == result[10]
+    assert info["target_temp"] == result[11]
+    assert info["door_open"] == result[12]
+
+
+@pytest.mark.asyncio
+@patch.object(SwitchbotEncryptedDevice, "verify_encryption_key", new_callable=AsyncMock)
+async def test_verify_encryption_key(mock_parent_verify):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    key_id = "ff"
+    encryption_key = "ffffffffffffffffffffffffffffffff"
+
+    mock_parent_verify.return_value = True
+
+    result = await SwitchbotSmartThermostatRadiator.verify_encryption_key(
+        device=ble_device,
+        key_id=key_id,
+        encryption_key=encryption_key,
+        model=SMART_THERMOSTAT_RADIATOR_INFO.modelName,
+    )
+
+    mock_parent_verify.assert_awaited_once_with(
+        ble_device,
+        key_id,
+        encryption_key,
+        SMART_THERMOSTAT_RADIATOR_INFO.modelName,
+    )
+
+    assert result is True