Add button platform for BSB-Lan integration (#160243)

This commit is contained in:
Willem-Jan van Rootselaar
2026-02-24 18:52:33 +01:00
committed by GitHub
parent 8f824b566e
commit 4a4e077d40
8 changed files with 306 additions and 21 deletions
+1 -1
View File
@@ -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)
+59
View File
@@ -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)
@@ -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
@@ -1,4 +1,11 @@
{
"entity": {
"button": {
"sync_time": {
"default": "mdi:timer-sync-outline"
}
}
},
"services": {
"set_hot_water_schedule": {
"service": "mdi:calendar-clock"
+2 -20
View File
@@ -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(
@@ -60,6 +60,11 @@
}
},
"entity": {
"button": {
"sync_time": {
"name": "Sync time"
}
},
"sensor": {
"current_temperature": {
"name": "Current temperature"
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.bsb_lan_sync_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.bsb_lan_sync_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+140
View File
@@ -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,
)