diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 65836e0c619..a43bf4fd808 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, + Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import event @@ -97,15 +98,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # we will create entities before firing EVENT_COMPONENT_LOADED await async_process_integration_platform_for_component(hass, DOMAIN) hass.data[DOMAIN] = Sun(hass) + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - sun = hass.data.pop(DOMAIN) - sun.remove_listeners() - hass.states.async_remove(sun.entity_id) - return True + if unload_ok := await hass.config_entries.async_unload_platforms( + entry, [Platform.SENSOR] + ): + sun: Sun = hass.data.pop(DOMAIN) + sun.remove_listeners() + hass.states.async_remove(sun.entity_id) + return unload_ok class Sun(Entity): diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py new file mode 100644 index 00000000000..527ccc4069f --- /dev/null +++ b/homeassistant/components/sun/sensor.py @@ -0,0 +1,133 @@ +"""Sensor platform for Sun integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import Sun +from .const import DOMAIN + +ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" + + +@dataclass +class SunEntityDescriptionMixin: + """Mixin for required Sun base description keys.""" + + value_fn: Callable[[Sun], StateType | datetime] + + +@dataclass +class SunSensorEntityDescription(SensorEntityDescription, SunEntityDescriptionMixin): + """Describes Sun sensor entity.""" + + +SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( + SunSensorEntityDescription( + key="next_dawn", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next dawn", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dawn, + ), + SunSensorEntityDescription( + key="next_dusk", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next dusk", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dusk, + ), + SunSensorEntityDescription( + key="next_midnight", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next midnight", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_midnight, + ), + SunSensorEntityDescription( + key="next_noon", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next noon", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_noon, + ), + SunSensorEntityDescription( + key="next_rising", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next rising", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_rising, + ), + SunSensorEntityDescription( + key="next_setting", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next setting", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_setting, + ), + SunSensorEntityDescription( + key="solar_elevation", + name="Solar elevation", + icon="mdi:theme-light-dark", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_elevation, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), + SunSensorEntityDescription( + key="solar_azimuth", + name="Solar azimuth", + icon="mdi:sun-angle", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_azimuth, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sun sensor platform.""" + + sun: Sun = hass.data[DOMAIN] + + async_add_entities( + [SunSensor(sun, description, entry.entry_id) for description in SENSOR_TYPES] + ) + + +class SunSensor(SensorEntity): + """Representation of a Sun Sensor.""" + + entity_description: SunSensorEntityDescription + + def __init__( + self, sun: Sun, entity_description: SunSensorEntityDescription, entry_id: str + ) -> None: + """Initiate Sun Sensor.""" + self.entity_description = entity_description + self.entity_id = ENTITY_ID_SENSOR_FORMAT.format(entity_description.key) + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self.sun = sun + + @property + def native_value(self) -> StateType | datetime: + """Return value of sensor.""" + state = self.entity_description.value_fn(self.sun) + return state diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 2795330bf7b..fef9bd4e049 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -5,10 +5,9 @@ from unittest.mock import patch from freezegun import freeze_time import pytest -import homeassistant.components.sun as sun +from homeassistant.components import sun from homeassistant.const import EVENT_STATE_CHANGED -import homeassistant.core as ha -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -196,7 +195,7 @@ async def test_state_change_count(hass: HomeAssistant) -> None: events = [] - @ha.callback + @callback def state_change_listener(event): if event.data.get("entity_id") == "sun.sun": events.append(event) diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py new file mode 100644 index 00000000000..13f4fd0d62b --- /dev/null +++ b/tests/components/sun/test_sensor.py @@ -0,0 +1,101 @@ +"""The tests for the Sun sensor platform.""" +from datetime import datetime, timedelta + +from astral import LocationInfo +import astral.sun +from freezegun import freeze_time + +from homeassistant.components import sun +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + + +async def test_setting_rising(hass: HomeAssistant) -> None: + """Test retrieving sun setting and rising.""" + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(utc_now): + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) + + await hass.async_block_till_done() + + utc_today = utc_now.date() + + location = LocationInfo( + latitude=hass.config.latitude, longitude=hass.config.longitude + ) + + mod = -1 + while True: + next_dawn = astral.sun.dawn( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_dawn > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_dusk = astral.sun.dusk( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_dusk > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_midnight = astral.sun.midnight( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_midnight > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_noon = astral.sun.noon( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_noon > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_rising = astral.sun.sunrise( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_rising > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_setting = astral.sun.sunset( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_setting > utc_now: + break + mod += 1 + + state1 = hass.states.get("sensor.sun_next_dawn") + state2 = hass.states.get("sensor.sun_next_dusk") + state3 = hass.states.get("sensor.sun_next_midnight") + state4 = hass.states.get("sensor.sun_next_noon") + state5 = hass.states.get("sensor.sun_next_rising") + state6 = hass.states.get("sensor.sun_next_setting") + assert next_dawn.replace(microsecond=0) == dt_util.parse_datetime(state1.state) + assert next_dusk.replace(microsecond=0) == dt_util.parse_datetime(state2.state) + assert next_midnight.replace(microsecond=0) == dt_util.parse_datetime(state3.state) + assert next_noon.replace(microsecond=0) == dt_util.parse_datetime(state4.state) + assert next_rising.replace(microsecond=0) == dt_util.parse_datetime(state5.state) + assert next_setting.replace(microsecond=0) == dt_util.parse_datetime(state6.state) + + entry_ids = hass.config_entries.async_entries("sun") + + entity_reg = er.async_get(hass) + entity = entity_reg.async_get("sensor.sun_next_dawn") + + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dawn"