From f18fa07019a629bd73fa5f98a653349cdfd2deb7 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:47:03 +0800 Subject: [PATCH] Switchbot Cloud: Add new supported device Ai Art Frame (#160754) Co-authored-by: Joostlek --- .../components/switchbot_cloud/__init__.py | 10 ++ .../components/switchbot_cloud/button.py | 99 ++++++++++++++++--- .../components/switchbot_cloud/icons.json | 8 ++ .../components/switchbot_cloud/image.py | 71 +++++++++++++ .../components/switchbot_cloud/sensor.py | 1 + .../components/switchbot_cloud/strings.json | 14 +++ .../components/switchbot_cloud/test_button.py | 44 ++++++++- .../components/switchbot_cloud/test_image.py | 84 ++++++++++++++++ 8 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/switchbot_cloud/image.py create mode 100644 tests/components/switchbot_cloud/test_image.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 7759bc66bfb..a2e35c6ce57 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -33,6 +33,7 @@ PLATFORMS: list[Platform] = [ Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, + Platform.IMAGE, Platform.LIGHT, Platform.LOCK, Platform.SENSOR, @@ -62,6 +63,7 @@ class SwitchbotDevices: fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) lights: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) humidifiers: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + images: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @dataclass @@ -307,6 +309,14 @@ async def make_device_data( devices_data.binary_sensors.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) + if isinstance(device, Device) and device.device_type == "AI Art Frame": + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.buttons.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) + devices_data.images.append((device, coordinator)) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" diff --git a/homeassistant/components/switchbot_cloud/button.py b/homeassistant/components/switchbot_cloud/button.py index aae2758f3ca..d64139a052c 100644 --- a/homeassistant/components/switchbot_cloud/button.py +++ b/homeassistant/components/switchbot_cloud/button.py @@ -1,19 +1,61 @@ """Support for the Switchbot Bot as a Button.""" +from dataclasses import dataclass from typing import Any -from switchbot_api import BotCommands +from switchbot_api import ( + Commands as SwitchBotCloudBaseCommands, + Device, + Remote, + SwitchBotAPI, +) +from switchbot_api.commands import ArtFrameCommands, BotCommands, CommonCommands -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData +from . import SwitchbotCloudData, SwitchBotCoordinator from .const import DOMAIN from .entity import SwitchBotCloudEntity +@dataclass(frozen=True, kw_only=True) +class SwitchbotCloudButtonEntityDescription(ButtonEntityDescription): + """Switchbot Cloud Button EntityDescription.""" + + command: SwitchBotCloudBaseCommands = CommonCommands.PRESS + command_type: str = "command" + parameters: dict | str = "default" + + +BOT_BUTTON_DESCRIPTION = SwitchbotCloudButtonEntityDescription( + key="Button", command=BotCommands.PRESS, name=None +) + +ART_FRAME_NEXT_BUTTON_DESCRIPTION = SwitchbotCloudButtonEntityDescription( + key="next", + translation_key="art_frame_next_picture", + command=ArtFrameCommands.NEXT, +) + +ART_FRAME_PREVIOUS_BUTTON_DESCRIPTION = SwitchbotCloudButtonEntityDescription( + key="previous", + translation_key="art_frame_previous_picture", + command=ArtFrameCommands.PREVIOUS, +) + + +BUTTON_DESCRIPTIONS_BY_DEVICE_TYPES = { + "Bot": (BOT_BUTTON_DESCRIPTION,), + "AI Art Frame": ( + ART_FRAME_NEXT_BUTTON_DESCRIPTION, + ART_FRAME_PREVIOUS_BUTTON_DESCRIPTION, + ), +} + + async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, @@ -21,21 +63,52 @@ async def async_setup_entry( ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] - async_add_entities( - SwitchBotCloudBot(data.api, device, coordinator) - for device, coordinator in data.devices.buttons - ) + entities: list[SwitchBotCloudBot] = [] + for device, coordinator in data.devices.buttons: + description_set = BUTTON_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] + for description in description_set: + entities.extend( + [_async_make_entity(data.api, device, coordinator, description)] + ) + async_add_entities(entities) class SwitchBotCloudBot(SwitchBotCloudEntity, ButtonEntity): """Representation of a SwitchBot Bot.""" - _attr_name = None + entity_description: SwitchbotCloudButtonEntityDescription - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def __init__( + self, + api: SwitchBotAPI, + device: Device, + coordinator: SwitchBotCoordinator, + description: SwitchbotCloudButtonEntityDescription, + ) -> None: + """Initialize SwitchBot Cloud Button entity.""" + + super().__init__(api, device, coordinator) + self.entity_description = description + if description.key != "Button": + self._attr_unique_id = f"{device.device_id}-{description.key}" + self._device_id = device.device_id async def async_press(self, **kwargs: Any) -> None: - """Bot press command.""" - await self.send_api_command(BotCommands.PRESS) + """Button press command.""" + await self._api.send_command( + self._device_id, + self.entity_description.command.value, + self.entity_description.command_type, + self.entity_description.parameters, + ) + + +@callback +def _async_make_entity( + api: SwitchBotAPI, + device: Device | Remote, + coordinator: SwitchBotCoordinator, + description: SwitchbotCloudButtonEntityDescription, +) -> SwitchBotCloudBot: + """Make a button entity.""" + return SwitchBotCloudBot(api, device, coordinator, description) diff --git a/homeassistant/components/switchbot_cloud/icons.json b/homeassistant/components/switchbot_cloud/icons.json index a9a74feb5a1..ca1cbf81dce 100644 --- a/homeassistant/components/switchbot_cloud/icons.json +++ b/homeassistant/components/switchbot_cloud/icons.json @@ -1,5 +1,13 @@ { "entity": { + "button": { + "art_frame_next_picture": { + "default": "mdi:chevron-right-box" + }, + "art_frame_previous_picture": { + "default": "mdi:chevron-left-box" + } + }, "fan": { "air_purifier": { "default": "mdi:air-purifier", diff --git a/homeassistant/components/switchbot_cloud/image.py b/homeassistant/components/switchbot_cloud/image.py new file mode 100644 index 00000000000..e6966845ae0 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/image.py @@ -0,0 +1,71 @@ +"""Support for the Switchbot Image.""" + +import datetime + +from switchbot_api import Device, Remote, SwitchBotAPI +from switchbot_api.utils import get_file_stream_from_cloud + +from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData, SwitchBotCoordinator +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.images + ) + + +class SwitchBotCloudImage(SwitchBotCloudEntity, ImageEntity): + """Base Class for SwitchBot Image.""" + + _attr_translation_key = "display" + + def __init__( + self, + api: SwitchBotAPI, + device: Device | Remote, + coordinator: SwitchBotCoordinator, + ) -> None: + """Initialize the image entity.""" + super().__init__(api, device, coordinator) + ImageEntity.__init__(self, self.coordinator.hass) + self._image_content = b"" + + async def async_image(self) -> bytes | None: + """Async image.""" + if ( + not isinstance(self._attr_image_url, str) + or len(self._attr_image_url.strip()) == 0 + ): + self._image_content = b"" + return None + self._image_content = await get_file_stream_from_cloud(self._attr_image_url, 5) + return self._image_content + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + self._attr_image_last_updated = datetime.datetime.now() + self._attr_image_url = self.coordinator.data.get("imageUrl") + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> SwitchBotCloudImage: + """Make a SwitchBotCloudImage.""" + return SwitchBotCloudImage(api, device, coordinator) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index b5ff0e88e61..22b3b2b2390 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -253,6 +253,7 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { BATTERY_DESCRIPTION, ), "Smart Radiator Thermostat": (BATTERY_DESCRIPTION,), + "AI Art Frame": (BATTERY_DESCRIPTION,), } diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index 4436f562e4f..d37a92c6448 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -18,6 +18,14 @@ } }, "entity": { + "button": { + "art_frame_next_picture": { + "name": "Next" + }, + "art_frame_previous_picture": { + "name": "Previous" + } + }, "fan": { "air_purifier": { "state_attributes": { @@ -48,6 +56,12 @@ } } }, + "image": { + "display": { + "name": "Display" + } + }, + "sensor": { "light_level": { "name": "Light level" diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py index 8c74709fdf5..9c3b25b4c9a 100644 --- a/tests/components/switchbot_cloud/test_button.py +++ b/tests/components/switchbot_cloud/test_button.py @@ -2,7 +2,9 @@ from unittest.mock import patch +import pytest from switchbot_api import BotCommands, Device +from switchbot_api.commands import ArtFrameCommands from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.switchbot_cloud import SwitchBotAPI @@ -40,7 +42,7 @@ async def test_pressmode_bot( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True ) mock_send_command.assert_called_once_with( - "bot-id-1", BotCommands.PRESS, "command", "default" + "bot-id-1", BotCommands.PRESS.value, "command", "default" ) assert hass.states.get(entity_id).state != STATE_UNKNOWN @@ -65,3 +67,43 @@ async def test_switchmode_bot_no_button_entity( entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED assert not hass.states.async_entity_ids(BUTTON_DOMAIN) + + +@pytest.mark.parametrize( + "buttons", + [ + ("AI Art Frame", ArtFrameCommands.PREVIOUS), + ("AI Art Frame", ArtFrameCommands.NEXT), + ], +) +async def test_loaded_buttons( + hass: HomeAssistant, mock_list_devices, mock_get_status, buttons +) -> None: + """Test press.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="device_id", + deviceName="device_name", + deviceType=buttons[0], + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = { + "deviceType": buttons[0], + } + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = f"button.device_name_{buttons[1].value}" + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + assert hass.states.get(entity_id).state != STATE_UNKNOWN diff --git a/tests/components/switchbot_cloud/test_image.py b/tests/components/switchbot_cloud/test_image.py new file mode 100644 index 00000000000..72047f94d7b --- /dev/null +++ b/tests/components/switchbot_cloud/test_image.py @@ -0,0 +1,84 @@ +"""Test for the switchbot_cloud image.""" + +from unittest.mock import AsyncMock, patch + +from switchbot_api import Device + +from homeassistant.components.switchbot_cloud import DOMAIN +from homeassistant.components.switchbot_cloud.image import SwitchBotCloudImage +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_coordinator_data_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test coordinator data is none.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="ai-art-frame-id-1", + deviceName="ai-art-frame-1", + deviceType="AI Art Frame", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [None, None] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "image.ai_art_frame_1_display" + state = hass.states.get(entity_id) + assert state.state is STATE_UNKNOWN + + +async def test_async_image( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test coordinator data is none.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="ai-art-frame-id-1", + deviceName="ai-art-frame-1", + deviceType="AI Art Frame", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + { + "deviceId": "B0E9FEA5D7F0", + "deviceType": "AI Art Frame", + "hubDeviceId": "B0E9FEA5D7F0", + "battery": 0, + "displayMode": 1, + "imageUrl": "https://p3.itc.cn/images01/20231215/2f2db37e221c4ad3af575254c7769ca1.jpeg", + "version": "V0.0-0.5", + } + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cloud_data = hass.data[DOMAIN][entry.entry_id] + device, coordinator = cloud_data.devices.images[0] + image_entity = SwitchBotCloudImage(cloud_data.api, device, coordinator) + + # 1. load before refresh + await image_entity.async_image() + assert image_entity._image_content == b"" + + # 2. load after refresh + with patch( + "homeassistant.components.switchbot_cloud.image.get_file_stream_from_cloud", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = b"this is a bytes" + image_entity._attr_image_url = ( + "https://p3.itc.cn/images01/20231215/2f2db37e221c4ad3af575254c7769ca1.jpeg" + ) + await image_entity.async_image() + assert image_entity._image_content == mock_get.return_value