Ver Fonte

feat(ceiling_light): add night light mode support (#564)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
CougarSSS há 5 dias atrás
pai
commit
e018f3165a
2 ficheiros alterados com 123 adições e 4 exclusões
  1. 42 4
      switchbot/devices/ceiling_light.py
  2. 81 0
      tests/test_ceiling_light.py

+ 42 - 4
switchbot/devices/ceiling_light.py

@@ -27,6 +27,7 @@ class SwitchbotCeilingLight(SwitchbotSequenceBaseLight):
     _turn_off_command = f"{CEILING_LIGHT_CONTROL_HEADER}02FF01FFFF"
     _set_brightness_command = f"{CEILING_LIGHT_CONTROL_HEADER}01FF01{{}}"
     _set_color_temp_command = f"{CEILING_LIGHT_CONTROL_HEADER}01FF01{{}}"
+    _set_night_light_command = f"{CEILING_LIGHT_CONTROL_HEADER}01{{}}01{{}}"
     _get_basic_info_command = ["5702", "570f5581"]
 
     @property
@@ -37,9 +38,8 @@ class SwitchbotCeilingLight(SwitchbotSequenceBaseLight):
     @property
     def color_mode(self) -> ColorMode:
         """Return the current color mode."""
-        device_mode = CeilingLightColorMode(
-            value if (value := self._get_adv_value("color_mode")) is not None else 10
-        )
+        value = self._state.get("color_mode", self._get_adv_value("color_mode"))
+        device_mode = CeilingLightColorMode(value if value is not None else 10)
         return _CEILING_LIGHT_COLOR_MODE_MAP.get(device_mode, ColorMode.OFF)
 
     @update_after_operation
@@ -52,6 +52,40 @@ class SwitchbotCeilingLight(SwitchbotSequenceBaseLight):
         result = await self._send_command(self._set_brightness_command.format(hex_data))
         return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
+    async def set_night_light(self, is_on: bool, brightness: int | None = None) -> bool:
+        """
+        Toggle night light color mode on or off.
+
+        Powers the light on and, unless `brightness` is given, resets
+        brightness to 20% (night) or 100% (otherwise) — mirroring the
+        official app's night light toggle.
+        """
+        color_mode = (
+            CeilingLightColorMode.NIGHT if is_on else CeilingLightColorMode.COLOR_TEMP
+        )
+        hex_mode = f"{color_mode.value:02X}"
+        if brightness is None:
+            # The app's night light toggle always pairs NIGHT with 20%
+            # brightness and COLOR_TEMP with 100%, carrying over the
+            # last-set color temp.
+            brightness = 20 if is_on else 100
+        else:
+            self._validate_brightness(brightness)
+        color_temp = self._state.get("cw", DEFAULT_COLOR_TEMP)
+        hex_data = f"{brightness:02X}{color_temp:04X}"
+        result = await self._send_command(
+            self._set_night_light_command.format(hex_mode, hex_data)
+        )
+        return self._check_command_result(result, 0, {1})
+
+    def is_night_light_on(self) -> bool | None:
+        """Return the cached night light color mode state."""
+        value = self._state.get("color_mode")
+        if value is None:
+            return None
+        return value == CeilingLightColorMode.NIGHT.value
+
     async def get_basic_info(self) -> dict[str, Any] | None:
         """Get device basic settings."""
         if not (
@@ -65,10 +99,14 @@ class SwitchbotCeilingLight(SwitchbotSequenceBaseLight):
             self._state["cw"] = color_temp
         else:
             self._state.setdefault("cw", DEFAULT_COLOR_TEMP)
+        # Cached in self._state (not the adv-data merge) because the
+        # advertisement parser hardcodes color_mode, which would otherwise
+        # clobber this value on the next advertisement.
+        self._state["color_mode"] = (_data[1] & 0b01000000) >> 6
 
         return {
             "isOn": bool(_data[1] & 0b10000000),
-            "color_mode": (_data[1] & 0b01000000) >> 6,
+            "color_mode": self._state["color_mode"],
             "brightness": _data[2] & 0b01111111,
             "cw": self._state["cw"],
             "firmware": _version_info[2] / 10.0,

+ 81 - 0
tests/test_ceiling_light.py

@@ -133,6 +133,7 @@ async def test_get_basic_info(info_data, result):
     assert info["cw"] == result[2]
     assert info["color_mode"] == result[3]
     assert info["firmware"] == result[4]
+    assert device.is_night_light_on() is bool(result[3])
 
 
 @pytest.mark.asyncio
@@ -210,6 +211,76 @@ async def test_turn_off():
     assert device.is_on() is False
 
 
+@pytest.mark.asyncio
+async def test_set_night_light_on():
+    """Test turning night light mode on."""
+    device = create_device_for_command_testing()
+    device._state = {"cw": 2700}
+
+    await device.set_night_light(True)
+
+    device._send_command.assert_called_with(
+        device._set_night_light_command.format("01", "140A8C")
+    )
+
+
+@pytest.mark.asyncio
+async def test_set_night_light_off():
+    """Test turning night light mode off."""
+    device = create_device_for_command_testing()
+    device._state = {"cw": 2700}
+
+    await device.set_night_light(False)
+
+    device._send_command.assert_called_with(
+        device._set_night_light_command.format("00", "640A8C")
+    )
+
+
+@pytest.mark.asyncio
+async def test_set_night_light_custom_brightness():
+    """Test that an explicit brightness overrides the night-light default."""
+    device = create_device_for_command_testing()
+    device._state = {"cw": 2700}
+
+    await device.set_night_light(True, brightness=30)
+
+    device._send_command.assert_called_with(
+        device._set_night_light_command.format("01", "1E0A8C")
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("brightness", [-1, 101, 300])
+async def test_set_night_light_invalid_brightness(brightness):
+    """Test that an out-of-range explicit brightness is rejected."""
+    device = create_device_for_command_testing()
+    device._state = {"cw": 2700}
+
+    with pytest.raises(ValueError, match="Brightness must be between 0 and 100"):
+        await device.set_night_light(True, brightness=brightness)
+
+    device._send_command.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("color_mode", "expected"),
+    [
+        (0, False),
+        (1, True),
+        (None, None),
+    ],
+)
+async def test_is_night_light_on(color_mode, expected):
+    """Test reading the cached night light state."""
+    device = create_device_for_command_testing()
+    if color_mode is not None:
+        device._state = {"color_mode": color_mode}
+
+    assert device.is_night_light_on() is expected
+
+
 @pytest.mark.asyncio
 async def test_set_brightness():
     """Test setting brightness."""
@@ -239,3 +310,13 @@ async def test_get_color_mode(adv_value, expected_color_mode):
 
     with patch.object(device, "_get_adv_value", return_value=adv_value):
         assert device.color_mode == expected_color_mode
+
+
+@pytest.mark.asyncio
+async def test_get_color_mode_prefers_cached_state():
+    """Test that color_mode prefers the _state cache over the adv value."""
+    device = create_device_for_command_testing()
+    device._state = {"color_mode": 4}  # MUSIC -> EFFECT
+
+    with patch.object(device, "_get_adv_value", return_value=0):  # COLOR_TEMP
+        assert device.color_mode == ColorMode.EFFECT