diff --git a/homeassistant/components/bsblan/icons.json b/homeassistant/components/bsblan/icons.json index 8846d4e153c3a..f58cebd1651e1 100644 --- a/homeassistant/components/bsblan/icons.json +++ b/homeassistant/components/bsblan/icons.json @@ -2,6 +2,9 @@ "services": { "set_hot_water_schedule": { "service": "mdi:calendar-clock" + }, + "sync_time": { + "service": "mdi:timer-sync-outline" } } } diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index bd0d876c710cd..7768c790041b8 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -13,6 +13,7 @@ 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 @@ -30,8 +31,9 @@ ATTR_SATURDAY_SLOTS = "saturday_slots" ATTR_SUNDAY_SLOTS = "sunday_slots" -# Service name +# Service names SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule" +SERVICE_SYNC_TIME = "sync_time" # Schema for a single time slot @@ -203,6 +205,74 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None: await entry.runtime_data.slow_coordinator.async_request_refresh() +async def async_sync_time(service_call: ServiceCall) -> None: + """Synchronize BSB-LAN device time with Home Assistant.""" + device_id: str = service_call.data[ATTR_DEVICE_ID] + + # Get the device and config entry + device_registry = dr.async_get(service_call.hass) + device_entry = device_registry.async_get(device_id) + + if device_entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, + ) + + # Find the config entry for this device + matching_entries: list[BSBLanConfigEntry] = [ + entry + for entry in service_call.hass.config_entries.async_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries + ] + + if not matching_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_config_entry_for_device", + translation_placeholders={"device_id": device_entry.name or device_id}, + ) + + entry = matching_entries[0] + + # Verify the config entry is loaded + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_loaded", + translation_placeholders={"device_name": device_entry.name or device_id}, + ) + + 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 + + +SYNC_TIME_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + } +) + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Register the BSB-Lan services.""" @@ -212,3 +282,10 @@ def async_setup_services(hass: HomeAssistant) -> None: set_hot_water_schedule, schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA, ) + + hass.services.async_register( + DOMAIN, + SERVICE_SYNC_TIME, + async_sync_time, + schema=SYNC_TIME_SCHEMA, + ) diff --git a/homeassistant/components/bsblan/services.yaml b/homeassistant/components/bsblan/services.yaml index 959cf398c65bf..0844aa35feaaf 100644 --- a/homeassistant/components/bsblan/services.yaml +++ b/homeassistant/components/bsblan/services.yaml @@ -1,3 +1,12 @@ +sync_time: + fields: + device_id: + required: true + example: "abc123device456" + selector: + device: + integration: bsblan + set_hot_water_schedule: fields: device_id: diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 128aa053c620a..f7a53654ab3c3 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -79,9 +79,6 @@ "invalid_device_id": { "message": "Invalid device ID: {device_id}" }, - "invalid_time_format": { - "message": "Invalid time format provided" - }, "no_config_entry_for_device": { "message": "No configuration entry found for device: {device_id}" }, @@ -108,6 +105,9 @@ }, "setup_general_error": { "message": "An unknown error occurred while retrieving static device data" + }, + "sync_time_failed": { + "message": "Failed to sync time for {device_name}: {error}" } }, "services": { @@ -148,6 +148,16 @@ } }, "name": "Set hot water schedule" + }, + "sync_time": { + "description": "Synchronize Home Assistant time to the BSB-Lan device. Only updates if device time differs from Home Assistant time.", + "fields": { + "device_id": { + "description": "The BSB-LAN device to sync time for.", + "name": "Device" + } + }, + "name": "Sync time" } } } diff --git a/tests/components/bsblan/fixtures/time.json b/tests/components/bsblan/fixtures/time.json new file mode 100644 index 0000000000000..fdbdfb69927cf --- /dev/null +++ b/tests/components/bsblan/fixtures/time.json @@ -0,0 +1,11 @@ +{ + "time": { + "name": "Time", + "value": "14.11.2025 10:30:00", + "unit": "", + "desc": "", + "dataType": 0, + "readonly": 0, + "error": 0 + } +} diff --git a/tests/components/bsblan/test_services.py b/tests/components/bsblan/test_services.py index f0955a7ac7a8d..6d1807b2f1db8 100644 --- a/tests/components/bsblan/test_services.py +++ b/tests/components/bsblan/test_services.py @@ -4,7 +4,8 @@ from typing import Any from unittest.mock import MagicMock -from bsblan import BSBLANError, DaySchedule, TimeSlot +from bsblan import BSBLANError, DaySchedule, DeviceTime, TimeSlot +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -16,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -174,9 +176,22 @@ async def test_invalid_device_id( assert exc_info.value.translation_key == "invalid_device_id" +@pytest.mark.parametrize( + ("service_name", "service_data"), + [ + ( + SERVICE_SET_HOT_WATER_SCHEDULE, + {"monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}]}, + ), + ("sync_time", {}), + ], + ids=["set_hot_water_schedule", "sync_time"], +) async def test_no_config_entry_for_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + service_name: str, + service_data: dict[str, Any], ) -> None: """Test error when device has no matching BSB-LAN config entry.""" # Create a different config entry (not for bsblan) @@ -196,11 +211,8 @@ async def test_no_config_entry_for_device( with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, - SERVICE_SET_HOT_WATER_SCHEDULE, - { - "device_id": device_entry.id, - "monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}], - }, + service_name, + {"device_id": device_entry.id, **service_data}, blocking=True, ) @@ -421,3 +433,198 @@ async def test_async_setup_services( # Verify service is now registered assert hass.services.has_service(DOMAIN, SERVICE_SET_HOT_WATER_SCHEDULE) + + +async def test_sync_time_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sync_time service.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE_MAC)}) + assert device is not None + + # 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}}' + ) + + # Call the service + await hass.services.async_call( + DOMAIN, + "sync_time", + {"device_id": device.id}, + 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_sync_time_service_no_update_when_same( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sync_time service doesn't update when time matches.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE_MAC)}) + assert device is not None + + # 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}}}}' + ) + + # Call the service + await hass.services.async_call( + DOMAIN, + "sync_time", + {"device_id": device.id}, + 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_sync_time_service_error_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the sync_time service handles errors gracefully.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE_MAC)}) + assert device is not None + + # Mock time() to raise an error + mock_bsblan.time.side_effect = BSBLANError("Connection failed") + + # Call the service - should raise HomeAssistantError + with pytest.raises(HomeAssistantError, match="Failed to sync time"): + await hass.services.async_call( + DOMAIN, + "sync_time", + {"device_id": device.id}, + blocking=True, + ) + + +async def test_sync_time_service_set_time_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the sync_time service handles set_time errors.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the device + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE_MAC)}) + assert device is not None + + # 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") + + # Call the service - should raise HomeAssistantError + with pytest.raises(HomeAssistantError, match="Failed to sync time"): + await hass.services.async_call( + DOMAIN, + "sync_time", + {"device_id": device.id}, + blocking=True, + ) + + +async def test_sync_time_service_entry_not_found( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, +) -> None: + """Test the sync_time service raises error for non-existent device.""" + # Set up the entry (this registers the service) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Call the service with a non-existent device ID + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + "sync_time", + {"device_id": "non_existent_device_id"}, + blocking=True, + ) + + +async def test_sync_time_service_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the sync_time service raises error for unloaded entry.""" + # Set up the first entry (this registers the service) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Create a second unloaded entry + unloaded_entry = MockConfigEntry( + domain=DOMAIN, + title="Unloaded BSBLAN", + data=mock_config_entry.data.copy(), + unique_id="unloaded_unique_id", + ) + unloaded_entry.add_to_hass(hass) + # Don't call async_setup on this entry, so it stays NOT_LOADED + + # Manually register a device for this unloaded entry + unloaded_device = device_registry.async_get_or_create( + config_entry_id=unloaded_entry.entry_id, + identifiers={(DOMAIN, "unloaded_device_mac")}, + name="Unloaded Device", + ) + + # Call the service with the device from the unloaded entry - should raise error + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + "sync_time", + {"device_id": unloaded_device.id}, + blocking=True, + )