diff --git a/.coveragerc b/.coveragerc index 80412c14a65..e2d8b9b5cef 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1284,6 +1284,7 @@ omit = homeassistant/components/starlink/device_tracker.py homeassistant/components/starlink/sensor.py homeassistant/components/starlink/switch.py + homeassistant/components/starlink/time.py homeassistant/components/starline/__init__.py homeassistant/components/starline/account.py homeassistant/components/starline/binary_sensor.py diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index fc6863e7935..17081a7491e 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -15,6 +15,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH, + Platform.TIME, ] diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 069c9969de7..9c597fbb033 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -6,6 +6,7 @@ import asyncio from dataclasses import dataclass from datetime import timedelta import logging +from zoneinfo import ZoneInfo from starlink_grpc import ( AlertDict, @@ -14,8 +15,10 @@ from starlink_grpc import ( LocationDict, ObstructionDict, StatusDict, + get_sleep_config, location_data, reboot, + set_sleep_config, set_stow_state, status_data, ) @@ -32,6 +35,7 @@ class StarlinkData: """Contains data pulled from the Starlink system.""" location: LocationDict + sleep: tuple[int, int, bool] status: StatusDict obstruction: ObstructionDict alert: AlertDict @@ -43,7 +47,7 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): def __init__(self, hass: HomeAssistant, name: str, url: str) -> None: """Initialize an UpdateCoordinator for a group of sensors.""" self.channel_context = ChannelContext(target=url) - + self.timezone = ZoneInfo(hass.config.time_zone) super().__init__( hass, _LOGGER, @@ -54,13 +58,16 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): async def _async_update_data(self) -> StarlinkData: async with asyncio.timeout(4): try: - status = await self.hass.async_add_executor_job( - status_data, self.channel_context + status, location, sleep = await asyncio.gather( + self.hass.async_add_executor_job(status_data, self.channel_context), + self.hass.async_add_executor_job( + location_data, self.channel_context + ), + self.hass.async_add_executor_job( + get_sleep_config, self.channel_context + ), ) - location = await self.hass.async_add_executor_job( - location_data, self.channel_context - ) - return StarlinkData(location, *status) + return StarlinkData(location, sleep, *status) except GrpcError as exc: raise UpdateFailed from exc @@ -81,3 +88,45 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): await self.hass.async_add_executor_job(reboot, self.channel_context) except GrpcError as exc: raise HomeAssistantError from exc + + async def async_set_sleep_schedule_enabled(self, sleep_schedule: bool) -> None: + """Set whether Starlink system uses the configured sleep schedule.""" + async with asyncio.timeout(4): + try: + await self.hass.async_add_executor_job( + set_sleep_config, + self.data.sleep[0], + self.data.sleep[1], + sleep_schedule, + self.channel_context, + ) + except GrpcError as exc: + raise HomeAssistantError from exc + + async def async_set_sleep_start(self, start: int) -> None: + """Set Starlink system sleep schedule start time.""" + async with asyncio.timeout(4): + try: + await self.hass.async_add_executor_job( + set_sleep_config, + start, + self.data.sleep[1], + self.data.sleep[2], + self.channel_context, + ) + except GrpcError as exc: + raise HomeAssistantError from exc + + async def async_set_sleep_duration(self, end: int) -> None: + """Set Starlink system sleep schedule end time.""" + async with asyncio.timeout(4): + try: + await self.hass.async_add_executor_job( + set_sleep_config, + self.data.sleep[0], + end, + self.data.sleep[2], + self.channel_context, + ) + except GrpcError as exc: + raise HomeAssistantError from exc diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json index bc6807e8ba7..36a4f176e70 100644 --- a/homeassistant/components/starlink/strings.json +++ b/homeassistant/components/starlink/strings.json @@ -75,6 +75,17 @@ "switch": { "stowed": { "name": "Stowed" + }, + "sleep_schedule": { + "name": "Sleep schedule" + } + }, + "time": { + "sleep_start": { + "name": "Sleep start" + }, + "sleep_end": { + "name": "Sleep end" } } } diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index af773a39f79..3534748127e 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -67,5 +67,17 @@ SWITCHES = [ value_fn=lambda data: data.status["state"] == "STOWED", turn_on_fn=lambda coordinator: coordinator.async_stow_starlink(True), turn_off_fn=lambda coordinator: coordinator.async_stow_starlink(False), - ) + ), + StarlinkSwitchEntityDescription( + key="sleep_schedule", + translation_key="sleep_schedule", + device_class=SwitchDeviceClass.SWITCH, + value_fn=lambda data: data.sleep[2], + turn_on_fn=lambda coordinator: coordinator.async_set_sleep_schedule_enabled( + True + ), + turn_off_fn=lambda coordinator: coordinator.async_set_sleep_schedule_enabled( + False + ), + ), ] diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py new file mode 100644 index 00000000000..4d9e2d06675 --- /dev/null +++ b/homeassistant/components/starlink/time.py @@ -0,0 +1,98 @@ +"""Contains time pickers exposed by the Starlink integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import UTC, datetime, time, tzinfo +import math + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import StarlinkData, StarlinkUpdateCoordinator +from .entity import StarlinkEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up all time entities for this entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + StarlinkTimeEntity(coordinator, description) for description in TIMES + ) + + +@dataclass(frozen=True, kw_only=True) +class StarlinkTimeEntityDescription(TimeEntityDescription): + """Describes a Starlink time entity.""" + + value_fn: Callable[[StarlinkData, tzinfo], time | None] + update_fn: Callable[[StarlinkUpdateCoordinator, time], Awaitable[None]] + available_fn: Callable[[StarlinkData], bool] + + +class StarlinkTimeEntity(StarlinkEntity, TimeEntity): + """A TimeEntity for Starlink devices. Handles creating unique IDs.""" + + entity_description: StarlinkTimeEntityDescription + + @property + def native_value(self) -> time | None: + """Return the value reported by the time.""" + return self.entity_description.value_fn( + self.coordinator.data, self.coordinator.timezone + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.entity_description.available_fn(self.coordinator.data) + + async def async_set_value(self, value: time) -> None: + """Change the time.""" + return await self.entity_description.update_fn(self.coordinator, value) + + +def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time: + hour = math.floor(utc_minutes / 60) + minute = utc_minutes % 60 + utc = datetime.now(UTC).replace(hour=hour, minute=minute, second=0, microsecond=0) + return utc.astimezone(timezone).time() + + +def _time_to_utc_minutes(t: time, timezone: tzinfo) -> int: + zoned_time = datetime.now(timezone).replace( + hour=t.hour, minute=t.minute, second=0, microsecond=0 + ) + utc_time = zoned_time.astimezone(UTC).time() + return (utc_time.hour * 60) + utc_time.minute + + +TIMES = [ + StarlinkTimeEntityDescription( + key="sleep_start", + translation_key="sleep_start", + value_fn=lambda data, timezone: _utc_minutes_to_time(data.sleep[0], timezone), + update_fn=lambda coordinator, time: coordinator.async_set_sleep_start( + _time_to_utc_minutes(time, coordinator.timezone) + ), + available_fn=lambda data: data.sleep[2], + ), + StarlinkTimeEntityDescription( + key="sleep_end", + translation_key="sleep_end", + value_fn=lambda data, timezone: _utc_minutes_to_time( + data.sleep[0] + data.sleep[1], timezone + ), + update_fn=lambda coordinator, time: coordinator.async_set_sleep_duration( + _time_to_utc_minutes(time, coordinator.timezone) + ), + available_fn=lambda data: data.sleep[2], + ), +] diff --git a/tests/components/starlink/fixtures/sleep_data_success.json b/tests/components/starlink/fixtures/sleep_data_success.json new file mode 100644 index 00000000000..99942688adc --- /dev/null +++ b/tests/components/starlink/fixtures/sleep_data_success.json @@ -0,0 +1 @@ +[0, 1, false] diff --git a/tests/components/starlink/patchers.py b/tests/components/starlink/patchers.py index 85e7a77c78c..f8179f07bed 100644 --- a/tests/components/starlink/patchers.py +++ b/tests/components/starlink/patchers.py @@ -19,6 +19,11 @@ LOCATION_DATA_SUCCESS_PATCHER = patch( return_value=json.loads(load_fixture("location_data_success.json", "starlink")), ) +SLEEP_DATA_SUCCESS_PATCHER = patch( + "homeassistant.components.starlink.coordinator.get_sleep_config", + return_value=json.loads(load_fixture("sleep_data_success.json", "starlink")), +) + DEVICE_FOUND_PATCHER = patch( "homeassistant.components.starlink.config_flow.get_id", return_value="some-valid-id" ) diff --git a/tests/components/starlink/snapshots/test_diagnostics.ambr b/tests/components/starlink/snapshots/test_diagnostics.ambr index 3bb7f235017..4c85ad84ca7 100644 --- a/tests/components/starlink/snapshots/test_diagnostics.ambr +++ b/tests/components/starlink/snapshots/test_diagnostics.ambr @@ -52,6 +52,11 @@ None, ]), }), + 'sleep': list([ + 0, + 1, + False, + ]), 'status': dict({ 'alerts': 0, 'currently_obstructed': False, diff --git a/tests/components/starlink/test_diagnostics.py b/tests/components/starlink/test_diagnostics.py index 2c11c19d4c2..22e1d6e84be 100644 --- a/tests/components/starlink/test_diagnostics.py +++ b/tests/components/starlink/test_diagnostics.py @@ -6,7 +6,11 @@ from homeassistant.components.starlink.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from .patchers import LOCATION_DATA_SUCCESS_PATCHER, STATUS_DATA_SUCCESS_PATCHER +from .patchers import ( + LOCATION_DATA_SUCCESS_PATCHER, + SLEEP_DATA_SUCCESS_PATCHER, + STATUS_DATA_SUCCESS_PATCHER, +) from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -24,7 +28,7 @@ async def test_diagnostics( data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, ) - with STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER: + with STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER: entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/starlink/test_init.py b/tests/components/starlink/test_init.py index 53d58a852cd..4fdb6afc3ed 100644 --- a/tests/components/starlink/test_init.py +++ b/tests/components/starlink/test_init.py @@ -5,7 +5,11 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from .patchers import LOCATION_DATA_SUCCESS_PATCHER, STATUS_DATA_SUCCESS_PATCHER +from .patchers import ( + LOCATION_DATA_SUCCESS_PATCHER, + SLEEP_DATA_SUCCESS_PATCHER, + STATUS_DATA_SUCCESS_PATCHER, +) from tests.common import MockConfigEntry @@ -17,7 +21,7 @@ async def test_successful_entry(hass: HomeAssistant) -> None: data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, ) - with STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER: + with STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER: entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -34,7 +38,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, ) - with STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER: + with STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER: entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id)