mirror of
https://github.com/home-assistant/core.git
synced 2026-09-04 04:22:09 +01:00
840 lines
26 KiB
Python
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,
|
|
)
|