Files
core/tests/components/bsblan/test_services.py
T

840 lines
26 KiB
Python

"""Tests for BSB-LAN services."""
import asyncio
from datetime import time, timedelta
from typing import Any
from unittest.mock import MagicMock
from bsblan import (
BSBLANError,
BSBLANMalformedResponseError,
BSBLANUnsupportedFeatureError,
DaySchedule,
DeviceTime,
TimeSlot,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
import voluptuous as vol
from homeassistant.components.bsblan.const import DOMAIN
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, async_fire_time_changed
# Test constants
TEST_DEVICE_MAC = "00:80:41:19:69:90"
@pytest.fixture
async def setup_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
) -> None:
"""Set up the BSB-LAN integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
@pytest.fixture
def device_entry(
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
setup_integration: None,
) -> dr.DeviceEntry:
"""Get the device entry for testing."""
device = device_registry.async_get_device_by_identifier(
(DOMAIN, TEST_DEVICE_MAC), mock_config_entry.entry_id
)
assert device is not None
return device
@pytest.fixture
def water_heater_device_entry(
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
setup_integration: None,
) -> dr.DeviceEntry:
"""Get the water heater sub-device entry for testing."""
device = device_registry.async_get_device_by_identifier(
(DOMAIN, f"{TEST_DEVICE_MAC}-water-heater"), mock_config_entry.entry_id
)
assert device is not None
return device
@pytest.mark.usefixtures("setup_integration")
@pytest.mark.parametrize(
("service_data", "expected_schedules"),
[
(
{
"monday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
{"start_time": time(17, 0), "end_time": time(21, 0)},
],
"tuesday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
],
},
{
"monday": DaySchedule(
slots=[
TimeSlot(start=time(6, 0), end=time(8, 0)),
TimeSlot(start=time(17, 0), end=time(21, 0)),
]
),
"tuesday": DaySchedule(
slots=[TimeSlot(start=time(6, 0), end=time(8, 0))]
),
},
),
(
{
"friday_slots": [
{"start_time": time(17, 0), "end_time": time(21, 0)},
],
"saturday_slots": [
{"start_time": time(8, 0), "end_time": time(22, 0)},
],
},
{
"friday": DaySchedule(
slots=[TimeSlot(start=time(17, 0), end=time(21, 0))]
),
"saturday": DaySchedule(
slots=[TimeSlot(start=time(8, 0), end=time(22, 0))]
),
},
),
(
{
"wednesday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
],
},
{
"wednesday": DaySchedule(
slots=[TimeSlot(start=time(6, 0), end=time(8, 0))]
),
},
),
(
{
"monday_slots": [], # Empty array (shouldn't happen in UI)
},
{
"monday": DaySchedule(slots=[]),
},
),
],
ids=[
"multiple_slots_per_day",
"weekend_schedule",
"single_day",
"clear_schedule_with_empty_array",
],
)
async def test_set_hot_water_schedule(
hass: HomeAssistant,
mock_bsblan: MagicMock,
water_heater_device_entry: dr.DeviceEntry,
service_data: dict[str, Any],
expected_schedules: dict[str, DaySchedule],
) -> None:
"""Test setting hot water schedule with various configurations."""
# Call the service with device_id and slot fields
service_call_data = {"device_id": water_heater_device_entry.id}
service_call_data.update(service_data)
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
service_call_data,
blocking=True,
)
# Verify the service was called correctly
assert len(mock_bsblan.set_hot_water_schedule.mock_calls) == 1
call_args = mock_bsblan.set_hot_water_schedule.call_args
# Verify expected values - all values are in the DHWSchedule object
dhw_schedule = call_args.args[0]
for key, expected_schedule in expected_schedules.items():
actual_schedule = getattr(dhw_schedule, key)
assert actual_schedule == expected_schedule
async def test_set_hot_water_schedule_refreshes_coordinator(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test setting a schedule refreshes the slow coordinator."""
refreshed_schedule = MagicMock()
mock_bsblan.hot_water_schedule.return_value = refreshed_schedule
mock_bsblan.hot_water_schedule.reset_mock()
slow_coordinator = mock_config_entry.runtime_data.slow_coordinator
updates = 0
def _handle_update() -> None:
nonlocal updates
updates += 1
slow_coordinator.async_add_listener(_handle_update)
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": water_heater_device_entry.id,
"monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}],
},
blocking=True,
)
mock_bsblan.hot_water_schedule.assert_awaited_once_with()
assert slow_coordinator.data.dhw_schedule is refreshed_schedule
assert updates == 1
async def test_overlapping_schedule_writes_preserve_latest_refresh(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test an in-flight refresh does not consume a newer refresh request."""
first_fetch_started = asyncio.Event()
release_first_fetch = asyncio.Event()
stale_schedule = MagicMock()
refreshed_schedule = MagicMock()
fetch_count = 0
async def _schedule() -> MagicMock:
nonlocal fetch_count
fetch_count += 1
if fetch_count == 1:
first_fetch_started.set()
await release_first_fetch.wait()
return stale_schedule
return refreshed_schedule
mock_bsblan.hot_water_schedule.reset_mock()
mock_bsblan.hot_water_schedule.side_effect = _schedule
service_data = {
"device_id": water_heater_device_entry.id,
"monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}],
}
first_write = asyncio.create_task(
hass.services.async_call(
DOMAIN, "set_hot_water_schedule", service_data, blocking=True
)
)
await first_fetch_started.wait()
second_write = asyncio.create_task(
hass.services.async_call(
DOMAIN, "set_hot_water_schedule", service_data, blocking=True
)
)
await asyncio.sleep(0)
release_first_fetch.set()
await asyncio.gather(first_write, second_write)
assert mock_bsblan.hot_water_schedule.await_count == 2
assert (
mock_config_entry.runtime_data.slow_coordinator.data.dhw_schedule
is refreshed_schedule
)
async def test_set_hot_water_schedule_retries_failed_refresh(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
water_heater_device_entry: dr.DeviceEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a failed post-write refresh is retried on the next interval."""
slow_coordinator = mock_config_entry.runtime_data.slow_coordinator
old_schedule = slow_coordinator.data.dhw_schedule
refreshed_schedule = MagicMock()
mock_bsblan.hot_water_schedule.side_effect = [
BSBLANMalformedResponseError("Invalid response"),
refreshed_schedule,
]
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": water_heater_device_entry.id,
"monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}],
},
blocking=True,
)
assert slow_coordinator.data.dhw_schedule is old_schedule
freezer.tick(delta=timedelta(minutes=5, seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert slow_coordinator.data.dhw_schedule is refreshed_schedule
async def test_set_hot_water_schedule_does_not_retry_unsupported_refresh(
hass: HomeAssistant,
mock_bsblan: MagicMock,
water_heater_device_entry: dr.DeviceEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a post-write refresh does not retry an unsupported schedule."""
mock_bsblan.hot_water_schedule.reset_mock()
mock_bsblan.hot_water_schedule.side_effect = BSBLANUnsupportedFeatureError(
"No hot water schedule parameters available"
)
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": water_heater_device_entry.id,
"monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}],
},
blocking=True,
)
for _ in range(2):
freezer.tick(delta=timedelta(minutes=5, seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_bsblan.hot_water_schedule.assert_awaited_once_with()
async def test_invalid_device_id(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
) -> None:
"""Test error when device ID is invalid."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": "invalid_device_id",
"monday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
],
},
blocking=True,
)
assert exc_info.value.translation_key == "service_device_not_found"
@pytest.mark.usefixtures("setup_integration")
@pytest.mark.parametrize(
("service_name", "service_data"),
[
(
"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)
other_entry = MockConfigEntry(domain="other_domain", data={})
other_entry.add_to_hass(hass)
# Create a device for that other entry
device_entry = device_registry.async_get_or_create(
config_entry_id=other_entry.entry_id,
identifiers={("other_domain", "other_device")},
name="Other Device",
)
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
service_name,
{"device_id": device_entry.id, **service_data},
blocking=True,
)
assert exc_info.value.translation_key == "service_device_wrong_domain"
async def test_config_entry_not_loaded(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test error when config entry is not loaded."""
await hass.config_entries.async_unload(mock_config_entry.entry_id)
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
],
},
blocking=True,
)
assert exc_info.value.translation_key == "service_config_entry_not_loaded"
@pytest.mark.usefixtures("setup_integration")
async def test_api_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test error when BSB-LAN API call fails."""
mock_bsblan.set_hot_water_schedule.side_effect = BSBLANError("API Error")
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
],
},
blocking=True,
)
assert exc_info.value.translation_key == "set_schedule_failed"
@pytest.mark.usefixtures("setup_integration")
async def test_set_hot_water_schedule_rejects_main_device(
hass: HomeAssistant,
device_entry: dr.DeviceEntry,
) -> None:
"""Test that picking the main device for hot water schedule is rejected."""
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": device_entry.id,
"monday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
],
},
blocking=True,
)
assert exc_info.value.translation_key == "not_a_water_heater_device"
@pytest.mark.usefixtures("setup_integration")
@pytest.mark.parametrize(
("start_time", "end_time", "expected_error"),
[
(time(13, 0), time(11, 0), "end_time_before_start_time"),
("13:00", "11:00", "end_time_before_start_time"),
],
ids=[
"time_objects_end_before_start",
"strings_end_before_start",
],
)
async def test_time_validation_errors(
hass: HomeAssistant,
water_heater_device_entry: dr.DeviceEntry,
start_time: time | str,
end_time: time | str,
expected_error: str,
) -> None:
"""Test validation errors for various time input scenarios."""
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": start_time, "end_time": end_time},
],
},
blocking=True,
)
assert exc_info.value.translation_key == expected_error
@pytest.mark.usefixtures("setup_integration")
async def test_unprovided_days_are_none(
hass: HomeAssistant,
mock_bsblan: MagicMock,
water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test that unprovided days are sent as None to BSB-LAN API."""
# Only provide Monday and Tuesday, leave other days unprovided
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
],
"tuesday_slots": [
{"start_time": time(17, 0), "end_time": time(21, 0)},
],
},
blocking=True,
)
# Verify the API was called
assert mock_bsblan.set_hot_water_schedule.called
call_args = mock_bsblan.set_hot_water_schedule.call_args
dhw_schedule = call_args.args[0]
# Verify provided days have values
assert dhw_schedule.monday == DaySchedule(
slots=[TimeSlot(start=time(6, 0), end=time(8, 0))]
)
assert dhw_schedule.tuesday == DaySchedule(
slots=[TimeSlot(start=time(17, 0), end=time(21, 0))]
)
# Verify unprovided days are None (not empty DaySchedule)
assert dhw_schedule.wednesday is None
assert dhw_schedule.thursday is None
assert dhw_schedule.friday is None
assert dhw_schedule.saturday is None
assert dhw_schedule.sunday is None
@pytest.mark.usefixtures("setup_integration")
async def test_string_time_formats(
hass: HomeAssistant,
mock_bsblan: MagicMock,
water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test service with string time formats."""
# Test with string time formats
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": "06:00:00", "end_time": "08:00:00"}, # With seconds
],
"tuesday_slots": [
{"start_time": "17:00", "end_time": "21:00"}, # Without seconds
],
},
blocking=True,
)
# Verify the API was called
assert mock_bsblan.set_hot_water_schedule.called
call_args = mock_bsblan.set_hot_water_schedule.call_args
dhw_schedule = call_args.args[0]
# Should parse both formats correctly (seconds are stripped)
assert dhw_schedule.monday == DaySchedule(
slots=[TimeSlot(start=time(6, 0), end=time(8, 0))]
)
assert dhw_schedule.tuesday == DaySchedule(
slots=[TimeSlot(start=time(17, 0), end=time(21, 0))]
)
@pytest.mark.usefixtures("setup_integration")
async def test_non_standard_time_types(
hass: HomeAssistant,
water_heater_device_entry: dr.DeviceEntry,
) -> None:
"""Test service with non-standard time types raises error."""
# Test with integer time values - schema validation will reject these
with pytest.raises(vol.MultipleInvalid):
await hass.services.async_call(
DOMAIN,
"set_hot_water_schedule",
{
"device_id": water_heater_device_entry.id,
"monday_slots": [
{"start_time": 600, "end_time": 800},
],
},
blocking=True,
)
async def test_async_setup_services(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
) -> None:
"""Test service registration."""
# Verify service doesn't exist initially
assert not hass.services.has_service(DOMAIN, "set_hot_water_schedule")
# Set up the integration
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify service is now registered
assert hass.services.has_service(DOMAIN, "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_by_identifier(
(DOMAIN, TEST_DEVICE_MAC), mock_config_entry.entry_id
)
assert device is not None
# Mock device time that differs from HA time
mock_bsblan.time.return_value = DeviceTime.model_validate_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_by_identifier(
(DOMAIN, TEST_DEVICE_MAC), mock_config_entry.entry_id
)
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.model_validate_json(
f'{{"time": {{"name": "Time",'
f' "value": "{current_time_str}",'
f' "unit": "", "desc": "",'
f' "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_by_identifier(
(DOMAIN, TEST_DEVICE_MAC), mock_config_entry.entry_id
)
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) as exc:
await hass.services.async_call(
DOMAIN,
"sync_time",
{"device_id": device.id},
blocking=True,
)
assert exc.value.translation_domain == DOMAIN
assert exc.value.translation_key == "sync_time_failed"
assert exc.value.translation_placeholders == {
"device_name": "BSB-LAN",
"error": "Connection failed",
}
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_by_identifier(
(DOMAIN, TEST_DEVICE_MAC), mock_config_entry.entry_id
)
assert device is not None
# Mock device time that differs
mock_bsblan.time.return_value = DeviceTime.model_validate_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) as exc:
await hass.services.async_call(
DOMAIN,
"sync_time",
{"device_id": device.id},
blocking=True,
)
assert exc.value.translation_domain == DOMAIN
assert exc.value.translation_key == "sync_time_failed"
assert exc.value.translation_placeholders == {
"device_name": "BSB-LAN",
"error": "Write failed",
}
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,
)