From 93b3d76ee2b91204ec4d26a91038b7911102eee4 Mon Sep 17 00:00:00 2001 From: Steve HOLWEG Date: Thu, 16 Jan 2025 19:34:30 +0100 Subject: [PATCH] Add button to move netatmo cover to preferred position (#134722) --- homeassistant/components/netatmo/button.py | 73 ++++++++++++++ homeassistant/components/netatmo/const.py | 2 + .../components/netatmo/data_handler.py | 6 +- homeassistant/components/netatmo/icons.json | 5 + homeassistant/components/netatmo/strings.json | 5 + .../netatmo/snapshots/test_button.ambr | 95 +++++++++++++++++++ tests/components/netatmo/test_button.py | 72 ++++++++++++++ 7 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/netatmo/button.py create mode 100644 tests/components/netatmo/snapshots/test_button.ambr create mode 100644 tests/components/netatmo/test_button.py diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py new file mode 100644 index 00000000000..7b2899c84aa --- /dev/null +++ b/homeassistant/components/netatmo/button.py @@ -0,0 +1,73 @@ +"""Support for Netatmo/Bubendorff button.""" + +from __future__ import annotations + +import logging + +from pyatmo import modules as NaModules + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .entity import NetatmoModuleEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Netatmo button platform.""" + + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoCoverPreferredPositionButton(netatmo_device) + _LOGGER.debug("Adding button %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_BUTTON, _create_entity) + ) + + +class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): + """Representation of a Netatmo cover preferred position button device.""" + + _attr_configuration_url = CONF_URL_CONTROL + _attr_entity_registry_enabled_default = False + _attr_translation_key = "preferred_position" + device: NaModules.Shutter + + def __init__(self, netatmo_device: NetatmoDevice) -> None: + """Initialize the Netatmo device.""" + super().__init__(netatmo_device) + + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self.home.entity_id, + SIGNAL_NAME: f"{HOME}-{self.home.entity_id}", + }, + ] + ) + self._attr_unique_id = ( + f"{self.device.entity_id}-{self.device_type}-preferred_position" + ) + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + # No state to update for button + + async def async_press(self) -> None: + """Handle button press to move the cover to a preferred position.""" + _LOGGER.debug("Moving %s to a preferred position", self.device.entity_id) + await self.device.async_move_to_preferred_position() diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 74f2ebc84b2..d69a62f37f9 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -10,6 +10,7 @@ DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CAMERA, Platform.CLIMATE, Platform.COVER, @@ -45,6 +46,7 @@ NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" NETATMO_CREATE_COVER = "netatmo_create_cover" +NETATMO_CREATE_BUTTON = "netatmo_create_button" NETATMO_CREATE_FAN = "netatmo_create_fan" NETATMO_CREATE_LIGHT = "netatmo_create_light" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 3a28c3b8336..283ccc3740e 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -33,6 +33,7 @@ from .const import ( DOMAIN, MANUFACTURER, NETATMO_CREATE_BATTERY, + NETATMO_CREATE_BUTTON, NETATMO_CREATE_CAMERA, NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, @@ -350,7 +351,10 @@ class NetatmoDataHandler: NETATMO_CREATE_CAMERA_LIGHT, ], NetatmoDeviceCategory.dimmer: [NETATMO_CREATE_LIGHT], - NetatmoDeviceCategory.shutter: [NETATMO_CREATE_COVER], + NetatmoDeviceCategory.shutter: [ + NETATMO_CREATE_COVER, + NETATMO_CREATE_BUTTON, + ], NetatmoDeviceCategory.switch: [ NETATMO_CREATE_LIGHT, NETATMO_CREATE_SWITCH, diff --git a/homeassistant/components/netatmo/icons.json b/homeassistant/components/netatmo/icons.json index 9f712e08f33..099c6aa1784 100644 --- a/homeassistant/components/netatmo/icons.json +++ b/homeassistant/components/netatmo/icons.json @@ -13,6 +13,11 @@ } } }, + "button": { + "preferred_position": { + "default": "mdi:window-shutter-auto" + } + }, "sensor": { "temp_trend": { "default": "mdi:trending-up" diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 6b91aa204b2..23b800e460d 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -181,6 +181,11 @@ } } }, + "button": { + "preferred_position": { + "name": "Preferred position" + } + }, "sensor": { "temp_trend": { "name": "Temperature trend" diff --git a/tests/components/netatmo/snapshots/test_button.ambr b/tests/components/netatmo/snapshots/test_button.ambr new file mode 100644 index 00000000000..6ad1b9e78ba --- /dev/null +++ b/tests/components/netatmo/snapshots/test_button.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_entity[button.bubendorff_blind_preferred_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.bubendorff_blind_preferred_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preferred position', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preferred_position', + 'unique_id': '0009999993-DeviceType.NBO-preferred_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[button.bubendorff_blind_preferred_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Bubendorff blind Preferred position', + }), + 'context': , + 'entity_id': 'button.bubendorff_blind_preferred_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity[button.entrance_blinds_preferred_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.entrance_blinds_preferred_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preferred position', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preferred_position', + 'unique_id': '0009999992-DeviceType.NBR-preferred_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[button.entrance_blinds_preferred_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Entrance Blinds Preferred position', + }), + 'context': , + 'entity_id': 'button.entrance_blinds_preferred_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/netatmo/test_button.py b/tests/components/netatmo/test_button.py new file mode 100644 index 00000000000..681e42af051 --- /dev/null +++ b/tests/components/netatmo/test_button.py @@ -0,0 +1,72 @@ +"""The tests for Netatmo button.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.BUTTON, + entity_registry, + snapshot, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_setup_and_services( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: + """Test setup and services.""" + with selected_platforms([Platform.BUTTON]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + button_entity = "button.entrance_blinds_preferred_position" + + assert hass.states.get(button_entity).state == STATE_UNKNOWN + + # Test button press + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: button_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "0009999992", + "target_position": -2, + "bridge": "12:34:56:30:d5:d4", + } + ] + } + ) + + assert (state := hass.states.get(button_entity)) + assert state.state != STATE_UNKNOWN