From 05d57167d2e0972d4e6ba9bc8d5403eb2ed94230 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:54:07 +0800 Subject: [PATCH] Add support for switchbot keypad vision (#160484) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- .../components/switchbot/__init__.py | 12 ++- .../components/switchbot/binary_sensor.py | 5 +- homeassistant/components/switchbot/event.py | 63 ++++++++++++++ tests/components/switchbot/test_event.py | 85 +++++++++++++++++++ tests/components/switchbot/test_sensor.py | 73 ++++++++++++++++ 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/switchbot/event.py create mode 100644 tests/components/switchbot/test_event.py diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index c002318d6da..d5946644e26 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -127,8 +127,16 @@ PLATFORMS_BY_TYPE = { Platform.BINARY_SENSOR, Platform.BUTTON, ], - SupportedModels.KEYPAD_VISION.value: [Platform.SENSOR, Platform.BINARY_SENSOR], - SupportedModels.KEYPAD_VISION_PRO.value: [Platform.SENSOR, Platform.BINARY_SENSOR], + SupportedModels.KEYPAD_VISION.value: [ + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.EVENT, + ], + SupportedModels.KEYPAD_VISION_PRO.value: [ + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.EVENT, + ], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index f98b3569247..ef035bbfdf2 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -85,10 +85,13 @@ BINARY_SENSOR_TYPES: dict[str, SwitchbotBinarySensorEntityDescription] = { ), "battery_charging": SwitchbotBinarySensorEntityDescription( key="battery_charging", - translation_key="battery_charging", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ), + "tamper_alarm": SwitchbotBinarySensorEntityDescription( + key="tamper_alarm", + device_class=BinarySensorDeviceClass.TAMPER, + ), } diff --git a/homeassistant/components/switchbot/event.py b/homeassistant/components/switchbot/event.py new file mode 100644 index 00000000000..30ccca7ea95 --- /dev/null +++ b/homeassistant/components/switchbot/event.py @@ -0,0 +1,63 @@ +"""Support for SwitchBot event entities.""" + +from __future__ import annotations + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +PARALLEL_UPDATES = 0 + +EVENT_TYPES = { + "doorbell": EventEntityDescription( + key="doorbell", + device_class=EventDeviceClass.DOORBELL, + event_types=["ring"], + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the SwitchBot event platform.""" + coordinator = config_entry.runtime_data + async_add_entities( + SwitchbotEventEntity(coordinator, event, description) + for event, description in EVENT_TYPES.items() + if event in coordinator.device.parsed_data + ) + + +class SwitchbotEventEntity(SwitchbotEntity, EventEntity): + """Representation of a SwitchBot event.""" + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + event: str, + description: EventEntityDescription, + ) -> None: + """Initialize the SwitchBot event.""" + super().__init__(coordinator) + self._event = event + self.entity_description = description + self._attr_unique_id = f"{coordinator.base_unique_id}-{event}" + self._previous_value = False + + @callback + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + value = bool(self.parsed_data.get(self._event, False)) + if value and not self._previous_value: + self._trigger_event("ring") + self._previous_value = value diff --git a/tests/components/switchbot/test_event.py b/tests/components/switchbot/test_event.py new file mode 100644 index 00000000000..75dfe8e58fe --- /dev/null +++ b/tests/components/switchbot/test_event.py @@ -0,0 +1,85 @@ +"""Test the switchbot event entities.""" + +from collections.abc import Callable +from unittest.mock import patch + +import pytest + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.switchbot.const import DOMAIN +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import KEYPAD_VISION_PRO_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import ( + generate_advertisement_data, + generate_ble_device, + inject_bluetooth_service_info, +) + + +def _with_doorbell_event( + info: BluetoothServiceInfoBleak, +) -> BluetoothServiceInfoBleak: + """Return a BLE service info with the doorbell bit set.""" + mfr_data = bytearray(info.manufacturer_data[2409]) + mfr_data[12] |= 0b00001000 + updated_mfr_data = {2409: bytes(mfr_data)} + return BluetoothServiceInfoBleak( + name=info.name, + manufacturer_data=updated_mfr_data, + service_data=info.service_data, + service_uuids=info.service_uuids, + address=info.address, + rssi=info.rssi, + source=info.source, + advertisement=generate_advertisement_data( + local_name=info.name, + manufacturer_data=updated_mfr_data, + service_data=info.service_data, + service_uuids=info.service_uuids, + ), + device=generate_ble_device(info.address, info.name), + time=info.time, + connectable=info.connectable, + tx_power=info.tx_power, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_keypad_vision_pro_doorbell_event( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], +) -> None: + """Test keypad vision pro doorbell event entity (encrypted device).""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, KEYPAD_VISION_PRO_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="keypad_vision_pro") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.sensor.switchbot.SwitchbotKeypadVision.update", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "event.test_name_doorbell" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + inject_bluetooth_service_info( + hass, _with_doorbell_event(KEYPAD_VISION_PRO_INFO) + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNKNOWN + assert state.attributes["event_type"] == "ring" + assert state.attributes["event_types"] == ["ring"] diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index cc2471b2724..8b270f884d8 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.switchbot.const import ( CONF_ENCRYPTION_KEY, @@ -18,6 +19,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE, + STATE_OFF, STATE_ON, ) from homeassistant.core import HomeAssistant @@ -29,6 +31,8 @@ from . import ( EVAPORATIVE_HUMIDIFIER_SERVICE_INFO, HUB3_SERVICE_INFO, HUBMINI_MATTER_SERVICE_INFO, + KEYPAD_VISION_INFO, + KEYPAD_VISION_PRO_INFO, LEAK_SERVICE_INFO, PLUG_MINI_EU_SERVICE_INFO, PRESENCE_SENSOR_SERVICE_INFO, @@ -843,3 +847,72 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("adv_info", "sensor_type", "charging_state"), + [ + (KEYPAD_VISION_INFO, "keypad_vision", STATE_ON), + (KEYPAD_VISION_PRO_INFO, "keypad_vision_pro", STATE_OFF), + ], +) +async def test_keypad_vision_sensor( + hass: HomeAssistant, + adv_info: BluetoothServiceInfoBleak, + sensor_type: str, + charging_state: str, +) -> None: + """Test setting up creates the sensors for Keypad Vision (Pro).""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, adv_info) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: sensor_type, + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.sensor.switchbot.SwitchbotKeypadVision.update", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + assert len(hass.states.async_all("binary_sensor")) == 2 + + battery_sensor = hass.states.get("sensor.test_name_battery") + battery_sensor_attrs = battery_sensor.attributes + assert battery_sensor + assert battery_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Battery" + assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + tamper_sensor = hass.states.get("binary_sensor.test_name_tamper") + tamper_sensor_attrs = tamper_sensor.attributes + assert tamper_sensor + assert tamper_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Tamper" + assert tamper_sensor.state == STATE_OFF + + charging_sensor = hass.states.get("binary_sensor.test_name_charging") + charging_sensor_attrs = charging_sensor.attributes + assert charging_sensor + assert charging_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Charging" + assert charging_sensor.state == charging_state + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done()