mirror of
https://github.com/home-assistant/core.git
synced 2026-04-29 18:33:44 +02:00
Add time synchronization feature to BSB-Lan integration (#156600)
This commit is contained in:
committed by
GitHub
parent
d6751eb63f
commit
9539a612a6
@@ -2,6 +2,9 @@
|
||||
"services": {
|
||||
"set_hot_water_schedule": {
|
||||
"service": "mdi:calendar-clock"
|
||||
},
|
||||
"sync_time": {
|
||||
"service": "mdi:timer-sync-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user