1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-14 23:28:42 +00:00

Switchbot Cloud: Add new supported device Ai Art Frame (#160754)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Samuel Xiao
2026-02-13 08:47:03 +08:00
committed by GitHub
parent ce704dd5f7
commit f18fa07019
8 changed files with 317 additions and 14 deletions

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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",

View File

@@ -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)

View File

@@ -253,6 +253,7 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
BATTERY_DESCRIPTION,
),
"Smart Radiator Thermostat": (BATTERY_DESCRIPTION,),
"AI Art Frame": (BATTERY_DESCRIPTION,),
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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