Skip to content

Commit

Permalink
Allow configuring Starlink sleep schedule (#103057)
Browse files Browse the repository at this point in the history
* Expose sleep config getters and setters

* Add a switch for toggling sleep schedule

* Add Time platform

* Add frozen to dataclasses

* Update tests

* Add starlink time to coveragerc

* No more mixin

* Update time.py

* Update time.py

* Run data collectors asynchronously

* Fix timezone handling
  • Loading branch information
boswelja committed Mar 18, 2024
1 parent e882d47 commit 34b0ff4
Show file tree
Hide file tree
Showing 11 changed files with 204 additions and 13 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/starlink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Platform.DEVICE_TRACKER,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
]


Expand Down
63 changes: 56 additions & 7 deletions homeassistant/components/starlink/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from dataclasses import dataclass
from datetime import timedelta
import logging
from zoneinfo import ZoneInfo

from starlink_grpc import (
AlertDict,
Expand All @@ -14,8 +15,10 @@
LocationDict,
ObstructionDict,
StatusDict,
get_sleep_config,
location_data,
reboot,
set_sleep_config,
set_stow_state,
status_data,
)
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -54,13 +58,16 @@ def __init__(self, hass: HomeAssistant, name: str, url: str) -> None:
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
)
location = await self.hass.async_add_executor_job(
location_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
),
)
return StarlinkData(location, *status)
return StarlinkData(location, sleep, *status)
except GrpcError as exc:
raise UpdateFailed from exc

Expand All @@ -81,3 +88,45 @@ async def async_reboot_starlink(self) -> None:
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
11 changes: 11 additions & 0 deletions homeassistant/components/starlink/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@
"switch": {
"stowed": {
"name": "Stowed"
},
"sleep_schedule": {
"name": "Sleep schedule"
}
},
"time": {
"sleep_start": {
"name": "Sleep start"
},
"sleep_end": {
"name": "Sleep end"
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion homeassistant/components/starlink/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,17 @@ async def async_turn_off(self, **kwargs: Any) -> None:
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
),
),
]
98 changes: 98 additions & 0 deletions homeassistant/components/starlink/time.py
Original file line number Diff line number Diff line change
@@ -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],
),
]
1 change: 1 addition & 0 deletions tests/components/starlink/fixtures/sleep_data_success.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[0, 1, false]
5 changes: 5 additions & 0 deletions tests/components/starlink/patchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
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"
)
Expand Down
5 changes: 5 additions & 0 deletions tests/components/starlink/snapshots/test_diagnostics.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
None,
]),
}),
'sleep': list([
0,
1,
False,
]),
'status': dict({
'alerts': 0,
'currently_obstructed': False,
Expand Down
8 changes: 6 additions & 2 deletions tests/components/starlink/test_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
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
Expand All @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions tests/components/starlink/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
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

Expand All @@ -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)
Expand All @@ -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)
Expand Down

0 comments on commit 34b0ff4

Please sign in to comment.