diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 78ef9e99d509..529eeb5aa6da 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -36,7 +36,7 @@ from .const import CONF_PASSKEY, DOMAIN from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator from .services import async_setup_services -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] +PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/bsblan/button.py b/homeassistant/components/bsblan/button.py new file mode 100644 index 000000000000..9d3261814a2a --- /dev/null +++ b/homeassistant/components/bsblan/button.py @@ -0,0 +1,59 @@ +"""Button platform for BSB-Lan integration.""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BSBLanConfigEntry, BSBLanData +from .coordinator import BSBLanFastCoordinator +from .entity import BSBLanEntity +from .helpers import async_sync_device_time + +PARALLEL_UPDATES = 1 + +BUTTON_DESCRIPTIONS: tuple[ButtonEntityDescription, ...] = ( + ButtonEntityDescription( + key="sync_time", + translation_key="sync_time", + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BSBLanConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up BSB-Lan button entities from a config entry.""" + data = entry.runtime_data + + async_add_entities( + BSBLanButtonEntity(data.fast_coordinator, data, description) + for description in BUTTON_DESCRIPTIONS + ) + + +class BSBLanButtonEntity(BSBLanEntity, ButtonEntity): + """Defines a BSB-Lan button entity.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: BSBLanFastCoordinator, + data: BSBLanData, + description: ButtonEntityDescription, + ) -> None: + """Initialize BSB-Lan button entity.""" + super().__init__(coordinator, data) + self.entity_description = description + self._attr_unique_id = f"{data.device.MAC}-{description.key}" + self._data = data + + async def async_press(self) -> None: + """Handle the button press.""" + await async_sync_device_time(self._data.client, self._data.device.name) diff --git a/homeassistant/components/bsblan/helpers.py b/homeassistant/components/bsblan/helpers.py new file mode 100644 index 000000000000..236d4825b7e9 --- /dev/null +++ b/homeassistant/components/bsblan/helpers.py @@ -0,0 +1,42 @@ +"""Helper functions for BSB-Lan integration.""" + +from __future__ import annotations + +from bsblan import BSBLAN, BSBLANError + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + + +async def async_sync_device_time(client: BSBLAN, device_name: str) -> None: + """Synchronize BSB-LAN device time with Home Assistant. + + Only updates if device time differs from Home Assistant time. + + Args: + client: The BSB-LAN client instance. + device_name: The name of the device (used in error messages). + + Raises: + HomeAssistantError: If the time sync operation fails. + + """ + try: + device_time = await client.time() + current_time = dt_util.now() + current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S") + + # Only sync if device time differs from HA time + if device_time.time.value != current_time_str: + await client.set_time(current_time_str) + except BSBLANError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sync_time_failed", + translation_placeholders={ + "device_name": device_name, + "error": str(err), + }, + ) from err diff --git a/homeassistant/components/bsblan/icons.json b/homeassistant/components/bsblan/icons.json index f58cebd1651e..c4f02f88726d 100644 --- a/homeassistant/components/bsblan/icons.json +++ b/homeassistant/components/bsblan/icons.json @@ -1,4 +1,11 @@ { + "entity": { + "button": { + "sync_time": { + "default": "mdi:timer-sync-outline" + } + } + }, "services": { "set_hot_water_schedule": { "service": "mdi:calendar-clock" diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index 5f1ca463b4fe..d11ff96780cb 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -13,9 +13,9 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.util import dt as dt_util from .const import DOMAIN +from .helpers import async_sync_device_time if TYPE_CHECKING: from . import BSBLanConfigEntry @@ -245,25 +245,7 @@ async def async_sync_time(service_call: ServiceCall) -> None: ) client = entry.runtime_data.client - - try: - # Get current device time - device_time = await client.time() - current_time = dt_util.now() - current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S") - - # Only sync if device time differs from HA time - if device_time.time.value != current_time_str: - await client.set_time(current_time_str) - except BSBLANError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="sync_time_failed", - translation_placeholders={ - "device_name": device_entry.name or device_id, - "error": str(err), - }, - ) from err + await async_sync_device_time(client, device_entry.name or device_id) SYNC_TIME_SCHEMA = vol.Schema( diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index fdb85c5f2737..4d7fc880f12e 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -60,6 +60,11 @@ } }, "entity": { + "button": { + "sync_time": { + "name": "Sync time" + } + }, "sensor": { "current_temperature": { "name": "Current temperature" diff --git a/tests/components/bsblan/snapshots/test_button.ambr b/tests/components/bsblan/snapshots/test_button.ambr new file mode 100644 index 000000000000..59a8f7f69c07 --- /dev/null +++ b/tests/components/bsblan/snapshots/test_button.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_button_entity_properties[button.bsb_lan_sync_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.bsb_lan_sync_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sync time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync time', + 'platform': 'bsblan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sync_time', + 'unique_id': '00:80:41:19:69:90-sync_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entity_properties[button.bsb_lan_sync_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BSB-LAN Sync time', + }), + 'context': , + 'entity_id': 'button.bsb_lan_sync_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/bsblan/test_button.py b/tests/components/bsblan/test_button.py new file mode 100644 index 000000000000..d5bb46d1e382 --- /dev/null +++ b/tests/components/bsblan/test_button.py @@ -0,0 +1,140 @@ +"""Tests for the BSB-Lan button platform.""" + +from unittest.mock import MagicMock + +from bsblan import BSBLANError, DeviceTime +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_SYNC_TIME = "button.bsb_lan_sync_time" + + +async def test_button_entity_properties( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the button entity properties.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_button_press_syncs_time( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test pressing the sync time button syncs the device time.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON]) + + # Mock device time that differs from HA time + mock_bsblan.time.return_value = DeviceTime.from_json( + '{"time": {"name": "Time", "value": "01.01.2020 00:00:00", "unit": "", "desc": "", "dataType": 0, "readonly": 0, "error": 0}}' + ) + + # Press the button + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_SYNC_TIME}, + blocking=True, + ) + + # Verify time() was called to check current device time + assert mock_bsblan.time.called + + # Verify set_time() was called with current HA time + current_time_str = dt_util.now().strftime("%d.%m.%Y %H:%M:%S") + mock_bsblan.set_time.assert_called_once_with(current_time_str) + + +async def test_button_press_no_update_when_same( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test button press doesn't update when time matches.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON]) + + # Mock device time that matches HA time + current_time_str = dt_util.now().strftime("%d.%m.%Y %H:%M:%S") + mock_bsblan.time.return_value = DeviceTime.from_json( + f'{{"time": {{"name": "Time", "value": "{current_time_str}", "unit": "", "desc": "", "dataType": 0, "readonly": 0, "error": 0}}}}' + ) + + # Press the button + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_SYNC_TIME}, + blocking=True, + ) + + # Verify time() was called + assert mock_bsblan.time.called + + # Verify set_time() was NOT called since times match + assert not mock_bsblan.set_time.called + + +async def test_button_press_error_handling( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test button press handles errors gracefully.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON]) + + # Mock time() to raise an error + mock_bsblan.time.side_effect = BSBLANError("Connection failed") + + # Press the button - should raise HomeAssistantError + with pytest.raises(HomeAssistantError, match="Failed to sync time"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_SYNC_TIME}, + blocking=True, + ) + + +async def test_button_press_set_time_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test button press handles set_time errors.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON]) + + # Mock device time that differs + mock_bsblan.time.return_value = DeviceTime.from_json( + '{"time": {"name": "Time", "value": "01.01.2020 00:00:00", "unit": "", "desc": "", "dataType": 0, "readonly": 0, "error": 0}}' + ) + + # Mock set_time() to raise an error + mock_bsblan.set_time.side_effect = BSBLANError("Write failed") + + # Press the button - should raise HomeAssistantError + with pytest.raises(HomeAssistantError, match="Failed to sync time"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_SYNC_TIME}, + blocking=True, + )