Add time synchronization feature to BSB-Lan integration (#156600)

This commit is contained in:
Willem-Jan van Rootselaar
2026-01-02 15:54:37 +01:00
committed by GitHub
parent d6751eb63f
commit 9539a612a6
6 changed files with 327 additions and 10 deletions
@@ -2,6 +2,9 @@
"services": {
"set_hot_water_schedule": {
"service": "mdi:calendar-clock"
},
"sync_time": {
"service": "mdi:timer-sync-outline"
}
}
}
+78 -1
View File
@@ -13,6 +13,7 @@ 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
@@ -30,8 +31,9 @@ ATTR_FRIDAY_SLOTS = "friday_slots"
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,
)
@@ -1,3 +1,12 @@
sync_time:
fields:
device_id:
required: true
example: "abc123device456"
selector:
device:
integration: bsblan
set_hot_water_schedule:
fields:
device_id:
+13 -3
View File
@@ -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"
}
}
}
@@ -0,0 +1,11 @@
{
"time": {
"name": "Time",
"value": "14.11.2025 10:30:00",
"unit": "",
"desc": "",
"dataType": 0,
"readonly": 0,
"error": 0
}
}
+213 -6
View File
@@ -4,7 +4,8 @@ from datetime import time
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.components.bsblan.services import (
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,
)