Files
core/tests/components/teslemetry/test_calendar.py
T

582 lines
20 KiB
Python

"""Test the Teslemetry calendar platform."""
from collections.abc import Generator
from copy import deepcopy
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.calendar import (
DOMAIN as CALENDAR_DOMAIN,
EVENT_END_DATETIME,
EVENT_START_DATETIME,
SERVICE_GET_EVENTS,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from . import assert_entities, setup_platform
from .const import SITE_INFO, SITE_INFO_MULTI_SEASON, SITE_INFO_WEEK_CROSSING
ENTITY_BUY = "calendar.energy_site_buy_tariff"
ENTITY_SELL = "calendar.energy_site_sell_tariff"
ENTITY_OPERATION_MODE = "select.energy_site_operation_mode"
TARIFF_V2 = SITE_INFO["response"]["tariff_content_v2"]
SLIM_SITE_INFO = {
key: value
for key, value in SITE_INFO["response"].items()
if key != "tariff_content_v2"
}
@pytest.fixture
def mock_site_info_week_crossing(mock_site_info) -> Generator[AsyncMock]:
"""Mock Teslemetry Energy site_info with week-crossing tariff data."""
with patch(
"tesla_fleet_api.tesla.energysite.EnergySite.site_info",
side_effect=lambda: deepcopy(SITE_INFO_WEEK_CROSSING),
) as mock:
yield mock
@pytest.fixture
def mock_site_info_multi_season(mock_site_info) -> Generator[AsyncMock]:
"""Mock Teslemetry Energy site_info with multi-season tariff data."""
with patch(
"tesla_fleet_api.tesla.energysite.EnergySite.site_info",
side_effect=lambda: deepcopy(SITE_INFO_MULTI_SEASON),
) as mock:
yield mock
@pytest.fixture
def mock_site_info_no_tariff(mock_site_info) -> Generator[AsyncMock]:
"""Mock Teslemetry Energy site_info with no tariff data."""
site_info_no_tariff = deepcopy(SITE_INFO_WEEK_CROSSING)
site_info_no_tariff["response"]["tariff_content_v2"]["seasons"] = {}
site_info_no_tariff["response"]["tariff_content_v2"]["sell_tariff"]["seasons"] = {}
with patch(
"tesla_fleet_api.tesla.energysite.EnergySite.site_info",
side_effect=lambda: deepcopy(site_info_no_tariff),
) as mock:
yield mock
@pytest.fixture
def mock_site_info_invalid_season(mock_site_info) -> Generator[AsyncMock]:
"""Mock site_info with invalid/empty season data."""
site_info = deepcopy(SITE_INFO)
# Empty season first (hits _get_current_season empty check),
# then season with missing keys (hits KeyError exception handler)
site_info["response"]["tariff_content_v2"]["seasons"] = {
"Empty": {},
"Invalid": {"someKey": "value"},
}
site_info["response"]["tariff_content_v2"]["sell_tariff"]["seasons"] = {}
with patch(
"tesla_fleet_api.tesla.energysite.EnergySite.site_info",
side_effect=lambda: deepcopy(site_info),
) as mock:
yield mock
@pytest.fixture
def mock_site_info_end_of_day(
request: pytest.FixtureRequest, mock_site_info
) -> Generator[AsyncMock]:
"""Mock site_info with a real end-of-day tariff period ending at the day boundary."""
site_info = deepcopy(SITE_INFO)
tariff = site_info["response"]["tariff_content_v2"]
# 23:30->24:00 period taken verbatim from a PowerSync Flow Power tariff,
# where Tesla encodes the end of the day as hour 24 or minute 60.
tariff["energy_charges"]["Summer"]["rates"] = {"PERIOD_23_30": 0.3561}
tariff["seasons"]["Summer"]["tou_periods"] = {
"PERIOD_23_30": {
"periods": [
{"toDayOfWeek": 6, "fromHour": 23, "fromMinute": 30, **request.param}
]
}
}
tariff["sell_tariff"]["seasons"] = {}
with patch(
"tesla_fleet_api.tesla.energysite.EnergySite.site_info",
side_effect=lambda: deepcopy(site_info),
) as mock:
yield mock
@pytest.fixture
def mock_site_info_invalid_price(mock_site_info) -> Generator[AsyncMock]:
"""Mock site_info with non-numeric price data."""
site_info = deepcopy(SITE_INFO)
site_info["response"]["tariff_content_v2"]["energy_charges"]["Summer"]["rates"] = {
"OFF_PEAK": "not_a_number",
"ON_PEAK": "not_a_number",
}
site_info["response"]["tariff_content_v2"]["sell_tariff"]["seasons"] = {}
with patch(
"tesla_fleet_api.tesla.energysite.EnergySite.site_info",
side_effect=lambda: deepcopy(site_info),
) as mock:
yield mock
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_calendar(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
) -> None:
"""Tests that the calendar entity is correct."""
tz = dt_util.get_default_time_zone()
freezer.move_to(datetime(2024, 1, 1, 10, 0, 0, tzinfo=tz))
entry = await setup_platform(hass, [Platform.CALENDAR])
assert_entities(hass, entry.entry_id, entity_registry, snapshot)
@pytest.mark.parametrize(
"entity_id",
[ENTITY_BUY, ENTITY_SELL],
)
@pytest.mark.parametrize(
"time_tuple",
[
(2024, 1, 1, 10, 0, 0), # OFF_PEAK period started yesterday
(2024, 1, 1, 20, 0, 0), # ON_PEAK period starts and ends today
(2024, 1, 1, 22, 0, 0), # OFF_PEAK period ends tomorrow
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_calendar_events(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
entity_id: str,
time_tuple: tuple,
) -> None:
"""Tests that the energy tariff calendar entity events are correct."""
tz = dt_util.get_default_time_zone()
freezer.move_to(datetime(*time_tuple, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR])
state = hass.states.get(entity_id)
assert state
assert state.attributes == snapshot(name="event")
result = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
ATTR_ENTITY_ID: [entity_id],
EVENT_START_DATETIME: dt_util.parse_datetime("2024-01-01T00:00:00Z"),
EVENT_END_DATETIME: dt_util.parse_datetime("2024-01-07T00:00:00Z"),
},
blocking=True,
return_response=True,
)
assert result == snapshot(name="events")
@pytest.mark.parametrize(
("time_tuple", "expected_state", "expected_period"),
[
# Friday (day 4) - WEEKEND period active (Fri-Mon crossing)
((2024, 1, 5, 12, 0, 0), "on", "Weekend"),
# Saturday (day 5) - WEEKEND period active
((2024, 1, 6, 12, 0, 0), "on", "Weekend"),
# Sunday (day 6) - WEEKEND period active
((2024, 1, 7, 12, 0, 0), "on", "Weekend"),
# Monday (day 0) - WEEKEND period active (end of Fri-Mon range)
((2024, 1, 8, 12, 0, 0), "on", "Weekend"),
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_calendar_week_crossing(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_site_info_week_crossing: AsyncMock,
time_tuple: tuple,
expected_state: str,
expected_period: str,
) -> None:
"""Test calendar handles week-crossing day ranges correctly."""
tz = dt_util.get_default_time_zone()
time = datetime(*time_tuple, tzinfo=tz)
freezer.move_to(time)
await setup_platform(hass, [Platform.CALENDAR])
state = hass.states.get(ENTITY_BUY)
assert state
assert state.state == expected_state
assert expected_period in state.attributes["message"]
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_calendar_week_crossing_excluded_day(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_site_info_week_crossing: AsyncMock,
) -> None:
"""Test calendar excludes days outside week-crossing range."""
tz = dt_util.get_default_time_zone()
# Wednesday (day 2) - No period active (outside Fri-Mon range)
freezer.move_to(datetime(2024, 1, 3, 12, 0, 0, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR])
state = hass.states.get(ENTITY_BUY)
assert state
assert state.state == "off"
@pytest.mark.parametrize(
("time_tuple", "expected_season", "expected_buy_price"),
[
# June 15 at noon - Summer OFF_PEAK (Apr-Sep)
((2024, 6, 15, 12, 0, 0), "Summer", "0.20"),
# July 1 at 18:00 - Summer PEAK
((2024, 7, 1, 18, 0, 0), "Summer", "0.35"),
# December 15 at noon - Winter OFF_PEAK (Oct-Mar, crosses year boundary)
((2024, 12, 15, 12, 0, 0), "Winter", "0.12"),
# January 15 at noon - Winter OFF_PEAK (crosses year boundary)
((2024, 1, 15, 12, 0, 0), "Winter", "0.12"),
# February 28 at 18:00 - Winter PEAK
((2024, 2, 28, 18, 0, 0), "Winter", "0.25"),
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_calendar_multi_season(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_site_info_multi_season: AsyncMock,
time_tuple: tuple,
expected_season: str,
expected_buy_price: str,
) -> None:
"""Test calendar handles multiple seasons and year boundaries correctly."""
tz = dt_util.get_default_time_zone()
time = datetime(*time_tuple, tzinfo=tz)
freezer.move_to(time)
await setup_platform(hass, [Platform.CALENDAR])
state = hass.states.get(ENTITY_BUY)
assert state
assert state.state == "on"
assert expected_season in state.attributes["description"]
assert expected_buy_price in state.attributes["message"]
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_calendar_no_tariff_data(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_site_info_no_tariff: AsyncMock,
) -> None:
"""Test calendar entity is not created when tariff data is missing."""
tz = dt_util.get_default_time_zone()
freezer.move_to(datetime(2024, 1, 1, 10, 0, 0, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR])
state = hass.states.get(ENTITY_BUY)
assert state is None
state = hass.states.get(ENTITY_SELL)
assert state is None
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_calendar_invalid_season_data(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_site_info_invalid_season: AsyncMock,
) -> None:
"""Test calendar handles invalid/empty season data gracefully."""
tz = dt_util.get_default_time_zone()
freezer.move_to(datetime(2024, 6, 15, 12, 0, 0, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR])
# No valid season found -> event returns None -> state is "off"
state = hass.states.get(ENTITY_BUY)
assert state
assert state.state == "off"
# async_get_events also returns empty when no valid seasons
result = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
ATTR_ENTITY_ID: [ENTITY_BUY],
EVENT_START_DATETIME: dt_util.parse_datetime("2024-06-15T00:00:00Z"),
EVENT_END_DATETIME: dt_util.parse_datetime("2024-06-17T00:00:00Z"),
},
blocking=True,
return_response=True,
)
assert result[ENTITY_BUY]["events"] == []
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_calendar_week_crossing_get_events(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_site_info_week_crossing: AsyncMock,
) -> None:
"""Test async_get_events filters by day of week with week-crossing periods."""
tz = dt_util.get_default_time_zone()
freezer.move_to(datetime(2024, 1, 1, 12, 0, 0, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR])
# Request events for a full week - only Fri-Mon should have events
result = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
ATTR_ENTITY_ID: [ENTITY_BUY],
EVENT_START_DATETIME: dt_util.parse_datetime("2024-01-01T00:00:00Z"),
EVENT_END_DATETIME: dt_util.parse_datetime("2024-01-08T00:00:00Z"),
},
blocking=True,
return_response=True,
)
events = result[ENTITY_BUY]["events"]
# 5 events: Sun Dec 31, Mon Jan 1, Fri Jan 5, Sat Jan 6, Sun Jan 7
# (Dec 31 included due to UTC-to-local shift) - no Tue/Wed/Thu
assert len(events) == 5
for event in events:
start = dt_util.parse_datetime(event["start"])
assert start is not None
assert start.weekday() in (0, 4, 5, 6)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_calendar_midnight_crossing_local_start(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
) -> None:
"""Test async_get_events includes overnight period at local midnight."""
tz = dt_util.get_default_time_zone()
freezer.move_to(datetime(2024, 1, 1, 10, 0, 0, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR])
# Use local-timezone timestamps so UTC-to-local shift does not
# accidentally push the start back to the previous day.
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=tz)
end = datetime(2024, 1, 2, 0, 0, 0, tzinfo=tz)
result = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
ATTR_ENTITY_ID: [ENTITY_BUY],
EVENT_START_DATETIME: start,
EVENT_END_DATETIME: end,
},
blocking=True,
return_response=True,
)
events = result[ENTITY_BUY]["events"]
# Expect 2 events on Jan 1:
# 1) OFF_PEAK that started Dec 31 21:00 and ends Jan 1 16:00
# 2) ON_PEAK from Jan 1 16:00 to Jan 1 21:00
# The OFF_PEAK starting Jan 1 21:00 (ending Jan 2 16:00) also overlaps,
# so 3 events total.
assert len(events) == 3
starts = [dt_util.parse_datetime(e["start"]) for e in events]
assert starts[0].day == 31 # Dec 31 21:00 (previous evening)
assert starts[1].day == 1 # Jan 1 16:00
assert starts[2].day == 1 # Jan 1 21:00
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
"mock_site_info_end_of_day",
[
pytest.param({"toHour": 24}, id="hour_24"),
pytest.param({"toHour": 23, "toMinute": 60}, id="minute_60"),
],
indirect=True,
)
async def test_calendar_end_of_day_period(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_site_info_end_of_day: AsyncMock,
) -> None:
"""Test a tariff period ending at the day boundary parses instead of crashing."""
tz = dt_util.get_default_time_zone()
# Sunday 23:45, inside the 23:30->24:00 end-of-day period
freezer.move_to(datetime(2024, 1, 7, 23, 45, 0, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR])
state = hass.states.get(ENTITY_BUY)
assert state
assert state.state == "on"
result = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
ATTR_ENTITY_ID: [ENTITY_BUY],
EVENT_START_DATETIME: datetime(2024, 1, 7, 0, 0, 0, tzinfo=tz),
EVENT_END_DATETIME: datetime(2024, 1, 8, 0, 0, 0, tzinfo=tz),
},
blocking=True,
return_response=True,
)
events = result[ENTITY_BUY]["events"]
assert len(events) == 1
# The day boundary normalises to the following midnight
assert dt_util.parse_datetime(events[0]["end"]) == datetime(
2024, 1, 8, 0, 0, 0, tzinfo=tz
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_calendar_invalid_price(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_site_info_invalid_price: AsyncMock,
) -> None:
"""Test calendar handles non-numeric price data gracefully."""
tz = dt_util.get_default_time_zone()
freezer.move_to(datetime(2024, 1, 1, 10, 0, 0, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR])
# Period matches but price is invalid -> shows "Unknown Price"
state = hass.states.get(ENTITY_BUY)
assert state
assert state.state == "on"
assert "Unknown Price" in state.attributes["message"]
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
"order",
[
pytest.param(("site_info", "tariff"), id="site_info_first"),
pytest.param(("tariff", "site_info"), id="tariff_first"),
],
)
async def test_energy_stream_site_info_and_tariff_compose(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_energy_info_stream: MagicMock,
mock_energy_tariff_stream: MagicMock,
order: tuple[str, str],
) -> None:
"""Slim site_info and tariff events are independently replaceable partitions."""
tz = dt_util.get_default_time_zone()
freezer.move_to(datetime(2024, 1, 1, 10, 0, 0, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR, Platform.SELECT])
# The REST cold read populated both partitions.
assert hass.states.get(ENTITY_OPERATION_MODE).state == "self_consumption"
buy = hass.states.get(ENTITY_BUY)
assert buy.state == "on"
assert "0.20/kWh" in buy.attributes["message"]
slim = deepcopy(SLIM_SITE_INFO)
slim["default_real_mode"] = "autonomous"
# A distinct OFF_PEAK price proves the streamed tariff replaced the cold read.
streamed_tariff = deepcopy(TARIFF_V2)
streamed_tariff["energy_charges"]["Summer"]["rates"]["OFF_PEAK"] = 0.99
events = {
"site_info": lambda: mock_energy_info_stream.send(slim),
"tariff": lambda: mock_energy_tariff_stream.send(streamed_tariff),
}
for name in order:
events[name]()
await hass.async_block_till_done()
# Both partitions survive regardless of arrival order.
assert hass.states.get(ENTITY_OPERATION_MODE).state == "autonomous"
buy = hass.states.get(ENTITY_BUY)
assert buy.state == "on"
assert "0.99/kWh" in buy.attributes["message"]
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_energy_stream_tariff_removal_clears_calendar(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_energy_tariff_stream: MagicMock,
) -> None:
"""A tariff_content_v2 removal marks the tariff calendar unavailable."""
tz = dt_util.get_default_time_zone()
freezer.move_to(datetime(2024, 1, 1, 10, 0, 0, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR])
assert hass.states.get(ENTITY_BUY).state != STATE_UNAVAILABLE
mock_energy_tariff_stream.send(None)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_BUY).state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_energy_stream_slim_site_info_drops_removed_field(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_legacy: AsyncMock,
mock_energy_info_stream: MagicMock,
) -> None:
"""A replacement site_info event drops a removed field without touching tariff."""
tz = dt_util.get_default_time_zone()
freezer.move_to(datetime(2024, 1, 1, 10, 0, 0, tzinfo=tz))
await setup_platform(hass, [Platform.CALENDAR, Platform.SELECT])
assert hass.states.get(ENTITY_OPERATION_MODE).state == "self_consumption"
assert hass.states.get(ENTITY_BUY).state != STATE_UNAVAILABLE
slim = deepcopy(SLIM_SITE_INFO)
slim.pop("default_real_mode")
mock_energy_info_stream.send(slim)
await hass.async_block_till_done()
# The removed field no longer lingers in the site_info-derived entity.
assert hass.states.get(ENTITY_OPERATION_MODE).state == STATE_UNKNOWN
# The tariff partition is untouched by a site_info replacement.
assert hass.states.get(ENTITY_BUY).state != STATE_UNAVAILABLE