From 6f5f7025f8531033a43fbab0bf81d0f844141604 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 8 Feb 2026 15:32:13 +1000 Subject: [PATCH 1/2] Add charging schedule calendar to Teslemetry integration Add a calendar entity for vehicle charging schedules, including shared helper code (Schedule dataclass, rrule generation, event sorting) that will be reused by a follow-up preconditioning PR. Co-Authored-By: Claude Opus 4.6 --- .../components/teslemetry/__init__.py | 1 + .../components/teslemetry/calendar.py | 192 ++++++++++++++++++ .../components/teslemetry/strings.json | 5 + .../teslemetry/fixtures/vehicle_data.json | 78 +++++++ .../teslemetry/snapshots/test_calendar.ambr | 141 +++++++++++++ .../snapshots/test_diagnostics.ambr | 74 +++++++ tests/components/teslemetry/test_calendar.py | 72 +++++++ 7 files changed, 563 insertions(+) create mode 100644 homeassistant/components/teslemetry/calendar.py create mode 100644 tests/components/teslemetry/snapshots/test_calendar.ambr create mode 100644 tests/components/teslemetry/test_calendar.py diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 765f345898c2fb..4446770aba661c 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -48,6 +48,7 @@ PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CALENDAR, Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, diff --git a/homeassistant/components/teslemetry/calendar.py b/homeassistant/components/teslemetry/calendar.py new file mode 100644 index 00000000000000..b827a680dafaa4 --- /dev/null +++ b/homeassistant/components/teslemetry/calendar.py @@ -0,0 +1,192 @@ +"""Calendar platform for Teslemetry integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from dataclasses import dataclass +from datetime import datetime, timedelta + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from . import TeslemetryConfigEntry +from .entity import TeslemetryVehiclePollingEntity +from .models import TeslemetryVehicleData + +PARALLEL_UPDATES = 0 + + +def get_rrule_days(days_of_week: int) -> list[str]: + """Get the rrule days for a days_of_week binary.""" + rrule_days_map = { + 0b0000001: "MO", + 0b0000010: "TU", + 0b0000100: "WE", + 0b0001000: "TH", + 0b0010000: "FR", + 0b0100000: "SA", + 0b1000000: "SU", + } + return [ + day_code + for day_flag, day_code in rrule_days_map.items() + if days_of_week & day_flag + ] + + +def test_days_of_week(date: datetime, days_of_week: int) -> bool: + """Check if a specific day is in the days_of_week binary.""" + return (days_of_week & (1 << date.weekday())) > 0 + + +@dataclass +class Schedule: + """A schedule for a vehicle.""" + + name: str + start_mins: timedelta + end_mins: timedelta + days_of_week: int + uid: str + location: str + rrule: str | None = None + + def generate_upcoming_events( + self, start_dt: datetime, end_dt: datetime + ) -> Generator[CalendarEvent]: + """Generate CalendarEvent objects within the time range [start_dt, end_dt).""" + current_day = dt_util.start_of_local_day(start_dt) + + while current_day < end_dt: + if test_days_of_week(current_day, self.days_of_week): + event_start = current_day + self.start_mins + event_end = current_day + self.end_mins + + if event_start < end_dt and event_end > start_dt: + yield CalendarEvent( + start=event_start, + end=event_end, + summary=self.name, + description=self.location, + location=self.location, + uid=self.uid, + rrule=self.rrule, + ) + + current_day += timedelta(days=1) + + +async def async_get_sorted_schedule_events( + schedules: list[Schedule], start_dt: datetime, end_dt: datetime +) -> list[CalendarEvent]: + """Fetch events from multiple schedules and return them sorted by start time.""" + all_events: list[CalendarEvent] = [ + event + for schedule in schedules + for event in schedule.generate_upcoming_events(start_dt, end_dt) + ] + return sorted(all_events, key=lambda event: event.start) + + +class TeslemetryChargeSchedule(TeslemetryVehiclePollingEntity, CalendarEntity): + """Vehicle charge schedule calendar.""" + + _attr_entity_registry_enabled_default = False + + def __init__(self, data: TeslemetryVehicleData) -> None: + """Initialize the charge schedule calendar.""" + self.schedules: list[Schedule] = [] + self.summary_format = ( + f"Charge scheduled for {data.device.get('name', 'Vehicle')}" + ) + super().__init__(data, "charge_schedule_data_charge_schedules") + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + now = dt_util.now() + next_event: CalendarEvent | None = None + future_limit = now + timedelta(days=14) + + for schedule in self.schedules: + first_occurrence = next( + schedule.generate_upcoming_events(now, future_limit), None + ) + if first_occurrence and ( + next_event is None or first_occurrence.start < next_event.start + ): + next_event = first_occurrence + + return next_event + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime, + end_date: datetime, + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + return await async_get_sorted_schedule_events( + self.schedules, start_date, end_date + ) + + def _async_update_attrs(self) -> None: + """Update the calendar events by parsing raw schedule data.""" + raw_schedules_data = self._value or [] + self.schedules = [] + for schedule_data in raw_schedules_data: + if not schedule_data.get("enabled") or not schedule_data.get( + "days_of_week" + ): + continue + + start_time_min = schedule_data.get("start_time", 0) + end_time_min = schedule_data.get("end_time", 0) + start_enabled = schedule_data.get("start_enabled", True) + end_enabled = schedule_data.get("end_enabled", True) + + if not end_enabled: + start_mins = timedelta(minutes=start_time_min) + end_mins = start_mins + elif not start_enabled: + end_mins = timedelta(minutes=end_time_min) + start_mins = end_mins + elif start_time_min > end_time_min: + start_mins = timedelta(minutes=start_time_min) + end_mins = timedelta(days=1, minutes=end_time_min) + else: + start_mins = timedelta(minutes=start_time_min) + end_mins = timedelta(minutes=end_time_min) + + days_of_week = schedule_data["days_of_week"] + rrule_days = get_rrule_days(days_of_week) + rrule = f"FREQ=WEEKLY;WKST=MO;BYDAY={','.join(rrule_days)}" + + if schedule_data.get("one_time"): + rrule += ";COUNT=1" + + self.schedules.append( + Schedule( + name=schedule_data.get("name") or self.summary_format, + start_mins=start_mins, + end_mins=end_mins, + days_of_week=days_of_week, + uid=str(schedule_data.get("id", f"charge_{len(self.schedules)}")), + location=f"{schedule_data.get('latitude', '')},{schedule_data.get('longitude', '')}", + rrule=rrule, + ) + ) + self._attr_available = bool(self.schedules) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Teslemetry calendar platform from a config entry.""" + async_add_entities( + TeslemetryChargeSchedule(vehicle) for vehicle in entry.runtime_data.vehicles + ) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index c89a79ab1beea2..d29c4086ec329c 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -272,6 +272,11 @@ "name": "Wake" } }, + "calendar": { + "charge_schedule_data_charge_schedules": { + "name": "Charging schedule" + } + }, "climate": { "climate_state_cabin_overheat_protection": { "name": "Cabin overheat protection" diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 051c7199d000c0..af5d1ebe32f43a 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -278,6 +278,84 @@ "vehicle_self_test_progress": 0, "vehicle_self_test_requested": false, "webcam_available": true + }, + "charge_schedule_data": { + "charge_schedules": [ + { + "id": 1728797699, + "name": "", + "days_of_week": 127, + "start_enabled": true, + "start_time": 540, + "end_enabled": true, + "end_time": 960, + "one_time": false, + "enabled": true, + "latitude": -30.222626, + "longitude": -97.6236871 + }, + { + "id": 1745538630, + "name": "", + "days_of_week": 2, + "start_enabled": true, + "start_time": 360, + "end_enabled": false, + "end_time": 360, + "one_time": true, + "enabled": true, + "latitude": -30.222626, + "longitude": -97.6236871 + }, + { + "id": 1745538649, + "name": "", + "days_of_week": 4, + "start_enabled": false, + "start_time": 1320, + "end_enabled": true, + "end_time": 360, + "one_time": true, + "enabled": true, + "latitude": -30.222626, + "longitude": -97.6236871 + }, + { + "id": 1745538666, + "name": "", + "days_of_week": 28, + "start_enabled": true, + "start_time": 1320, + "end_enabled": true, + "end_time": 360, + "one_time": false, + "enabled": true, + "latitude": -30.222626, + "longitude": -97.6236871 + }, + { + "id": 1745538667, + "name": "", + "days_of_week": 1, + "start_enabled": true, + "start_time": 1320, + "end_enabled": true, + "end_time": 360, + "one_time": false, + "enabled": false, + "latitude": -30.222626, + "longitude": -97.6236871 + } + ], + "timestamp": { + "seconds": 1745538813, + "nanos": 178000000 + }, + "charge_schedule_window": null, + "charge_buffer": 6, + "max_num_charge_schedules": 100, + "next_schedule": true, + "show_schedule_complete_state": false } } } diff --git a/tests/components/teslemetry/snapshots/test_calendar.ambr b/tests/components/teslemetry/snapshots/test_calendar.ambr new file mode 100644 index 00000000000000..98c5594233d6ac --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_calendar.ambr @@ -0,0 +1,141 @@ +# serializer version: 1 +# name: test_calendar[calendar.test_charging_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.test_charging_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging schedule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging schedule', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_schedule_data_charge_schedules', + 'unique_id': 'LRW3F7EK4NC700000-charge_schedule_data_charge_schedules', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar[calendar.test_charging_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': False, + 'description': '-30.222626,-97.6236871', + 'end_time': '2024-01-01 16:00:00', + 'friendly_name': 'Test Charging schedule', + 'location': '-30.222626,-97.6236871', + 'message': 'Charge scheduled for Test', + 'start_time': '2024-01-01 09:00:00', + }), + 'context': , + 'entity_id': 'calendar.test_charging_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_calendar_events[calendar.test_charging_schedule] + dict({ + 'calendar.test_charging_schedule': dict({ + 'events': list([ + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-01T16:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-01T09:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-02T06:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-02T06:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-02T16:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-02T09:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-03T06:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-03T06:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-03T16:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-03T09:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-04T06:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-03T22:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-04T16:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-04T09:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-05T06:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-04T22:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-05T16:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-05T09:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-06T06:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-05T22:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-06T16:00:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-06T09:00:00-08:00', + 'summary': 'Charge scheduled for Test', + }), + ]), + }), + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 6b02b2f6d83c8a..068fa0243d8623 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -204,6 +204,80 @@ 'backseat_token_updated_at': None, 'ble_autopair_enrolled': False, 'calendar_enabled': True, + 'charge_schedule_data_charge_buffer': 6, + 'charge_schedule_data_charge_schedule_window': None, + 'charge_schedule_data_charge_schedules': list([ + dict({ + 'days_of_week': 127, + 'enabled': True, + 'end_enabled': True, + 'end_time': 960, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': False, + 'start_enabled': True, + 'start_time': 540, + }), + dict({ + 'days_of_week': 2, + 'enabled': True, + 'end_enabled': False, + 'end_time': 360, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': True, + 'start_enabled': True, + 'start_time': 360, + }), + dict({ + 'days_of_week': 4, + 'enabled': True, + 'end_enabled': True, + 'end_time': 360, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': True, + 'start_enabled': False, + 'start_time': 1320, + }), + dict({ + 'days_of_week': 28, + 'enabled': True, + 'end_enabled': True, + 'end_time': 360, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': False, + 'start_enabled': True, + 'start_time': 1320, + }), + dict({ + 'days_of_week': 1, + 'enabled': False, + 'end_enabled': True, + 'end_time': 360, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': False, + 'start_enabled': True, + 'start_time': 1320, + }), + ]), + 'charge_schedule_data_max_num_charge_schedules': 100, + 'charge_schedule_data_next_schedule': True, + 'charge_schedule_data_show_schedule_complete_state': False, + 'charge_schedule_data_timestamp_nanos': 178000000, + 'charge_schedule_data_timestamp_seconds': 1745538813, 'charge_state_battery_heater_on': False, 'charge_state_battery_level': 77, 'charge_state_battery_range': 266.87, diff --git a/tests/components/teslemetry/test_calendar.py b/tests/components/teslemetry/test_calendar.py new file mode 100644 index 00000000000000..9e733eca9545c2 --- /dev/null +++ b/tests/components/teslemetry/test_calendar.py @@ -0,0 +1,72 @@ +"""Test the Teslemetry calendar platform.""" + +from datetime import datetime +from unittest.mock import AsyncMock + +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, 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 + + +@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: + """Test 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", + [ + "calendar.test_charging_schedule", + ], +) +@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, +) -> None: + """Test that the calendar events are correct.""" + 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]) + 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() From c0ad99b35bada83ae96f4a3206a21e7822b93636 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 8 Feb 2026 15:35:24 +1000 Subject: [PATCH 2/2] Add preconditioning schedule calendar to Teslemetry integration Add a calendar entity for vehicle preconditioning schedules. Preconditioning events are instantaneous (start == end) and only recurring schedules get an rrule. Co-Authored-By: Claude Opus 4.6 --- .../components/teslemetry/calendar.py | 86 ++++++++++++++++++- .../components/teslemetry/strings.json | 3 + .../teslemetry/fixtures/vehicle_data.json | 81 +++++++++++++++++ .../teslemetry/snapshots/test_calendar.ambr | 77 +++++++++++++++++ .../snapshots/test_diagnostics.ambr | 77 +++++++++++++++++ tests/components/teslemetry/test_calendar.py | 1 + 6 files changed, 324 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/calendar.py b/homeassistant/components/teslemetry/calendar.py index b827a680dafaa4..b018049a88897c 100644 --- a/homeassistant/components/teslemetry/calendar.py +++ b/homeassistant/components/teslemetry/calendar.py @@ -181,12 +181,96 @@ def _async_update_attrs(self) -> None: self._attr_available = bool(self.schedules) +class TeslemetryPreconditionSchedule(TeslemetryVehiclePollingEntity, CalendarEntity): + """Vehicle precondition schedule calendar.""" + + _attr_entity_registry_enabled_default = False + + def __init__(self, data: TeslemetryVehicleData) -> None: + """Initialize the precondition schedule calendar.""" + self.schedules: list[Schedule] = [] + self.summary_format = ( + f"Precondition scheduled for {data.device.get('name', 'Vehicle')}" + ) + super().__init__(data, "preconditioning_schedule_data_precondition_schedules") + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + now = dt_util.now() + next_event: CalendarEvent | None = None + future_limit = now + timedelta(days=14) + + for schedule in self.schedules: + first_occurrence = next( + schedule.generate_upcoming_events(now, future_limit), None + ) + if first_occurrence and ( + next_event is None or first_occurrence.start < next_event.start + ): + next_event = first_occurrence + + return next_event + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime, + end_date: datetime, + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + return await async_get_sorted_schedule_events( + self.schedules, start_date, end_date + ) + + def _async_update_attrs(self) -> None: + """Update the calendar events by parsing raw schedule data.""" + raw_schedules_data = self._value or [] + self.schedules = [] + for schedule_data in raw_schedules_data: + if not schedule_data.get("enabled") or not schedule_data.get( + "days_of_week" + ): + continue + + precondition_time_min = schedule_data.get("precondition_time", 0) + start_mins = timedelta(minutes=precondition_time_min) + end_mins = start_mins + + days_of_week = schedule_data["days_of_week"] + rrule = None + if not schedule_data.get("one_time"): + rrule_days = get_rrule_days(days_of_week) + rrule = f"FREQ=WEEKLY;WKST=MO;BYDAY={','.join(rrule_days)}" + + self.schedules.append( + Schedule( + name=schedule_data.get("name") or self.summary_format, + start_mins=start_mins, + end_mins=end_mins, + days_of_week=days_of_week, + uid=str( + schedule_data.get("id", f"precondition_{len(self.schedules)}") + ), + location=f"{schedule_data.get('latitude', '')},{schedule_data.get('longitude', '')}", + rrule=rrule, + ) + ) + self._attr_available = bool(self.schedules) + + async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry calendar platform from a config entry.""" - async_add_entities( + entities: list[CalendarEntity] = [] + entities.extend( TeslemetryChargeSchedule(vehicle) for vehicle in entry.runtime_data.vehicles ) + entities.extend( + TeslemetryPreconditionSchedule(vehicle) + for vehicle in entry.runtime_data.vehicles + ) + async_add_entities(entities) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index d29c4086ec329c..1576df3516a73c 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -275,6 +275,9 @@ "calendar": { "charge_schedule_data_charge_schedules": { "name": "Charging schedule" + }, + "preconditioning_schedule_data_precondition_schedules": { + "name": "Precondition schedule" } }, "climate": { diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index af5d1ebe32f43a..b0e4915aab273d 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -356,6 +356,87 @@ "max_num_charge_schedules": 100, "next_schedule": true, "show_schedule_complete_state": false + }, + "preconditioning_schedule_data": { + "precondition_schedules": [ + { + "id": 1739410968753, + "name": "", + "days_of_week": 0, + "precondition_time": 60, + "one_time": true, + "enabled": true, + "latitude": -30.222626, + "longitude": -97.6236871 + }, + { + "id": 1724190474, + "name": "", + "days_of_week": 127, + "precondition_time": 960, + "one_time": false, + "enabled": false, + "latitude": -30.222626, + "longitude": -97.6236871 + }, + { + "id": 1738189335, + "name": "", + "days_of_week": 48, + "precondition_time": 945, + "one_time": false, + "enabled": false, + "latitude": -30.222626, + "longitude": -97.6236871 + }, + { + "id": 1730953614, + "name": "", + "days_of_week": 48, + "precondition_time": 510, + "one_time": false, + "enabled": true, + "latitude": -30.222626, + "longitude": -97.6236871 + }, + { + "id": 1734819574, + "name": "", + "days_of_week": 1, + "precondition_time": 540, + "one_time": true, + "enabled": false, + "latitude": -30.222626, + "longitude": -97.6236871 + }, + { + "id": 1724190472, + "name": "", + "days_of_week": 127, + "precondition_time": 465, + "one_time": false, + "enabled": false, + "latitude": -30.222626, + "longitude": -97.6236871 + }, + { + "id": 1724190471, + "name": "", + "days_of_week": 127, + "precondition_time": 450, + "one_time": false, + "enabled": false, + "latitude": -30.222626, + "longitude": -97.6236871 + } + ], + "timestamp": { + "seconds": 1745538813, + "nanos": 727000000 + }, + "preconditioning_schedule_window": null, + "max_num_precondition_schedules": 100, + "next_schedule": true } } } diff --git a/tests/components/teslemetry/snapshots/test_calendar.ambr b/tests/components/teslemetry/snapshots/test_calendar.ambr index 98c5594233d6ac..ba37013f1ff081 100644 --- a/tests/components/teslemetry/snapshots/test_calendar.ambr +++ b/tests/components/teslemetry/snapshots/test_calendar.ambr @@ -54,6 +54,61 @@ 'state': 'on', }) # --- +# name: test_calendar[calendar.test_precondition_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.test_precondition_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Precondition schedule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precondition schedule', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'preconditioning_schedule_data_precondition_schedules', + 'unique_id': 'LRW3F7EK4NC700000-preconditioning_schedule_data_precondition_schedules', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar[calendar.test_precondition_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': False, + 'description': '-30.222626,-97.6236871', + 'end_time': '2024-01-05 08:30:00', + 'friendly_name': 'Test Precondition schedule', + 'location': '-30.222626,-97.6236871', + 'message': 'Precondition scheduled for Test', + 'start_time': '2024-01-05 08:30:00', + }), + 'context': , + 'entity_id': 'calendar.test_precondition_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_calendar_events[calendar.test_charging_schedule] dict({ 'calendar.test_charging_schedule': dict({ @@ -139,3 +194,25 @@ }), }) # --- +# name: test_calendar_events[calendar.test_precondition_schedule] + dict({ + 'calendar.test_precondition_schedule': dict({ + 'events': list([ + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-05T08:30:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-05T08:30:00-08:00', + 'summary': 'Precondition scheduled for Test', + }), + dict({ + 'description': '-30.222626,-97.6236871', + 'end': '2024-01-06T08:30:00-08:00', + 'location': '-30.222626,-97.6236871', + 'start': '2024-01-06T08:30:00-08:00', + 'summary': 'Precondition scheduled for Test', + }), + ]), + }), + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 068fa0243d8623..b10e897f1d751e 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -398,6 +398,83 @@ 'id': '**REDACTED**', 'id_s': '**REDACTED**', 'in_service': False, + 'preconditioning_schedule_data_max_num_precondition_schedules': 100, + 'preconditioning_schedule_data_next_schedule': True, + 'preconditioning_schedule_data_precondition_schedules': list([ + dict({ + 'days_of_week': 0, + 'enabled': True, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': True, + 'precondition_time': 60, + }), + dict({ + 'days_of_week': 127, + 'enabled': False, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': False, + 'precondition_time': 960, + }), + dict({ + 'days_of_week': 48, + 'enabled': False, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': False, + 'precondition_time': 945, + }), + dict({ + 'days_of_week': 48, + 'enabled': True, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': False, + 'precondition_time': 510, + }), + dict({ + 'days_of_week': 1, + 'enabled': False, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': True, + 'precondition_time': 540, + }), + dict({ + 'days_of_week': 127, + 'enabled': False, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': False, + 'precondition_time': 465, + }), + dict({ + 'days_of_week': 127, + 'enabled': False, + 'id': '**REDACTED**', + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'name': '', + 'one_time': False, + 'precondition_time': 450, + }), + ]), + 'preconditioning_schedule_data_preconditioning_schedule_window': None, + 'preconditioning_schedule_data_timestamp_nanos': 727000000, + 'preconditioning_schedule_data_timestamp_seconds': 1745538813, 'state': 'online', 'tokens': '**REDACTED**', 'user_id': '**REDACTED**', diff --git a/tests/components/teslemetry/test_calendar.py b/tests/components/teslemetry/test_calendar.py index 9e733eca9545c2..baf24001492800 100644 --- a/tests/components/teslemetry/test_calendar.py +++ b/tests/components/teslemetry/test_calendar.py @@ -42,6 +42,7 @@ async def test_calendar( "entity_id", [ "calendar.test_charging_schedule", + "calendar.test_precondition_schedule", ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default")