diff --git a/README.md b/README.md index dbf0a56..b1a7f33 100644 --- a/README.md +++ b/README.md @@ -3,19 +3,7 @@ Creates sensors that provide information about various sun related events. Follow the installation instructions below. -Then add the desired configuration. Here is an example of a typical configuration: -```yaml -sensor: - - platform: sun2 - monitored_conditions: - - sunrise - - sunset - - sun_phase -binary_sensor: - - platform: sun2 - monitored_conditions: - - elevation -``` +Then add one or more locations with desired sensors either via YAML, the UI or both. ## Installation ### With HACS @@ -33,217 +21,301 @@ https://github.com/pnbruckner/ha-sun2 ### Manual -Place a copy of: - -[`__init__.py`](custom_components/sun2/__init__.py) at `/custom_components/sun2/__init__.py` -[`binary_sensor.py`](custom_components/sun2/binary_sensor.py) at `/custom_components/sun2/binary_sensor.py` -[`const.py`](custom_components/sun2/const.py) at `/custom_components/sun2/const.py` -[`helpers.py`](custom_components/sun2/helpers.py) at `/custom_components/sun2/helpers.py` -[`sensor.py`](custom_components/sun2/sensor.py) at `/custom_components/sun2/sensor.py` -[`manifest.json`](custom_components/sun2/manifest.json) at `/custom_components/sun2/manifest.json` - +Place a copy of the files from [`custom_components/sun2`](custom_components/sun2) +in `/custom_components/sun2`, where `` is your Home Assistant configuration directory. ->__NOTE__: Do not download the file by using the link above directly. Rather, click on it, then on the page that comes up use the `Raw` button. +>__NOTE__: When downloading, make sure to use the `Raw` button from each file's page. ### Versions -This custom integration supports HomeAssistant versions 2023.3 or newer. +This custom integration supports HomeAssistant versions 2023.4.0 or newer. -## Sensors -### Configuration variables +## Services -- **`monitored_conditions`**: A list of sensor types to create. One or more of the following: +### `sun2.reload` -#### Point in Time Sensors -type | description --|- -`solar_midnight` | The time when the sun is at its lowest point closest to 00:00:00 of the specified date; i.e. it may be a time that is on the previous day. -`astronomical_dawn` | The time in the morning when the sun is 18 degrees below the horizon. -`nautical_dawn` | The time in the morning when the sun is 12 degrees below the horizon. -`dawn` | The time in the morning when the sun is 6 degrees below the horizon. -`sunrise` | The time in the morning when the sun is 0.833 degrees below the horizon. This is to account for refraction. -`solar_noon` | The time when the sun is at its highest point. -`sunset` | The time in the evening when the sun is 0.833 degrees below the horizon. This is to account for refraction. -`dusk` | The time in the evening when the sun is a 6 degrees below the horizon. -`nautical_dusk` | The time in the evening when the sun is a 12 degrees below the horizon. -`astronomical_dusk` | The time in the evening when the sun is a 18 degrees below the horizon. -`time_at_elevation` | See [Time at Elevation Sensor](#time-at-elevation-sensor) -`elevation_at_time` | See [Elevation at Time Sensor](#elevation-at-time-sensor) - -#### Length of Time Sensors (in hours) -type | description --|- -`daylight` | The amount of time between sunrise and sunset. -`civil_daylight` | The amount of time between dawn and dusk. -`nautical_daylight` | The amount of time between nautical dawn and nautical dusk. -`astronomical_daylight` | The amount of time between astronomical dawn and astronomical dusk. -`night` | The amount of time between sunset and sunrise of the next day. -`civil_night` | The amount of time between dusk and dawn of the next day. -`nautical_night` | The amount of time between nautical dusk and nautical dawn of the next day. -`astronomical_night` | The amount of time between astronomical dusk and astronomical dawn of the next day. - -#### Other Sensors -type | description --|- -`azimuth` | The sun's azimuth (degrees). -`elevation` | The sun's elevation (degrees). -`min_elevation` | The sun's elevation at solar midnight (degrees). -`max_elevation` | The sun's elevation at solar noon (degrees). -`deconz_daylight` | Emulation of [deCONZ Daylight Sensor](https://www.home-assistant.io/integrations/deconz/#deconz-daylight-sensor). Entity is `sensor.deconz_daylight` instead of `sensor.daylight`. -`sun_phase` | See [Sun Phase Sensor](#sun-phase-sensor) +Reloads Sun2 from the YAML-configuration. Also adds `SUN2` to the Developers Tools -> YAML page. + +## Configuration variables + +A list of configuration options for one or more "locations". Each location is defined by the following options. + +> Note: This defines configuration via YAML. However, the same sensors can be added to locations created in the UI. + +Key | Optional | Description +-|-|- +`unique_id` | no | Unique identifier for location. This allows any of the remaining options to be changed without looking like a new location. +`location` | yes* | Name of location +`latitude` | yes* | The location's latitude (in degrees) +`longitude` | yes* | The location's longitude (in degrees) +`time_zone` | yes* | The location's time zone. (See the "TZ database name" column at http://en.wikipedia.org/wiki/List_of_tz_database_time_zones.) +`elevation` | yes* | The location's elevation above sea level (in meters) +`binary_sensors` | yes | Binary sensor configurations as defined [here](#binary-sensor-configurations) +`sensors` | yes | Sensor configurations as defined [here](#sensor-configurations) + +\* These must all be used together. If not used, the default is Home Assistant's location & name configuration. + +### Binary Sensor Configurations + +A list of one or more of the following. + +#### `elevation` + +`'on'` when sun's elevation is above threshold, `'off'` when at or below threshold. + +Key | Optional | Description +-|-|- +`unique_id` | no | Unique identifier for entity. Must be unique within set of binary sensors for location. This allows any of the remaining options to be changed without looking like a new entity. +`elevation` | no | Elevation threshold (in degrees) or `horizon` +`name` | yes | Entity friendly name + +For example, this: + +```yaml +- unique_id: bs1 + elevation: horizon +``` + +Would be equivalent to: + +```yaml +- unique_id: bs1 + elevation: -0.833 + name: Above horizon +``` -##### Time at Elevation Sensor +### Sensor Configurations -key | optional | description +A list of one or more of the following. + +#### Time at Elevation Sensor + +Key | Optional | Description -|-|- -`time_at_elevation` | no | Elevation +`unique_id` | no | Unique identifier for entity. Must be unique within set of sensors for location. This allows any of the remaining options to be changed without looking like a new entity. +`time_at_elevation` | no | Elevation (in degrees) `direction` | yes | `rising` (default) or `setting` -`icon` | yes | default is `mdi:weather-sunny` -`name` | yes | default is "DIRECTION at [minus] ELEVATION °" +`icon` | yes | Default is `mdi:weather-sunny` +`name` | yes | Entity friendly name For example, this: ```yaml -- time_at_elevation: -0.833 +- unique_id: s1 + time_at_elevation: -0.833 ``` Would be equivalent to: ```yaml -- time_at_elevation: -0.833 +- unique_id: s1 + time_at_elevation: -0.833 direction: rising icon: mdi:weather-sunny name: Rising at minus 0.833 ° ``` -Which would result in an entity with the ID: `sensor.rising_at_minus_0_833_deg` - -##### Elevation at Time Sensor +#### Elevation at Time Sensor -key | optional | description +Key | Optional | Description -|-|- -`elevation_at_time` | no | time string or `input_datetime` entity ID -`name` | yes | default is "Elevation at " +`unique_id` | no | Unique identifier for entity. Must be unique within set of sensors for location. This allows any of the remaining options to be changed without looking like a new entity. +`elevation_at_time` | no | Time string or `input_datetime` entity ID +`name` | yes | Entity friendly name When using an `input_datetime` entity it must have the time component. The date component is optional. If the date is not present, the result will be the sun's elevation at the given time on the current date. If the date is present, it will be used and the result will be the sun's elevation at the given time on the given date. Also in this case, the `sensor` entity will not have `yesterday`, `today` and `tomorrow` attributes. -##### Sun Phase Sensor +## Aditional Sensors -###### Possible states -state | description --|- -`Night` | Sun is below -18° -`Astronomical Twilight` | Sun is between -18° and -12° -`Nautical Twilight` | Sun is between -12° and -6° -`Civil Twilight` | Sun is between -6° and -0.833° -`Day` | Sun is above -0.833° +Besides the sensors described above, the following will also be created automatically. Simply enable or disable these entities as desired. -###### Attributes -attribute | description --|- -`rising` | `True` if sun is rising. -`blue_hour` | `True` if sun is between -6° and -4° -`golden_hour` | `True` if sun is between -4° and 6° +### Point in Time Sensors -## Binary Sensors -### Configuration variables +Some of these will be enabled by default. The rest will be disabled by default. -- **`monitored_conditions`**: A list of sensor types to create. One or more of the following: - -#### `elevation` - -`'on'` when sun's elevation is above threshold, `'off'` when at or below threshold. Can be specified in any of the following ways: +Type | Enabled | Description +-|-|- +Solar Midnight | yes | The time when the sun is at its lowest point closest to 00:00:00 of the specified date; i.e. it may be a time that is on the previous day. +Astronomical Dawn | no | The time in the morning when the sun is 18 degrees below the horizon +Nautical Dawn | no | The time in the morning when the sun is 12 degrees below the horizon +Dawn | yes | The time in the morning when the sun is 6 degrees below the horizon +Rising | yes | The time in the morning when the sun is 0.833 degrees below the horizon. This is to account for refraction. +Solar Noon | yes | The time when the sun is at its highest point +Setting | yes | The time in the evening when the sun is 0.833 degrees below the horizon. This is to account for refraction. +Dusk | yes | The time in the evening when the sun is a 6 degrees below the horizon +Nautical Dusk | no | The time in the evening when the sun is a 12 degrees below the horizon +Astronomical Dusk | no | The time in the evening when the sun is a 18 degrees below the horizon + +### Length of Time Sensors (in hours) + +These are all disabled by default. + +Type | Description +-|- +Daylight | The amount of time between sunrise and sunset +Civil Daylight | The amount of time between dawn and dusk +Nautical Daylight | The amount of time between nautical dawn and nautical dusk +Astronomical Daylight | The amount of time between astronomical dawn and astronomical dusk +Night | The amount of time between sunset and sunrise of the next day +Civil Night | The amount of time between dusk and dawn of the next day +Nautical Night | The amount of time between nautical dusk and nautical dawn of the next day +Astronomical Night | The amount of time between astronomical dusk and astronomical dawn of the next day -```yaml -elevation +### Other Sensors -elevation: THRESHOLD +These are also all disabled by default. -elevation: - above: THRESHOLD - name: FRIENDLY_NAME -``` +Type | Description +-|- +Azimuth | The sun's azimuth (degrees) +Elevation | The sun's elevation (degrees) +Minimum Elevation | The sun's elevation at solar midnight (degrees) +maximum Elevation | The sun's elevation at solar noon (degrees) +deCONZ Daylight | Emulation of [deCONZ Daylight Sensor](https://www.home-assistant.io/integrations/deconz/#deconz-daylight-sensor) +Phase | See [Sun Phase Sensor](#sun-phase-sensor) -Default THRESHOLD (as with first format) is -0.833 (same as sunrise/sunset). +##### Sun Phase Sensor -Default FRIENDLY_NAME is "Above Horizon" if THRESHOLD is -0.833, "Above minus THRESHOLD" if THRESHOLD is negative, otherwise "Above THRESHOLD". +###### Possible states -`entity_id` will therefore be, for example, `binary_sensor.above_horizon` (-0.833), or `binary_sensor.above_minus_5_0` (-5) or `binary_sensor.above_10_5` (10.5). +State | Description +-|- +Night | Sun is below -18° +Astronomical Twilight | Sun is between -18° and -12° +Nautical Twilight | Sun is between -12° and -6° +Civil Twilight | Sun is between -6° and -0.833° +Day | Sun is above -0.833° -## Optional Location +###### Attributes -The following configuration parameters are optional, and can be used with all types of sensors. All four parameters are required, and should be specified once per platform entry. These can be used to create sensors that show sun data for another (or even multiple) location(s.) The default is to use Home Assistant's location configuration. +Attribute | Description +-|- +`rising` | `True` if sun is rising +`blue_hour` | `True` if sun is between -6° and -4° +`golden_hour` | `True` if sun is between -4° and 6° -### Configuration variables +## Example Full Configuration -type | description --|- -`latitude` | The location's latitude (in degrees.) -`longitude` | The location's longitude (in degrees.) -`time_zone` | The location's time zone. (See the "TZ database name" column at http://en.wikipedia.org/wiki/List_of_tz_database_time_zones.) -`elevation` | The location's elevation above sea level (in meters.) +```yaml +sun2: + - unique_id: home + binary_sensors: + - unique_id: bs1 + elevation: horizon + - unique_id: bs2 + elevation: 3 + - unique_id: bs3 + elevation: -6 + name: Above Civil Dawn + sensors: + - unique_id: s1 + time_at_elevation: 10 + - unique_id: s2 + time_at_elevation: -10 + direction: setting + icon: mdi:weather-sunset-down + name: Setting past 10 deg below horizon + - unique_id: s3 + elevation_at_time: '12:00' + name: Elv @ noon + - unique_id: s4 + elevation_at_time: input_datetime.test + name: Elv @ test var + + - unique_id: london + location: London + latitude: 51.50739529645933 + longitude: -0.12767666584664272 + time_zone: Europe/London + elevation: 11 + binary_sensors: + - unique_id: bs1 + elevation + - unique_id: bs2 + elevation: 3 + - unique_id: bs3 + elevation: -6 + name: Above Civil Dawn + sensors: + - unique_id: s1 + time_at_elevation: 10 + - unique_id: s2 + time_at_elevation: -10 + direction: setting + icon: mdi:weather-sunset-down + name: Setting past 10 deg below horizon + - unique_id: s3 + elevation_at_time: '12:00' + name: Elv @ noon + - unique_id: s4 + elevation_at_time: input_datetime.test + name: Elv @ test var +``` -## Entity Namespace +## Converting from `platform` configuration -When using the optional [`entity_namespace`](https://www.home-assistant.io/docs/configuration/platform_options/#entity-namespace) configuration parameter, not only will this affect Entity IDs, but it will also be used in creating the entity's `friendly_name`. E.g., in the configuration show below, the sunrise and sunset entities for London will be named "London Sunrise" and "London Sunset". +In previous versions, configuration was done under `binary_sensor` & `sensor`. +This is now deprecated and will generate a warning at startup. +It should be converted to the new `sun2` format as described above. -## Example Full Configuration +Here is an example of the old format: ```yaml +binary_sensor: + - platform: sun2 + entity_namespace: London + latitude: 51.50739529645933 + longitude: -0.12767666584664272 + time_zone: Europe/London + elevation: 11 + monitored_conditions: + - elevation: + above: -6 + name: Above Civil Dawn sensor: - platform: sun2 monitored_conditions: - - solar_midnight - - astronomical_dawn - - nautical_dawn - dawn - sunrise - - solar_noon - sunset - dusk - - nautical_dusk - - astronomical_dusk - - daylight - - civil_daylight - - nautical_daylight - - astronomical_daylight - - night - - civil_night - - nautical_night - - astronomical_night - - azimuth - - elevation - - min_elevation - - max_elevation - - sun_phase - - deconz_daylight - - time_at_elevation: 10 + - elevation_at_time: input_datetime.arrival + name: Elv @ arrival - time_at_elevation: -10 direction: setting icon: mdi:weather-sunset-down name: Setting past 10 deg below horizon - - elevation_at_time: '12:00' - - elevation_at_time: input_datetime.test - name: Elv @ test time - - platform: sun2 - entity_namespace: London +``` + +This is the equivalent configuration in the new format: + +```yaml +sun2: + - unique_id: london + location: London latitude: 51.50739529645933 longitude: -0.12767666584664272 time_zone: Europe/London elevation: 11 - monitored_conditions: - - sunrise - - sunset -binary_sensor: - - platform: sun2 - monitored_conditions: - - elevation - - elevation: 3 - - elevation: - above: -6 - name: Above Civil Dawn + binary_sensors: + - unique_id: bs1 + elevation: -6 + name: Above Civil Dawn + - unique_id: home + sensors: + - unique_id: s1 + elevation_at_time: input_datetime.arrival + name: Elv @ arrival + - unique_id: s2 + time_at_elevation: -10 + direction: setting + icon: mdi:weather-sunset-down + name: Setting past 10 deg below horizon ``` +All "simple" sensor options (e.g., `sunrise`, `sunset`, etc.) will be created automatically. +Some will be enabled by default, but most will not. +Simply go to the Settings -> Devices & services page, click on Sun2, then entities, and enable/disable the entities as desired. diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 28f5e4e..f1b4535 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -1 +1,164 @@ """Sun2 integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Coroutine +import re +from typing import Any, cast + +from astral import SunDirection + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_BINARY_SENSORS, + CONF_LATITUDE, + CONF_SENSORS, + CONF_UNIQUE_ID, + EVENT_CORE_CONFIG_UPDATE, + SERVICE_RELOAD, + Platform, +) +from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.reload import async_integration_yaml_config +from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_DIRECTION, CONF_TIME_AT_ELEVATION, DOMAIN, SIG_HA_LOC_UPDATED +from .helpers import LocData, LocParams, Sun2Data + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +_OLD_UNIQUE_ID = re.compile(r"[0-9a-f]{32}-([0-9a-f]{32})") +_UUID_UNIQUE_ID = re.compile(r"[0-9a-f]{32}") + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up composite integration.""" + + def update_local_loc_data() -> LocData: + """Update local location data from HA's config.""" + cast(Sun2Data, hass.data[DOMAIN]).locations[None] = loc_data = LocData( + LocParams( + hass.config.elevation, + hass.config.latitude, + hass.config.longitude, + str(hass.config.time_zone), + ) + ) + return loc_data + + async def process_config( + config: ConfigType | None, run_immediately: bool = True + ) -> None: + """Process sun2 config.""" + if not config or not (configs := config.get(DOMAIN)): + configs = [] + unique_ids = [config[CONF_UNIQUE_ID] for config in configs] + tasks: list[Coroutine[Any, Any, Any]] = [] + + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.source != SOURCE_IMPORT: + continue + if entry.unique_id not in unique_ids: + tasks.append(hass.config_entries.async_remove(entry.entry_id)) + + for conf in configs: + tasks.append( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf.copy() + ) + ) + + if not tasks: + return + + if run_immediately: + await asyncio.gather(*tasks) + else: + for task in tasks: + hass.async_create_task(task) + + async def reload_config(call: ServiceCall | None = None) -> None: + """Reload configuration.""" + await process_config(await async_integration_yaml_config(hass, DOMAIN)) + + async def handle_core_config_update(event: Event) -> None: + """Handle core config update.""" + if not event.data: + return + + loc_data = update_local_loc_data() + + if not any(key in event.data for key in ("location_name", "language")): + # Signal all instances that location data has changed. + dispatcher_send(hass, SIG_HA_LOC_UPDATED, loc_data) + return + + await reload_config() + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.source == SOURCE_IMPORT: + continue + if CONF_LATITUDE not in entry.options: + reload = not hass.config_entries.async_update_entry( + entry, title=hass.config.location_name + ) + else: + reload = True + if reload: + await hass.config_entries.async_reload(entry.entry_id) + + update_local_loc_data() + await process_config(config, run_immediately=False) + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, reload_config) + hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, handle_core_config_update) + + return True + + +async def entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle config entry update.""" + # Remove entity registry entries for additional sensors that were deleted. + unqiue_ids = [ + sensor[CONF_UNIQUE_ID] + for sensor_type in (CONF_BINARY_SENSORS, CONF_SENSORS) + for sensor in entry.options.get(sensor_type, []) + ] + ent_reg = er.async_get(hass) + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + unique_id = entity.unique_id + # Only sensors that were added via the UI have UUID type unique IDs. + if _UUID_UNIQUE_ID.fullmatch(unique_id) and unique_id not in unqiue_ids: + ent_reg.async_remove(entity.entity_id) + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up config entry.""" + # From 3.0.0b8 or older: Convert config direction from -1, 1 -> "setting", "rising" + options = dict(entry.options) + for sensor in options.get(CONF_SENSORS, []): + if CONF_TIME_AT_ELEVATION not in sensor: + continue + if isinstance(direction := sensor[CONF_DIRECTION], str): + continue + sensor[CONF_DIRECTION] = SunDirection(direction).name.lower() + if options != entry.options: + hass.config_entries.async_update_entry(entry, options=options) + + # From 3.0.0b9 or older: Convert unique_id from entry.entry_id-unique_id -> unique_id + ent_reg = er.async_get(hass) + for entity in ent_reg.entities.values(): + if entity.platform != DOMAIN: + continue + if m := _OLD_UNIQUE_ID.fullmatch(entity.unique_id): + ent_reg.async_update_entity(entity.entity_id, new_unique_id=m.group(1)) + + entry.async_on_unload(entry.add_update_listener(entry_updated)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 1a844e6..7e3f014 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -1,50 +1,61 @@ """Sun2 Binary Sensor.""" from __future__ import annotations +from collections.abc import Iterable from datetime import datetime -from numbers import Real from typing import cast import voluptuous as vol from homeassistant.components.binary_sensor import ( - BinarySensorEntity, - BinarySensorEntityDescription, DOMAIN as BINARY_SENSOR_DOMAIN, PLATFORM_SCHEMA, + BinarySensorEntity, + BinarySensorEntityDescription, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_ABOVE, + CONF_BINARY_SENSORS, CONF_ELEVATION, CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS, CONF_NAME, + CONF_PLATFORM, + CONF_UNIQUE_ID, ) from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt as dt_util - -from .const import ATTR_NEXT_CHANGE, LOGGER, MAX_ERR_BIN, ONE_DAY, ONE_SEC, SUNSET_ELEV +from homeassistant.util import dt as dt_util, slugify + +from .config import LOC_PARAMS +from .const import ( + ATTR_NEXT_CHANGE, + DOMAIN, + LOGGER, + MAX_ERR_BIN, + ONE_DAY, + ONE_SEC, + SUNSET_ELEV, +) from .helpers import ( - LOC_PARAMS, LocParams, Num, Sun2Entity, + Sun2EntityParams, get_loc_params, nearest_second, + sun2_dev_info, + translate, ) -DEFAULT_ELEVATION_ABOVE = SUNSET_ELEV -DEFAULT_ELEVATION_NAME = "Above Horizon" - ABOVE_ICON = "mdi:white-balance-sunny" BELOW_ICON = "mdi:moon-waxing-crescent" -_SENSOR_TYPES = [CONF_ELEVATION] - # elevation # elevation: @@ -53,60 +64,43 @@ # name: -def _val_cfg(config: str | ConfigType) -> ConfigType: +def _val_elevation(config: str | ConfigType) -> ConfigType: """Validate configuration.""" if isinstance(config, str): - config = {config: {}} + config = {CONF_ELEVATION: {}} else: - if CONF_ELEVATION in config: - value = config[CONF_ELEVATION] - if isinstance(value, Real): - config[CONF_ELEVATION] = {CONF_ABOVE: value} - if CONF_ELEVATION in config: - options = config[CONF_ELEVATION] - for key in options: - if key not in [CONF_ELEVATION, CONF_ABOVE, CONF_NAME]: - raise vol.Invalid(f"{key} not allowed for {CONF_ELEVATION}") - if CONF_ABOVE not in options: - options[CONF_ABOVE] = DEFAULT_ELEVATION_ABOVE - if CONF_NAME not in options: - above = options[CONF_ABOVE] - if above == DEFAULT_ELEVATION_ABOVE: - name = DEFAULT_ELEVATION_NAME - else: - name = "Above " - if above < 0: - name += f"minus {-above}" - else: - name += f"{above}" - options[CONF_NAME] = name + config = config.copy() + value = config[CONF_ELEVATION] + if isinstance(value, float): + config[CONF_ELEVATION] = {CONF_ABOVE: value} + else: + config[CONF_ELEVATION] = value.copy() + options = config[CONF_ELEVATION] + if CONF_ABOVE not in options: + options[CONF_ABOVE] = "horizon" return config -_BINARY_SENSOR_SCHEMA = vol.All( +_ELEVATION_SCHEMA = vol.All( vol.Any( - vol.In(_SENSOR_TYPES), - vol.Schema( - { - vol.Required(vol.In(_SENSOR_TYPES)): vol.Any( - vol.Coerce(float), - vol.Schema( - { - vol.Optional(CONF_ABOVE): vol.Coerce(float), - vol.Optional(CONF_NAME): cv.string, - } - ), - ), - } - ), + CONF_ELEVATION, + { + vol.Required(CONF_ELEVATION): vol.Any( + vol.Coerce(float), + { + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_NAME): cv.string, + }, + ) + }, ), - _val_cfg, + _val_elevation, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [_BINARY_SENSOR_SCHEMA] + cv.ensure_list, [_ELEVATION_SCHEMA] ), **LOC_PARAMS, } @@ -119,21 +113,27 @@ class Sun2ElevationSensor(Sun2Entity, BinarySensorEntity): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, name: str, - above: float, + threshold: float | str, ) -> None: """Initialize sensor.""" - object_id = name - if namespace: - name = f"{namespace} {name}" + if not isinstance(extra, Sun2EntityParams): + # Note that entity_platform will add namespace prefix to object ID. + self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{slugify(name)}" + if extra: + name = f"{extra} {name}" + extra = None self.entity_description = BinarySensorEntityDescription( key=CONF_ELEVATION, name=name ) - super().__init__(loc_params, BINARY_SENSOR_DOMAIN, object_id) + super().__init__(loc_params, cast(Sun2EntityParams | None, extra)) self._event = "solar_elevation" - self._threshold: float = above + if isinstance(threshold, str): + self._threshold = SUNSET_ELEV + else: + self._threshold = threshold def _find_nxt_dttm( self, t0_dttm: datetime, t0_elev: Num, t1_dttm: datetime, t1_elev: Num @@ -231,7 +231,7 @@ def _get_nxt_dttm(self, cur_dttm: datetime) -> datetime | None: else: t0_dttm = evt_dttm3 t1_dttm = evt_dttm4 - else: + else: # noqa: PLR5501 if not self._attr_is_on: t0_dttm = cur_dttm t1_dttm = evt_dttm4 @@ -283,7 +283,7 @@ def _update(self, cur_dttm: datetime) -> None: self._attr_is_on = cur_elev > self._threshold self._attr_icon = ABOVE_ICON if self._attr_is_on else BELOW_ICON LOGGER.debug( - "%s: above = %f, elevation = %f", self.name, self._threshold, cur_elev + "%s: threshold = %f, elevation = %f", self.name, self._threshold, cur_elev ) nxt_dttm = self._get_nxt_dttm(cur_dttm) @@ -299,16 +299,58 @@ def schedule_update(now: datetime) -> None: self.hass, schedule_update, nxt_dttm ) nxt_dttm = dt_util.as_local(nxt_dttm) - else: - if self.hass.state == CoreState.running: - LOGGER.error( - "%s: Sun elevation never reaches %f at this location", - self.name, - self._threshold, - ) + elif self.hass.state == CoreState.running: + LOGGER.error( + "%s: Sun elevation never reaches %f at this location", + self.name, + self._threshold, + ) self._attr_extra_state_attributes = {ATTR_NEXT_CHANGE: nxt_dttm} +def _elevation_name( + hass: HomeAssistant | None, name: str | None, threshold: float | str +) -> str: + """Return elevation sensor name.""" + if name: + return name + if not hass: + if isinstance(threshold, str): + return "Above Horizon" + if threshold < 0: + return f"Above minus {-threshold}" + return f"Above {threshold}" + if isinstance(threshold, str): + return translate(hass, "above_horizon") + if threshold < 0: + return translate(hass, "above_neg_elev", {"elevation": str(-threshold)}) + return translate(hass, "above_pos_elev", {"elevation": str(threshold)}) + + +def _sensors( + loc_params: LocParams | None, + extra: Sun2EntityParams | str | None, + sensors_config: Iterable[ConfigType], + hass: HomeAssistant | None = None, +) -> list[Entity]: + """Create list of entities to add.""" + sensors: list[Entity] = [] + for config in sensors_config: + if isinstance(extra, Sun2EntityParams): + unique_id = config[CONF_UNIQUE_ID] + if extra.entry.source == SOURCE_IMPORT: + unique_id = f"{extra.entry.entry_id}-{unique_id}" + extra.unique_id = unique_id + threshold = config[CONF_ELEVATION] + name = config.get(CONF_NAME) + else: + threshold = config[CONF_ELEVATION][CONF_ABOVE] + name = config[CONF_ELEVATION].get(CONF_NAME) + name = _elevation_name(hass, name, threshold) + sensors.append(Sun2ElevationSensor(loc_params, extra, name, threshold)) + return sensors + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -316,15 +358,37 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up sensors.""" - loc_params = get_loc_params(config) - namespace = config.get(CONF_ENTITY_NAMESPACE) - sensors = [] - for cfg in config[CONF_MONITORED_CONDITIONS]: - if CONF_ELEVATION in cfg: - options = cfg[CONF_ELEVATION] - sensors.append( - Sun2ElevationSensor( - loc_params, namespace, options[CONF_NAME], options[CONF_ABOVE] - ) - ) - async_add_entities(sensors, True) + LOGGER.warning( + "%s: %s under %s is deprecated. Move to %s:", + CONF_PLATFORM, + DOMAIN, + BINARY_SENSOR_DOMAIN, + DOMAIN, + ) + + async_add_entities( + _sensors( + get_loc_params(config), + config.get(CONF_ENTITY_NAMESPACE), + config[CONF_MONITORED_CONDITIONS], + ), + True, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + config = entry.options + async_add_entities( + _sensors( + get_loc_params(config), + Sun2EntityParams(entry, sun2_dev_info(hass, entry)), + config.get(CONF_BINARY_SENSORS, []), + hass, + ), + True, + ) diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py new file mode 100644 index 0000000..01c54d6 --- /dev/null +++ b/custom_components/sun2/config.py @@ -0,0 +1,136 @@ +"""Sun2 config validation.""" +from __future__ import annotations + +from typing import cast + +from astral import SunDirection +import voluptuous as vol + +from homeassistant.const import ( + CONF_BINARY_SENSORS, + CONF_ELEVATION, + CONF_ICON, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, + CONF_SENSORS, + CONF_TIME_ZONE, + CONF_UNIQUE_ID, +) +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_DIRECTION, + CONF_ELEVATION_AT_TIME, + CONF_TIME_AT_ELEVATION, + DOMAIN, +) +from .helpers import init_translations + +PACKAGE_MERGE_HINT = "list" + +LOC_PARAMS = { + vol.Inclusive(CONF_ELEVATION, "location"): vol.Coerce(float), + vol.Inclusive(CONF_LATITUDE, "location"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "location"): cv.longitude, + vol.Inclusive(CONF_TIME_ZONE, "location"): cv.time_zone, +} + +_SUN2_BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_ELEVATION): vol.Any( + vol.All(vol.Lower, "horizon"), + vol.Coerce(float), + msg="must be a float or the word horizon", + ), + vol.Optional(CONF_NAME): cv.string, + } +) + +ELEVATION_AT_TIME_SCHEMA_BASE = vol.Schema( + { + vol.Required(CONF_ELEVATION_AT_TIME): vol.Any( + vol.All(cv.string, cv.entity_domain("input_datetime")), + cv.time, + msg="expected time string or input_datetime entity ID", + ), + vol.Optional(CONF_NAME): cv.string, + } +) + +_ELEVATION_AT_TIME_SCHEMA = ELEVATION_AT_TIME_SCHEMA_BASE.extend( + {vol.Required(CONF_UNIQUE_ID): cv.string} +) + +SUN_DIRECTIONS = [dir.lower() for dir in SunDirection.__members__] + +TIME_AT_ELEVATION_SCHEMA_BASE = vol.Schema( + { + vol.Required(CONF_TIME_AT_ELEVATION): vol.All( + vol.Coerce(float), vol.Range(min=-90, max=90), msg="invalid elevation" + ), + vol.Optional(CONF_DIRECTION, default=SUN_DIRECTIONS[0]): vol.In(SUN_DIRECTIONS), + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_NAME): cv.string, + } +) + +_TIME_AT_ELEVATION_SCHEMA = TIME_AT_ELEVATION_SCHEMA_BASE.extend( + {vol.Required(CONF_UNIQUE_ID): cv.string} +) + + +def _sensor(config: ConfigType) -> ConfigType: + """Validate sensor config.""" + if CONF_ELEVATION_AT_TIME in config: + return cast(ConfigType, _ELEVATION_AT_TIME_SCHEMA(config)) + if CONF_TIME_AT_ELEVATION in config: + return cast(ConfigType, _TIME_AT_ELEVATION_SCHEMA(config)) + raise vol.Invalid(f"expected {CONF_ELEVATION_AT_TIME} or {CONF_TIME_AT_ELEVATION}") + + +_SUN2_LOCATION_CONFIG = vol.Schema( + { + vol.Required(CONF_UNIQUE_ID): cv.string, + vol.Inclusive(CONF_LOCATION, "location"): cv.string, + vol.Optional(CONF_BINARY_SENSORS): vol.All( + cv.ensure_list, [_SUN2_BINARY_SENSOR_SCHEMA] + ), + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [_sensor]), + **LOC_PARAMS, + } +) + + +def _unique_locations_names(configs: list[dict]) -> list[dict]: + """Check that location names are unique.""" + names = [config.get(CONF_LOCATION) for config in configs] + if len(names) != len(set(names)): + raise vol.Invalid(f"{CONF_LOCATION} values must be unique") + return configs + + +_SUN2_CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN): vol.All( + lambda config: config or [], + cv.ensure_list, + [_SUN2_LOCATION_CONFIG], + _unique_locations_names, + ), + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_validate_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType | None: + """Validate configuration.""" + await init_translations(hass) + + return cast(ConfigType, _SUN2_CONFIG_SCHEMA(config)) diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py new file mode 100644 index 0000000..dec5c02 --- /dev/null +++ b/custom_components/sun2/config_flow.py @@ -0,0 +1,460 @@ +"""Config flow for Sun2 integration.""" +from __future__ import annotations + +from abc import abstractmethod +from contextlib import suppress +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ( + SOURCE_IMPORT, + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import ( + CONF_BINARY_SENSORS, + CONF_ELEVATION, + CONF_ICON, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, + CONF_SENSORS, + CONF_TIME_ZONE, + CONF_UNIQUE_ID, +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowHandler, FlowResult +from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + BooleanSelector, + EntitySelector, + EntitySelectorConfig, + IconSelector, + LocationSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + TextSelector, + TimeSelector, +) +from homeassistant.util.uuid import random_uuid_hex + +from .config import SUN_DIRECTIONS +from .const import ( + CONF_DIRECTION, + CONF_ELEVATION_AT_TIME, + CONF_TIME_AT_ELEVATION, + DOMAIN, +) +from .helpers import init_translations + +_LOCATION_OPTIONS = [CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_TIME_ZONE] + + +class Sun2Flow(FlowHandler): + """Sun2 flow mixin.""" + + _existing_entries: list[ConfigEntry] | None = None + _existing_entities: dict[str, str] | None = None + + @property + def _entries(self) -> list[ConfigEntry]: + """Get existing config entries.""" + if self._existing_entries is None: + self._existing_entries = self.hass.config_entries.async_entries(DOMAIN) + return self._existing_entries + + @property + def _entities(self) -> dict[str, str]: + """Get existing configured entities.""" + if self._existing_entities is not None: + return self._existing_entities + + ent_reg = er.async_get(self.hass) + existing_entities: dict[str, str] = {} + for key, domain in { + CONF_BINARY_SENSORS: BS_DOMAIN, + CONF_SENSORS: SENSOR_DOMAIN, + }.items(): + for sensor in self.options.get(key, []): + unique_id = cast(str, sensor[CONF_UNIQUE_ID]) + entity_id = cast( + str, ent_reg.async_get_entity_id(domain, DOMAIN, unique_id) + ) + existing_entities[entity_id] = unique_id + self._existing_entities = existing_entities + return existing_entities + + @property + @abstractmethod + def options(self) -> dict[str, Any]: + """Return mutable copy of options.""" + + def _any_using_ha_loc(self) -> bool: + """Determine if a config is using Home Assistant location.""" + return any(CONF_LATITUDE not in entry.options for entry in self._entries) + + async def async_step_location( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle location options.""" + if user_input is not None: + user_input[CONF_TIME_ZONE] = cv.time_zone(user_input[CONF_TIME_ZONE]) + location: dict[str, Any] = user_input.pop(CONF_LOCATION) + user_input[CONF_LATITUDE] = location[CONF_LATITUDE] + user_input[CONF_LONGITUDE] = location[CONF_LONGITUDE] + self.options.update(user_input) + return await self.async_step_entities_menu() + + data_schema = vol.Schema( + { + vol.Required(CONF_LOCATION): LocationSelector(), + vol.Required(CONF_ELEVATION): NumberSelector( + NumberSelectorConfig(step="any", mode=NumberSelectorMode.BOX) + ), + vol.Required(CONF_TIME_ZONE): TextSelector(), + } + ) + if CONF_LATITUDE in self.options: + suggested_values = { + CONF_LOCATION: { + CONF_LATITUDE: self.options[CONF_LATITUDE], + CONF_LONGITUDE: self.options[CONF_LONGITUDE], + }, + CONF_ELEVATION: self.options[CONF_ELEVATION], + CONF_TIME_ZONE: self.options[CONF_TIME_ZONE], + } + else: + suggested_values = { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + }, + CONF_ELEVATION: self.hass.config.elevation, + CONF_TIME_ZONE: self.hass.config.time_zone, + } + data_schema = self.add_suggested_values_to_schema(data_schema, suggested_values) + return self.async_show_form( + step_id="location", data_schema=data_schema, last_step=False + ) + + async def async_step_entities_menu( + self, _: dict[str, Any] | None = None + ) -> FlowResult: + """Handle entity options.""" + await init_translations(self.hass) + menu_options = ["add_entities_menu"] + if self.options.get(CONF_BINARY_SENSORS) or self.options.get(CONF_SENSORS): + menu_options.append("remove_entities") + menu_options.append("done") + return self.async_show_menu(step_id="entities_menu", menu_options=menu_options) + + async def async_step_add_entities_menu( + self, _: dict[str, Any] | None = None + ) -> FlowResult: + """Add entities.""" + menu_options = [ + "elevation_binary_sensor", + "elevation_at_time_sensor_menu", + "time_at_elevation_sensor", + "done", + ] + return self.async_show_menu( + step_id="add_entities_menu", menu_options=menu_options + ) + + async def async_step_elevation_binary_sensor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle elevation binary sensor options.""" + if user_input is not None: + if user_input["use_horizon"]: + return await self.async_finish_sensor( + {CONF_ELEVATION: "horizon"}, CONF_BINARY_SENSORS + ) + return await self.async_step_elevation_binary_sensor_2() + + return self.async_show_form( + step_id="elevation_binary_sensor", + data_schema=vol.Schema({vol.Required("use_horizon"): BooleanSelector()}), + last_step=False, + ) + + async def async_step_elevation_binary_sensor_2( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle additional elevation binary sensor options.""" + if user_input is not None: + return await self.async_finish_sensor(user_input, CONF_BINARY_SENSORS) + + data_schema = vol.Schema( + { + vol.Required(CONF_ELEVATION): NumberSelector( + NumberSelectorConfig( + min=-90, max=90, step="any", mode=NumberSelectorMode.BOX + ) + ), + vol.Optional(CONF_NAME): TextSelector(), + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema, {CONF_ELEVATION: 0.0} + ) + return self.async_show_form( + step_id="elevation_binary_sensor_2", + data_schema=data_schema, + last_step=False, + ) + + async def async_step_elevation_at_time_sensor_menu( + self, _: dict[str, Any] | None = None + ) -> FlowResult: + """Ask elevation_at_time type.""" + menu_options = [ + "elevation_at_time_sensor_entity", + "elevation_at_time_sensor_time", + ] + return self.async_show_menu( + step_id="elevation_at_time_sensor_menu", menu_options=menu_options + ) + + async def async_step_elevation_at_time_sensor_entity( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle elevation_at_time sensor options w/ input_datetime entity.""" + if user_input is not None: + return await self.async_finish_sensor(user_input, CONF_SENSORS) + + data_schema = vol.Schema( + { + vol.Required(CONF_ELEVATION_AT_TIME): EntitySelector( + EntitySelectorConfig(domain="input_datetime") + ), + vol.Optional(CONF_NAME): TextSelector(), + } + ) + return self.async_show_form( + step_id="elevation_at_time_sensor_entity", + data_schema=data_schema, + last_step=False, + ) + + async def async_step_elevation_at_time_sensor_time( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle elevation_at_time sensor options w/ time string.""" + if user_input is not None: + return await self.async_finish_sensor(user_input, CONF_SENSORS) + + data_schema = vol.Schema( + { + vol.Required(CONF_ELEVATION_AT_TIME): TimeSelector(), + vol.Optional(CONF_NAME): TextSelector(), + } + ) + return self.async_show_form( + step_id="elevation_at_time_sensor_time", + data_schema=data_schema, + last_step=False, + ) + + async def async_step_time_at_elevation_sensor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle time_at_elevation sensor options.""" + if user_input is not None: + return await self.async_finish_sensor(user_input, CONF_SENSORS) + + data_schema = vol.Schema( + { + vol.Required(CONF_TIME_AT_ELEVATION): NumberSelector( + NumberSelectorConfig( + min=-90, max=90, step="any", mode=NumberSelectorMode.BOX + ) + ), + vol.Required(CONF_DIRECTION): SelectSelector( + SelectSelectorConfig( + options=SUN_DIRECTIONS, translation_key="direction" + ) + ), + vol.Optional(CONF_ICON): IconSelector(), + vol.Optional(CONF_NAME): TextSelector(), + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema, {CONF_TIME_AT_ELEVATION: 0.0} + ) + return self.async_show_form( + step_id="time_at_elevation_sensor", + data_schema=data_schema, + last_step=False, + ) + + async def async_finish_sensor( + self, + config: dict[str, Any], + sensor_type: str, + ) -> FlowResult: + """Finish elevation binary sensor.""" + config[CONF_UNIQUE_ID] = random_uuid_hex() + self.options.setdefault(sensor_type, []).append(config) + return await self.async_step_add_entities_menu() + + async def async_step_remove_entities( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Remove entities added previously.""" + + def delete_entity(unique_id: str) -> None: + """Remove entity with given unique ID.""" + for sensor_type in (CONF_BINARY_SENSORS, CONF_SENSORS): + for idx, sensor in enumerate(self.options.get(sensor_type, [])): + if sensor[CONF_UNIQUE_ID] == unique_id: + del self.options[sensor_type][idx] + if not self.options[sensor_type]: + del self.options[sensor_type] + return + assert False + + if user_input is not None: + for entity_id in user_input["choices"]: + delete_entity(self._entities[entity_id]) + return await self.async_step_done() + + entity_ids = list(self._entities) + data_schema = vol.Schema( + { + vol.Required("choices"): EntitySelector( + EntitySelectorConfig(include_entities=entity_ids, multiple=True) + ) + } + ) + return self.async_show_form( + step_id="remove_entities", + data_schema=data_schema, + last_step=False, + ) + + @abstractmethod + async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: + """Finish the flow.""" + + +class Sun2ConfigFlow(ConfigFlow, Sun2Flow, domain=DOMAIN): + """Sun2 config flow.""" + + VERSION = 1 + + _location_name: str | None = None + + def __init__(self) -> None: + """Initialize config flow.""" + self._options: dict[str, Any] = {} + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> Sun2OptionsFlow: + """Get the options flow for this handler.""" + flow = Sun2OptionsFlow(config_entry) + flow.init_step = ( + "location" if CONF_LATITUDE in config_entry.options else "entities_menu" + ) + return flow + + @classmethod + @callback + def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: + """Return options flow support for this handler.""" + if config_entry.source == SOURCE_IMPORT: + return False + return True + + @property + def options(self) -> dict[str, Any]: + """Return mutable copy of options.""" + return self._options + + async def async_step_import(self, data: dict[str, Any]) -> FlowResult: + """Import config entry from configuration.""" + title = cast(str, data.pop(CONF_LOCATION, self.hass.config.location_name)) + if existing_entry := await self.async_set_unique_id(data.pop(CONF_UNIQUE_ID)): + if not self.hass.config_entries.async_update_entry( + existing_entry, title=title, options=data + ): + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) + return self.async_abort(reason="already_configured") + + return self.async_create_entry(title=title, data={}, options=data) + + async def async_step_user(self, _: dict[str, Any] | None = None) -> FlowResult: + """Start user config flow.""" + if not self._any_using_ha_loc(): + return await self.async_step_use_home() + return await self.async_step_location_name() + + async def async_step_use_home( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Ask user if entry should use Home Assistant's name & location.""" + if user_input is not None: + if user_input["use_home"]: + self._location_name = self.hass.config.location_name + for option in _LOCATION_OPTIONS: + with suppress(KeyError): + del self.options[option] + return await self.async_step_entities_menu() + return await self.async_step_location_name() + + return self.async_show_form( + step_id="use_home", + data_schema=vol.Schema({vol.Required("use_home"): BooleanSelector()}), + last_step=False, + ) + + async def async_step_location_name( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Get location name.""" + errors = {} + + if user_input is not None: + self._location_name = cast(str, user_input[CONF_NAME]) + if not any(entry.title == self._location_name for entry in self._entries): + return await self.async_step_location() + errors[CONF_NAME] = "name_used" + + data_schema = vol.Schema({vol.Required(CONF_NAME): TextSelector()}) + if self._location_name is not None: + data_schema = self.add_suggested_values_to_schema( + data_schema, {CONF_NAME: self._location_name} + ) + return self.async_show_form( + step_id="location_name", + data_schema=data_schema, + errors=errors, + last_step=False, + ) + + async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: + """Finish the flow.""" + return self.async_create_entry( + title=cast(str, self._location_name), data={}, options=self.options + ) + + +class Sun2OptionsFlow(OptionsFlowWithConfigEntry, Sun2Flow): + """Sun2 integration options flow.""" + + async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: + """Finish the flow.""" + return self.async_create_entry(title="", data=self.options or {}) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index a445d6f..c8e7dd4 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -3,41 +3,47 @@ from abc import abstractmethod from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import date, datetime, time, timedelta, tzinfo from typing import Any, TypeVar, Union, cast from astral import LocationInfo from astral.location import Location -import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_TIME_ZONE, - EVENT_CORE_CONFIG_UPDATE, ) -from homeassistant.core import CALLBACK_TYPE, Event -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - dispatcher_send, -) -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util, slugify +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType -from .const import DOMAIN, ONE_DAY, SIG_HA_LOC_UPDATED +# Device Info moved to device_registry in 2023.9 +try: + from homeassistant.helpers.device_registry import DeviceInfo +except ImportError: + from homeassistant.helpers.entity import DeviceInfo # type: ignore[attr-defined] +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.translation import async_get_translations +from homeassistant.util import dt as dt_util + +from .const import ( + ATTR_NEXT_CHANGE, + ATTR_TODAY_HMS, + ATTR_TOMORROW, + ATTR_TOMORROW_HMS, + ATTR_YESTERDAY, + ATTR_YESTERDAY_HMS, + DOMAIN, + ONE_DAY, + SIG_HA_LOC_UPDATED, +) Num = Union[float, int] -LOC_PARAMS = { - vol.Inclusive(CONF_ELEVATION, "location"): vol.Coerce(float), - vol.Inclusive(CONF_LATITUDE, "location"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "location"): cv.longitude, - vol.Inclusive(CONF_TIME_ZONE, "location"): cv.time_zone, -} @dataclass(frozen=True) @@ -66,7 +72,16 @@ def __init__(self, lp: LocParams) -> None: object.__setattr__(self, "tzi", dt_util.get_time_zone(lp.time_zone)) -def get_loc_params(config: ConfigType) -> LocParams | None: +@dataclass +class Sun2Data: + """Sun2 shared data.""" + + locations: dict[LocParams | None, LocData] = field(default_factory=dict) + translations: dict[str, str] = field(default_factory=dict) + language: str | None = None + + +def get_loc_params(config: Mapping[str, Any]) -> LocParams | None: """Get location parameters from configuration.""" try: return LocParams( @@ -82,11 +97,49 @@ def get_loc_params(config: ConfigType) -> LocParams | None: def hours_to_hms(hours: Num | None) -> str | None: """Convert hours to HH:MM:SS string.""" try: - return str(timedelta(hours=cast(Num, hours))).split(".")[0] + return str(timedelta(seconds=int(cast(Num, hours) * 3600))) except TypeError: return None +_TRANS_PREFIX = f"component.{DOMAIN}.selector.misc.options" + + +async def init_translations(hass: HomeAssistant) -> None: + """Initialize translations.""" + data = cast(Sun2Data, hass.data.setdefault(DOMAIN, Sun2Data())) + if data.language != hass.config.language: + sel_trans = await async_get_translations( + hass, hass.config.language, "selector", [DOMAIN], False + ) + data.translations = {} + for sel_key, val in sel_trans.items(): + prefix, key = sel_key.rsplit(".", 1) + if prefix == _TRANS_PREFIX: + data.translations[key] = val + + +def translate( + hass: HomeAssistant, key: str, placeholders: dict[str, Any] | None = None +) -> str: + """Sun2 translations.""" + trans = cast(Sun2Data, hass.data[DOMAIN]).translations[key] + if not placeholders: + return trans + for ph_key, val in placeholders.items(): + trans = trans.replace(f"{{{ph_key}}}", str(val)) + return trans + + +def sun2_dev_info(hass: HomeAssistant, entry: ConfigEntry) -> DeviceInfo: + """Sun2 device (service) info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry.entry_id)}, + name=translate(hass, "service_name", {"location": entry.title}), + ) + + _Num = TypeVar("_Num", bound=Num) @@ -102,9 +155,28 @@ def next_midnight(dttm: datetime) -> datetime: return datetime.combine(dttm.date() + ONE_DAY, time(), dttm.tzinfo) +@dataclass +class Sun2EntityParams: + """Sun2Entity parameters.""" + + entry: ConfigEntry + device_info: DeviceInfo + unique_id: str | None = None + + class Sun2Entity(Entity): """Sun2 Entity.""" + _unrecorded_attributes = frozenset( + { + ATTR_NEXT_CHANGE, + ATTR_TODAY_HMS, + ATTR_TOMORROW, + ATTR_TOMORROW_HMS, + ATTR_YESTERDAY, + ATTR_YESTERDAY_HMS, + } + ) _attr_should_poll = False _loc_data: LocData = None # type: ignore[assignment] _unsub_update: CALLBACK_TYPE | None = None @@ -113,17 +185,28 @@ class Sun2Entity(Entity): @abstractmethod def __init__( - self, loc_params: LocParams | None, domain: str, object_id: str + self, + loc_params: LocParams | None, + sun2_entity_params: Sun2EntityParams | None = None, ) -> None: """Initialize base class. self.name must be set up to return name before calling this. E.g., set up self.entity_description.name first. """ - # Note that entity_platform will add namespace prefix to object ID. - self.entity_id = f"{domain}.{slugify(object_id)}" - self._attr_unique_id = self.name + if sun2_entity_params: + self._attr_has_entity_name = True + self._attr_translation_key = self.entity_description.key + self._attr_unique_id = sun2_entity_params.unique_id + self._attr_device_info = sun2_entity_params.device_info + else: + self._attr_unique_id = cast(str, self.name) self._loc_params = loc_params + self.async_on_remove(self._cancel_update) + + @property + def _sun2_data(self) -> Sun2Data: + return cast(Sun2Data, self.hass.data[DOMAIN]) async def async_update(self) -> None: """Update state.""" @@ -135,10 +218,6 @@ async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" self._setup_fixed_updating() - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - self._cancel_update() - def _cancel_update(self) -> None: """Cancel update.""" if self._unsub_update: @@ -150,30 +229,10 @@ def _get_loc_data(self) -> LocData: loc_params = None -> Use location parameters from HA's config. """ - if DOMAIN not in self.hass.data: - self.hass.data[DOMAIN] = {} - - def update_local_loc_data(event: Event | None = None) -> None: - """Update local location data from HA's config.""" - self.hass.data[DOMAIN][None] = loc_data = LocData( - LocParams( - self.hass.config.elevation, - self.hass.config.latitude, - self.hass.config.longitude, - str(self.hass.config.time_zone), - ) - ) - if event: - # Signal all instances that location data has changed. - dispatcher_send(self.hass, SIG_HA_LOC_UPDATED, loc_data) - - update_local_loc_data() - self.hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_local_loc_data) - try: - loc_data = cast(LocData, self.hass.data[DOMAIN][self._loc_params]) + loc_data = self._sun2_data.locations[self._loc_params] except KeyError: - loc_data = self.hass.data[DOMAIN][self._loc_params] = LocData( + loc_data = self._sun2_data.locations[self._loc_params] = LocData( cast(LocParams, self._loc_params) ) @@ -199,11 +258,9 @@ async def _async_loc_updated(self, loc_data: LocData) -> None: @abstractmethod def _update(self, cur_dttm: datetime) -> None: """Update state.""" - pass def _setup_fixed_updating(self) -> None: """Set up fixed updating.""" - pass def _astral_event( self, @@ -221,13 +278,12 @@ def _astral_event( try: if event in ("solar_midnight", "solar_noon"): return getattr(loc, event.split("_")[1])(date_or_dttm) - elif event == "time_at_elevation": + if event == "time_at_elevation": return loc.time_at_elevation( kwargs["elevation"], date_or_dttm, kwargs["direction"] ) - else: - return getattr(loc, event)( - date_or_dttm, observer_elevation=self._loc_data.elv - ) + return getattr(loc, event)( + date_or_dttm, observer_elevation=self._loc_data.elv + ) except (TypeError, ValueError): return None diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index 3930edf..47417c6 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -2,10 +2,11 @@ "domain": "sun2", "name": "Sun2", "codeowners": ["@pnbruckner"], + "config_flow": true, "dependencies": [], - "documentation": "https://github.com/pnbruckner/ha-sun2/blob/master/README.md", + "documentation": "https://github.com/pnbruckner/ha-sun2/blob/config_flow/README.md", "iot_class": "calculated", "issue_tracker": "https://github.com/pnbruckner/ha-sun2/issues", "requirements": [], - "version": "2.5.1" + "version": "3.0.0b10" } diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 7d278c4..ba31a45 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -2,16 +2,15 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Mapping, MutableMapping, Sequence +from collections.abc import Iterable, Mapping, MutableMapping, Sequence from contextlib import suppress from dataclasses import dataclass -from datetime import date, datetime, timedelta, time +from datetime import date, datetime, time, timedelta from math import ceil, floor from typing import Any, Generic, Optional, TypeVar, Union, cast from astral import SunDirection from astral.sun import SUN_APPARENT_RADIUS - import voluptuous as vol from homeassistant.components.sensor import ( @@ -22,19 +21,24 @@ SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ICON, CONF_ENTITY_NAMESPACE, CONF_ICON, CONF_MONITORED_CONDITIONS, CONF_NAME, + CONF_PLATFORM, + CONF_SENSORS, + CONF_UNIQUE_ID, DEGREE, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, UnitOfTime, ) -from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback, Event +from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_call_later, @@ -44,6 +48,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify +from .config import ( + ELEVATION_AT_TIME_SCHEMA_BASE, + LOC_PARAMS, + TIME_AT_ELEVATION_SCHEMA_BASE, +) from .const import ( ATTR_BLUE_HOUR, ATTR_DAYLIGHT, @@ -59,26 +68,39 @@ CONF_DIRECTION, CONF_ELEVATION_AT_TIME, CONF_TIME_AT_ELEVATION, - HALF_DAY, - MAX_ERR_ELEV, + DOMAIN, ELEV_STEP, + HALF_DAY, LOGGER, + MAX_ERR_ELEV, MAX_ERR_PHASE, ONE_DAY, SUNSET_ELEV, ) from .helpers import ( - LOC_PARAMS, LocData, LocParams, Num, Sun2Entity, + Sun2EntityParams, get_loc_params, hours_to_hms, nearest_second, next_midnight, + sun2_dev_info, + translate, ) +_ENABLED_SENSORS = [ + "solar_midnight", + "dawn", + "sunrise", + "solar_noon", + "sunset", + "dusk", + CONF_ELEVATION_AT_TIME, + CONF_TIME_AT_ELEVATION, +] _SOLAR_DEPRESSIONS = ("astronomical", "civil", "nautical") _DELTA = timedelta(minutes=5) @@ -94,28 +116,32 @@ class Sun2AzimuthSensor(Sun2Entity, SensorEntity): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: """Initialize sensor.""" name = sensor_type.replace("_", " ").title() - if namespace: - name = f"{namespace} {name}" + if not isinstance(extra, Sun2EntityParams): + # Note that entity_platform will add namespace prefix to object ID. + self.entity_id = f"{SENSOR_DOMAIN}.{slugify(sensor_type)}" + if extra: + name = f"{extra} {name}" + extra = None self.entity_description = SensorEntityDescription( key=sensor_type, + entity_registry_enabled_default=sensor_type in _ENABLED_SENSORS, icon=icon, name=name, native_unit_of_measurement=DEGREE, state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision = 2, + suggested_display_precision=2, ) - super().__init__(loc_params, SENSOR_DOMAIN, sensor_type) + super().__init__(loc_params, cast(Sun2EntityParams | None, extra)) self._event = "solar_azimuth" def _setup_fixed_updating(self) -> None: """Set up fixed updating.""" - pass def _update(self, cur_dttm: datetime) -> None: """Update state.""" @@ -156,7 +182,7 @@ class Sun2SensorEntity(Sun2Entity, SensorEntity, Generic[_T]): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, entity_description: SensorEntityDescription, default_solar_depression: Num | str = 0, name: str | None = None, @@ -165,14 +191,17 @@ def __init__( key = entity_description.key if name is None: name = key.replace("_", " ").title() - object_id = key + if isinstance(extra, Sun2EntityParams): + entity_description.entity_registry_enabled_default = key in _ENABLED_SENSORS else: - object_id = slugify(name) - if namespace: - name = f"{namespace} {name}" + # Note that entity_platform will add namespace prefix to object ID. + self.entity_id = f"{SENSOR_DOMAIN}.{slugify(name)}" + if extra: + name = f"{extra} {name}" + extra = None entity_description.name = name self.entity_description = entity_description - super().__init__(loc_params, SENSOR_DOMAIN, object_id) + super().__init__(loc_params, cast(Sun2EntityParams | None, extra)) if any(key.startswith(sol_dep + "_") for sol_dep in _SOLAR_DEPRESSIONS): self._solar_depression, self._event = key.rsplit("_", 1) @@ -232,9 +261,9 @@ class Sun2ElevationAtTimeSensor(Sun2SensorEntity[float]): def __init__( self, loc_params: LocParams | None, - namespace: str | None, - at_time: str | time, + extra: Sun2EntityParams | str | None, name: str, + at_time: str | time, ) -> None: """Initialize sensor.""" if isinstance(at_time, str): @@ -248,7 +277,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ) - super().__init__(loc_params, namespace, entity_description, name=name) + super().__init__(loc_params, extra, entity_description, name=name) self._event = "solar_elevation" @property @@ -271,6 +300,7 @@ def update_at_time(event: Event | None = None) -> None: if event and event.event_type == EVENT_STATE_CHANGED: state = event.data["new_state"] else: + assert self._input_datetime state = self.hass.states.get(self._input_datetime) if not state: if event and event.event_type == EVENT_STATE_CHANGED: @@ -281,29 +311,27 @@ def update_at_time(event: Event | None = None) -> None: self._unsub_listen = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STARTED, update_at_time ) + elif not state.attributes["has_time"]: + LOGGER.error( + "%s: %s missing time attributes", + self.name, + self._input_datetime, + ) + elif state.attributes["has_date"]: + self._at_time = datetime( + state.attributes["year"], + state.attributes["month"], + state.attributes["day"], + state.attributes["hour"], + state.attributes["minute"], + state.attributes["second"], + ) else: - if not state.attributes["has_time"]: - LOGGER.error( - "%s: %s missing time attributes", - self.name, - self._input_datetime, - ) - else: - if state.attributes["has_date"]: - self._at_time = datetime( - state.attributes["year"], - state.attributes["month"], - state.attributes["day"], - state.attributes["hour"], - state.attributes["minute"], - state.attributes["second"], - ) - else: - self._at_time = time( - state.attributes["hour"], - state.attributes["minute"], - state.attributes["second"], - ) + self._at_time = time( + state.attributes["hour"], + state.attributes["minute"], + state.attributes["second"], + ) self.async_schedule_update_ha_state(True) @@ -349,7 +377,7 @@ class Sun2PointInTimeSensor(Sun2SensorEntity[Union[datetime, str]]): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, name: str | None = None, @@ -360,7 +388,7 @@ def __init__( device_class=SensorDeviceClass.TIMESTAMP, icon=icon, ) - super().__init__(loc_params, namespace, entity_description, "civil", name) + super().__init__(loc_params, extra, entity_description, "civil", name) class Sun2TimeAtElevationSensor(Sun2PointInTimeSensor): @@ -369,16 +397,21 @@ class Sun2TimeAtElevationSensor(Sun2PointInTimeSensor): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, + name: str, icon: str | None, direction: SunDirection, elevation: float, - name: str, ) -> None: """Initialize sensor.""" + if not icon: + icon = { + SunDirection.RISING: "mdi:weather-sunset-up", + SunDirection.SETTING: "mdi:weather-sunset-down", + }[direction] self._direction = direction self._elevation = elevation - super().__init__(loc_params, namespace, "time_at_elevation", icon, name) + super().__init__(loc_params, extra, CONF_TIME_AT_ELEVATION, icon, name) def _astral_event( self, @@ -398,7 +431,7 @@ class Sun2PeriodOfTimeSensor(Sun2SensorEntity[float]): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -410,7 +443,7 @@ def __init__( native_unit_of_measurement=UnitOfTime.HOURS, suggested_display_precision=3, ) - super().__init__(loc_params, namespace, entity_description, SUN_APPARENT_RADIUS) + super().__init__(loc_params, extra, entity_description, SUN_APPARENT_RADIUS) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -452,7 +485,7 @@ class Sun2MinMaxElevationSensor(Sun2SensorEntity[float]): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -464,7 +497,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=3, ) - super().__init__(loc_params, namespace, entity_description) + super().__init__(loc_params, extra, entity_description) self._event = { "min_elevation": "solar_midnight", "max_elevation": "solar_noon", @@ -488,20 +521,19 @@ def _astral_event( @dataclass class CurveParameters: - """ - Parameters that describe current portion of elevation curve. + """Parameters that describe current portion of elevation curve. The ends of the current portion of the curve are bounded by a pair of solar - midnight and solar noon, such that tL_dttm <= cur_dttm < tR_dttm. rising is True if - tR_elev > tL_elev (i.e., tL represents a solar midnight and tR represents a solar + midnight and solar noon, such that tl_dttm <= cur_dttm < tr_dttm. rising is True if + tr_elev > tl_elev (i.e., tL represents a solar midnight and tR represents a solar noon.) mid_date is the date of the midpoint between tL & tR. nxt_noon is the solar noon for tomorrow (i.e., cur_date + 1.) """ - tL_dttm: datetime - tL_elev: Num - tR_dttm: datetime - tR_elev: Num + tl_dttm: datetime + tl_elev: Num + tr_dttm: datetime + tr_elev: Num mid_date: date nxt_noon: datetime rising: bool @@ -516,13 +548,13 @@ class Sun2CPSensorEntity(Sun2SensorEntity[_T]): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, entity_description: SensorEntityDescription, default_solar_depression: Num | str = 0, ) -> None: """Initialize sensor.""" super().__init__( - loc_params, namespace, entity_description, default_solar_depression + loc_params, extra, entity_description, default_solar_depression ) self._event = "solar_elevation" @@ -540,7 +572,6 @@ async def _async_loc_updated(self, loc_data: LocData) -> None: def _setup_fixed_updating(self) -> None: """Set up fixed updating.""" - pass def _attrs_at_elev(self, elev: Num) -> MutableMapping[str, Any]: """Return attributes at elevation.""" @@ -553,7 +584,7 @@ def _attrs_at_elev(self, elev: Num) -> MutableMapping[str, Any]: icon = "mdi:weather-sunset-up" else: icon = "mdi:weather-sunny" - else: + else: # noqa: PLR5501 if elev > SUNSET_ELEV: icon = "mdi:weather-sunny" elif elev > -18: @@ -582,42 +613,42 @@ def _get_curve_params(self, cur_dttm: datetime, cur_elev: Num) -> CurveParameter lo_dttm = cast(datetime, self._astral_event(cur_date, "solar_midnight")) nxt_noon = cast(datetime, self._astral_event(cur_date + ONE_DAY, "solar_noon")) if cur_dttm < lo_dttm: - tL_dttm = cast( + tl_dttm = cast( datetime, self._astral_event(cur_date - ONE_DAY, "solar_noon") ) - tR_dttm = lo_dttm + tr_dttm = lo_dttm elif cur_dttm < hi_dttm: - tL_dttm = lo_dttm - tR_dttm = hi_dttm + tl_dttm = lo_dttm + tr_dttm = hi_dttm else: lo_dttm = cast( datetime, self._astral_event(cur_date + ONE_DAY, "solar_midnight") ) if cur_dttm < lo_dttm: - tL_dttm = hi_dttm - tR_dttm = lo_dttm + tl_dttm = hi_dttm + tr_dttm = lo_dttm else: - tL_dttm = lo_dttm - tR_dttm = nxt_noon - tL_elev = cast(float, self._astral_event(tL_dttm)) - tR_elev = cast(float, self._astral_event(tR_dttm)) - rising = tR_elev > tL_elev + tl_dttm = lo_dttm + tr_dttm = nxt_noon + tl_elev = cast(float, self._astral_event(tl_dttm)) + tr_elev = cast(float, self._astral_event(tr_dttm)) + rising = tr_elev > tl_elev LOGGER.debug( "%s: tL = %s/%0.3f, cur = %s/%0.3f, tR = %s/%0.3f, rising = %s", self.name, - tL_dttm, - tL_elev, + tl_dttm, + tl_elev, cur_dttm, cur_elev, - tR_dttm, - tR_elev, + tr_dttm, + tr_elev, rising, ) - mid_date = (tL_dttm + (tR_dttm - tL_dttm) / 2).date() + mid_date = (tl_dttm + (tr_dttm - tl_dttm) / 2).date() return CurveParameters( - tL_dttm, tL_elev, tR_dttm, tR_elev, mid_date, nxt_noon, rising + tl_dttm, tl_elev, tr_dttm, tr_elev, mid_date, nxt_noon, rising ) def _get_dttm_at_elev( @@ -645,7 +676,7 @@ def _get_dttm_at_elev( except ZeroDivisionError: LOGGER.debug("%s ZeroDivisionError", msg) return None - if est_dttm < self._cp.tL_dttm or est_dttm > self._cp.tR_dttm: + if est_dttm < self._cp.tl_dttm or est_dttm > self._cp.tr_dttm: LOGGER.debug("%s outside range", msg) return None est_elev = cast(float, self._astral_event(est_dttm)) @@ -681,7 +712,7 @@ class Sun2ElevationSensor(Sun2CPSensorEntity[float]): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -693,7 +724,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ) - super().__init__(loc_params, namespace, entity_description) + super().__init__(loc_params, extra, entity_description) def _update(self, cur_dttm: datetime) -> None: """Update state.""" @@ -702,11 +733,9 @@ def _update(self, cur_dttm: datetime) -> None: cur_dttm = nearest_second(cur_dttm) cur_elev = cast(float, self._astral_event(cur_dttm)) self._attr_native_value = rnd_elev = round(cur_elev, 1) - LOGGER.debug( - "%s: Raw elevation = %f -> %s", self.name, cur_elev, rnd_elev - ) + LOGGER.debug("%s: Raw elevation = %f -> %s", self.name, cur_elev, rnd_elev) - if not self._cp or cur_dttm >= self._cp.tR_dttm: + if not self._cp or cur_dttm >= self._cp.tr_dttm: self._prv_dttm = None self._cp = self._get_curve_params(cur_dttm, cur_elev) @@ -729,8 +758,8 @@ def _update(self, cur_dttm: datetime) -> None: nxt_dttm = None if not nxt_dttm: - if self._cp.tR_dttm - _DELTA <= cur_dttm < self._cp.tR_dttm: - nxt_dttm = self._cp.tR_dttm + if self._cp.tr_dttm - _DELTA <= cur_dttm < self._cp.tr_dttm: + nxt_dttm = self._cp.tr_dttm else: nxt_dttm = cur_dttm + _DELTA @@ -776,7 +805,7 @@ class Sun2PhaseSensorBase(Sun2CPSensorEntity[str]): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, phase_data: PhaseData, @@ -792,7 +821,7 @@ def __init__( icon=icon, options=options, ) - super().__init__(loc_params, namespace, entity_description) + super().__init__(loc_params, extra, entity_description) self._d = phase_data self._updates: list[Update] = [] @@ -802,8 +831,7 @@ def _state_at_elev(self, elev: Num) -> str: if self._cp.rising: return list(filter(lambda x: elev >= x[0], self._d.rising_states))[-1][1] - else: - return list(filter(lambda x: elev <= x[0], self._d.falling_states))[-1][1] + return list(filter(lambda x: elev <= x[0], self._d.falling_states))[-1][1] @callback def _async_do_update(self, now: datetime) -> None: @@ -870,11 +898,11 @@ def get_est_dttm(offset: timedelta | None = None) -> datetime: ) est_dttm = get_est_dttm() - if not self._cp.tL_dttm <= est_dttm < self._cp.tR_dttm: + if not self._cp.tl_dttm <= est_dttm < self._cp.tr_dttm: est_dttm = get_est_dttm( - ONE_DAY if est_dttm < self._cp.tL_dttm else -ONE_DAY + ONE_DAY if est_dttm < self._cp.tl_dttm else -ONE_DAY ) - if not self._cp.tL_dttm <= est_dttm < self._cp.tR_dttm: + if not self._cp.tl_dttm <= est_dttm < self._cp.tr_dttm: raise ValueError except (AttributeError, TypeError, ValueError) as exc: if not isinstance(exc, ValueError): @@ -890,21 +918,18 @@ def get_est_dttm(offset: timedelta | None = None) -> datetime: elev, est_dttm, ) - t0_dttm = self._cp.tL_dttm - t1_dttm = self._cp.tR_dttm + t0_dttm = self._cp.tl_dttm + t1_dttm = self._cp.tr_dttm else: - t0_dttm = max(est_dttm - _DELTA, self._cp.tL_dttm) - t1_dttm = min(est_dttm + _DELTA, self._cp.tR_dttm) + t0_dttm = max(est_dttm - _DELTA, self._cp.tl_dttm) + t1_dttm = min(est_dttm + _DELTA, self._cp.tr_dttm) update_dttm = self._get_dttm_at_elev(t0_dttm, t1_dttm, elev, MAX_ERR_PHASE) if update_dttm: self._setup_update_at_time( update_dttm, self._state_at_elev(elev), self._attrs_at_elev(elev) ) - else: - if self.hass.state == CoreState.running: - LOGGER.error( - "%s: Failed to find the time at elev: %0.3f", self.name, elev - ) + elif self.hass.state == CoreState.running: + LOGGER.error("%s: Failed to find the time at elev: %0.3f", self.name, elev) def _setup_updates(self, cur_dttm: datetime, cur_elev: Num) -> None: """Set up updates for next portion of elevation curve.""" @@ -912,11 +937,11 @@ def _setup_updates(self, cur_dttm: datetime, cur_elev: Num) -> None: if self._cp.rising: for elev in self._d.rising_elevs: - if cur_elev < elev < self._cp.tR_elev: + if cur_elev < elev < self._cp.tr_elev: self._setup_update_at_elev(elev) else: for elev in self._d.falling_elevs: - if cur_elev > elev > self._cp.tR_elev: + if cur_elev > elev > self._cp.tr_elev: self._setup_update_at_elev(elev) def _cancel_update(self) -> None: @@ -950,7 +975,7 @@ def _update(self, cur_dttm: datetime) -> None: # reschedule aysnc_update() with self._updates being empty so as to make this # method run again to create a new schedule of udpates. Therefore we do not # need to provide state and attribute values. - self._setup_update_at_time(self._cp.tR_dttm) + self._setup_update_at_time(self._cp.tr_dttm) # _setup_updates may have already determined the state. if not self._attr_native_value: @@ -966,17 +991,17 @@ class Sun2PhaseSensor(Sun2PhaseSensorBase): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: """Initialize sensor.""" phases = ( - (-90, "Night"), - (-18, "Astronomical Twilight"), - (-12, "Nautical Twilight"), - (-6, "Civil Twilight"), - (SUNSET_ELEV, "Day"), + (-90, "night"), + (-18, "astronomical_twilight"), + (-12, "nautical_twilight"), + (-6, "civil_twilight"), + (SUNSET_ELEV, "day"), (90, None), ) elevs, states = cast( @@ -994,7 +1019,7 @@ def __init__( )[::-1] super().__init__( loc_params, - namespace, + extra, sensor_type, icon, PhaseData(rising_elevs, rising_states, falling_elevs, falling_states), @@ -1023,7 +1048,7 @@ class Sun2DeconzDaylightSensor(Sun2PhaseSensorBase): def __init__( self, loc_params: LocParams | None, - namespace: str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -1058,7 +1083,7 @@ def __init__( )[::-1] super().__init__( loc_params, - namespace, + extra, sensor_type, icon, PhaseData(rising_elevs, rising_states, falling_elevs, falling_states), @@ -1070,9 +1095,9 @@ def _attrs_at_elev(self, elev: Num) -> MutableMapping[str, Any]: attrs = super()._attrs_at_elev(elev) if self._cp.rising: - daylight = SUNSET_ELEV <= elev + daylight = elev >= SUNSET_ELEV else: - daylight = SUNSET_ELEV < elev + daylight = elev > SUNSET_ELEV attrs[ATTR_DAYLIGHT] = daylight return attrs @@ -1081,7 +1106,7 @@ def _setup_updates(self, cur_dttm: datetime, cur_elev: Num) -> None: assert self._cp if self._cp.rising: - nadir_dttm = self._cp.tR_dttm - HALF_DAY + nadir_dttm = self._cp.tr_dttm - HALF_DAY if cur_dttm < nadir_dttm: self._attr_native_value = self._d.falling_states[-1][1] nadir_elev = cast(float, self._astral_event(nadir_dttm)) @@ -1137,84 +1162,122 @@ class SensorParams: "deconz_daylight": SensorParams(Sun2DeconzDaylightSensor, None), } -_DIR_TO_ICON = { - SunDirection.RISING: "mdi:weather-sunset-up", - SunDirection.SETTING: "mdi:weather-sunset-down", -} +def _sensor(config: str | ConfigType) -> ConfigType: + """Validate sensor config.""" + if isinstance(config, str): + return cast(ConfigType, vol.In(_SENSOR_TYPES)(config)) + if CONF_ELEVATION_AT_TIME in config: + return cast(ConfigType, ELEVATION_AT_TIME_SCHEMA_BASE(config)) + if CONF_TIME_AT_ELEVATION in config: + return cast(ConfigType, TIME_AT_ELEVATION_SCHEMA_BASE(config)) + raise vol.Invalid( + f"value must be one of {', '.join(sorted(_SENSOR_TYPES))}" + f" or a dictionary containing key {CONF_ELEVATION_AT_TIME} or {CONF_TIME_AT_ELEVATION}" + ) -def _tae_defaults(config: ConfigType) -> ConfigType: - """Fill in defaults.""" - elevation = cast(float, config[CONF_TIME_AT_ELEVATION]) - direction = cast(SunDirection, config[CONF_DIRECTION]) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [_sensor]), + **LOC_PARAMS, + } +) - if not config.get(CONF_ICON): - config[CONF_ICON] = _DIR_TO_ICON[direction] - if not config.get(CONF_NAME): +def _elevation_at_time_name( + hass: HomeAssistant | None, name: str | None, at_time: str | time +) -> str: + """Return elevation_at_time sensor name.""" + if name: + return name + if not hass: + return f"Elevation at {at_time}" + return translate(hass, "elevation_at", {"elev_time": str(at_time)}) + + +def _time_at_elevation_name( + hass: HomeAssistant | None, + name: str | None, + direction: SunDirection, + elevation: float, +) -> str: + """Return time_at_elevation sensor name.""" + if name: + return name + if not hass: dir_str = direction.name.title() if elevation >= 0: elev_str = str(elevation) else: elev_str = f"minus {-elevation}" - config[CONF_NAME] = f"{dir_str} at {elev_str} °" - - return config - - -def _eat_defaults(config: ConfigType) -> ConfigType: - """Fill in defaults.""" - - if not config.get(CONF_NAME): - config[CONF_NAME] = f"Elevation at {config[CONF_ELEVATION_AT_TIME]}" - - return config - - -TIME_AT_ELEVATION_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_TIME_AT_ELEVATION): vol.Coerce(float), - vol.Optional(CONF_DIRECTION, default=SunDirection.RISING.name): vol.All( - vol.Upper, cv.enum(SunDirection) - ), - vol.Optional(CONF_ICON): cv.icon, - vol.Optional(CONF_NAME): cv.string, - } - ), - _tae_defaults, -) - -ELEVATION_AT_TIME_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_ELEVATION_AT_TIME): vol.Any( - vol.All(cv.string, cv.entity_domain("input_datetime")), - cv.time, - msg="Expected input_datetime entity ID or time string", - ), - vol.Optional(CONF_NAME): cv.string, - } - ), - _eat_defaults, -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, - [ - vol.Any( - TIME_AT_ELEVATION_SCHEMA, - ELEVATION_AT_TIME_SCHEMA, - vol.In(_SENSOR_TYPES), + return f"{dir_str} at {elev_str} °" + return translate( + hass, + f"{direction.name.lower()}_{'neg' if elevation < 0 else 'pos'}_elev", + {"elevation": str(abs(elevation))}, + ) + + +def _sensors( + loc_params: LocParams | None, + extra: Sun2EntityParams | str | None, + sensors_config: Iterable[str | dict[str, Any]], + hass: HomeAssistant | None = None, +) -> list[Entity]: + """Create list of entities to add.""" + sensors = [] + for config in sensors_config: + if isinstance(config, str): + if isinstance(extra, Sun2EntityParams): + extra.unique_id = f"{extra.entry.entry_id}-{config}" + sensors.append( + _SENSOR_TYPES[config].cls( + loc_params, extra, config, _SENSOR_TYPES[config].icon ) - ], - ), - **LOC_PARAMS, - } -) + ) + else: + if isinstance(extra, Sun2EntityParams): + unique_id = config[CONF_UNIQUE_ID] + if extra.entry.source == SOURCE_IMPORT: + unique_id = f"{extra.entry.entry_id}-{unique_id}" + extra.unique_id = unique_id + if CONF_ELEVATION_AT_TIME in config: + # For config entries, JSON serialization turns a time into a string. + # Convert back to time in that case. + at_time = config[CONF_ELEVATION_AT_TIME] + if isinstance(at_time, str): + with suppress(ValueError): + at_time = time.fromisoformat(at_time) + sensors.append( + Sun2ElevationAtTimeSensor( + loc_params, + extra, + _elevation_at_time_name(hass, config.get(CONF_NAME), at_time), + at_time, + ) + ) + else: + direction = SunDirection.__getitem__( + cast(str, config[CONF_DIRECTION]).upper() + ) + elevation = config[CONF_TIME_AT_ELEVATION] + sensors.append( + Sun2TimeAtElevationSensor( + loc_params, + extra, + _time_at_elevation_name( + hass, + config.get(CONF_NAME), + direction, + elevation, + ), + config.get(CONF_ICON), + direction, + elevation, + ) + ) + return sensors async def async_setup_platform( @@ -1224,36 +1287,36 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up sensors.""" - loc_params = get_loc_params(config) - namespace = config.get(CONF_ENTITY_NAMESPACE) + LOGGER.warning( + "%s: %s under %s is deprecated. Move to %s:", + CONF_PLATFORM, + DOMAIN, + SENSOR_DOMAIN, + DOMAIN, + ) + + async_add_entities( + _sensors( + get_loc_params(config), + config.get(CONF_ENTITY_NAMESPACE), + config[CONF_MONITORED_CONDITIONS], + ), + True, + ) - sensors = [] - for sensor in config[CONF_MONITORED_CONDITIONS]: - if isinstance(sensor, str): - sensors.append( - _SENSOR_TYPES[sensor].cls( - loc_params, namespace, sensor, _SENSOR_TYPES[sensor].icon - ) - ) - elif CONF_TIME_AT_ELEVATION in sensor: - sensors.append( - Sun2TimeAtElevationSensor( - loc_params, - namespace, - sensor[CONF_ICON], - sensor[CONF_DIRECTION], - sensor[CONF_TIME_AT_ELEVATION], - sensor[CONF_NAME], - ) - ) - else: - sensors.append( - Sun2ElevationAtTimeSensor( - loc_params, - namespace, - sensor[CONF_ELEVATION_AT_TIME], - sensor[CONF_NAME], - ) - ) - async_add_entities(sensors, True) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + config = entry.options + + loc_params = get_loc_params(config) + sun2_entity_params = Sun2EntityParams(entry, sun2_dev_info(hass, entry)) + async_add_entities( + _sensors(loc_params, sun2_entity_params, config.get(CONF_SENSORS, []), hass) + + _sensors(loc_params, sun2_entity_params, _SENSOR_TYPES.keys(), hass), + True, + ) diff --git a/custom_components/sun2/services.yaml b/custom_components/sun2/services.yaml new file mode 100644 index 0000000..50ece8f --- /dev/null +++ b/custom_components/sun2/services.yaml @@ -0,0 +1 @@ +reload: {} diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json new file mode 100644 index 0000000..fb83d35 --- /dev/null +++ b/custom_components/sun2/translations/en.json @@ -0,0 +1,459 @@ +{ + "title": "Sun2", + "config": { + "step": { + "add_entities_menu": { + "title": "Add Entities", + "description": "Choose type of entity to add", + "menu_options": { + "done": "Done", + "elevation_at_time_sensor_menu": "Elevation at time sensor", + "elevation_binary_sensor": "Elevation binary sensor", + "time_at_elevation_sensor": "Time at elevation sensor" + } + }, + "elevation_at_time_sensor_menu": { + "title": "Elevation at Time Type", + "menu_options": { + "elevation_at_time_sensor_entity": "input_datetime entity", + "elevation_at_time_sensor_time": "Time" + } + }, + "elevation_at_time_sensor_entity": { + "title": "Elevation at Time Sensor Options", + "data": { + "elevation_at_time": "input_datetime entity ID", + "name": "Name" + } + }, + "elevation_at_time_sensor_time": { + "title": "Elevation at Time Sensor Options", + "data": { + "elevation_at_time": "Time", + "name": "Name" + } + }, + "elevation_binary_sensor": { + "title": "Elevation Binary Sensor Options", + "data": { + "use_horizon": "Use horizon as elevation" + } + }, + "elevation_binary_sensor_2": { + "title": "Elevation Binary Sensor Options", + "data": { + "elevation": "Elevation", + "name": "Name" + } + }, + "entities_menu": { + "title": "Additional Entities", + "menu_options": { + "add_entities_menu": "Add entities", + "done": "Done" + } + }, + "location": { + "title": "Location Options", + "data": { + "elevation": "Elevation", + "location": "Location", + "time_zone": "Time zone" + }, + "data_description": { + "time_zone": "See the \"TZ identifier\" column at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List." + } + }, + "location_name": { + "title": "Location Name", + "data": { + "name": "Name" + } + }, + "time_at_elevation_sensor": { + "title": "Time at Elevation Sensor Options", + "data": { + "time_at_elevation": "Elevation", + "direction": "Sun direction", + "icon": "Icon", + "name": "Name" + } + }, + "use_home": { + "data": { + "use_home": "Use Home Assistant name and location" + } + } + }, + "error": { + "name_used": "Location name has already been used." + } + }, + "options": { + "step": { + "add_entities_menu": { + "title": "Add Entities", + "description": "Choose type of entity to add", + "menu_options": { + "done": "Done", + "elevation_at_time_sensor_menu": "Elevation at time sensor", + "elevation_binary_sensor": "Elevation binary sensor", + "time_at_elevation_sensor": "Time at elevation sensor" + } + }, + "elevation_at_time_sensor_menu": { + "title": "Elevation at Time Type", + "menu_options": { + "elevation_at_time_sensor_entity": "input_datetime entity", + "elevation_at_time_sensor_time": "Time" + } + }, + "elevation_at_time_sensor_entity": { + "title": "Elevation at Time Sensor Options", + "data": { + "elevation_at_time": "input_datetime entity ID", + "name": "Name" + } + }, + "elevation_at_time_sensor_time": { + "title": "Elevation at Time Sensor Options", + "data": { + "elevation_at_time": "Time", + "name": "Name" + } + }, + "elevation_binary_sensor": { + "title": "Elevation Binary Sensor Options", + "data": { + "use_horizon": "Use horizon as elevation" + } + }, + "elevation_binary_sensor_2": { + "title": "Elevation Binary Sensor Options", + "data": { + "elevation": "Elevation", + "name": "Name" + } + }, + "entities_menu": { + "title": "Additional Entities", + "menu_options": { + "add_entities_menu": "Add entities", + "done": "Done", + "remove_entities": "Remove entities" + } + }, + "location": { + "title": "Location Options", + "data": { + "elevation": "Elevation", + "location": "Location", + "time_zone": "Time zone" + }, + "data_description": { + "time_zone": "See the \"TZ identifier\" column at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List." + } + }, + "remove_entities": { + "title": "Remove Entities", + "data": { + "choices": "Select entities to remove" + } + }, + "time_at_elevation_sensor": { + "title": "Time at Elevation Sensor Options", + "data": { + "time_at_elevation": "Elevation", + "direction": "Sun direction", + "icon": "Icon", + "name": "Name" + } + }, + "use_home": { + "data": { + "use_home": "Use Home Assistant name and location" + } + } + } + }, + "entity": { + "binary_sensor": { + "elevation": { + "state_attributes": { + "next_change": {"name": "Next change"} + } + } + }, + "sensor": { + "astronomical_daylight": { + "name": "Astronomical daylight", + "state_attributes": { + "today": {"name": "Today"}, + "today_hms": {"name": "Today hms"}, + "tomorrow": {"name": "Tomorrow"}, + "tomorrow_hms": {"name": "Tomorrow hms"}, + "yesterday": {"name": "Yesterday"}, + "yesterday_hms": {"name": "Yesterday hms"} + } + }, + "astronomical_dawn": { + "name": "Astronomical dawn", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "astronomical_dusk": { + "name": "Astronomical dusk", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "astronomical_night": { + "name": "Astronomical night", + "state_attributes": { + "today": {"name": "Today"}, + "today_hms": {"name": "Today hms"}, + "tomorrow": {"name": "Tomorrow"}, + "tomorrow_hms": {"name": "Tomorrow hms"}, + "yesterday": {"name": "Yesterday"}, + "yesterday_hms": {"name": "Yesterday hms"} + } + }, + "azimuth": {"name": "Azimuth"}, + "civil_daylight": { + "name": "Civil daylight", + "state_attributes": { + "today": {"name": "Today"}, + "today_hms": {"name": "Today hms"}, + "tomorrow": {"name": "Tomorrow"}, + "tomorrow_hms": {"name": "Tomorrow hms"}, + "yesterday": {"name": "Yesterday"}, + "yesterday_hms": {"name": "Yesterday hms"} + } + }, + "civil_night": { + "name": "Civil night", + "state_attributes": { + "today": {"name": "Today"}, + "today_hms": {"name": "Today hms"}, + "tomorrow": {"name": "Tomorrow"}, + "tomorrow_hms": {"name": "Tomorrow hms"}, + "yesterday": {"name": "Yesterday"}, + "yesterday_hms": {"name": "Yesterday hms"} + } + }, + "daylight": { + "name": "Daylight", + "state_attributes": { + "today": {"name": "Today"}, + "today_hms": {"name": "Today hms"}, + "tomorrow": {"name": "Tomorrow"}, + "tomorrow_hms": {"name": "Tomorrow hms"}, + "yesterday": {"name": "Yesterday"}, + "yesterday_hms": {"name": "Yesterday hms"} + } + }, + "dawn": { + "name": "Dawn", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "deconz_daylight": { + "name": "deCONZ daylight", + "state": { + "dawn": "Dawn", + "dusk": "Dusk", + "golden_hour_1": "Golden hour 1", + "golden_hour_2": "Golden hour 2", + "nadir": "Nadir", + "nautical_dawn": "Nautial dawn", + "nautical_dusk": "Nautial dusk", + "night_end": "Night end", + "night_start": "Night start", + "solar_noon": "Solar noon", + "sunrise_end": "Sunrise end", + "sunrise_start": "Sunrise start" + }, + "state_attributes": { + "daylight": {"name": "Daylight"}, + "next_change": {"name": "Next change"} + } + }, + "dusk": { + "name": "Dusk", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "elevation": { + "name": "Elevation", + "state_attributes": { + "next_change": {"name": "Next change"} + } + }, + "elevation_at_time": { + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "max_elevation": { + "name": "Maximum elevation", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "min_elevation": { + "name": "Minimum elevation", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "nautical_daylight": { + "name": "Nautical daylight", + "state_attributes": { + "today": {"name": "Today"}, + "today_hms": {"name": "Today hms"}, + "tomorrow": {"name": "Tomorrow"}, + "tomorrow_hms": {"name": "Tomorrow hms"}, + "yesterday": {"name": "Yesterday"}, + "yesterday_hms": {"name": "Yesterday hms"} + } + }, + "nautical_dawn": { + "name": "Nautical dawn", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "nautical_dusk": { + "name": "Nautical dusk", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "nautical_night": { + "name": "Nautical night", + "state_attributes": { + "today": {"name": "Today"}, + "today_hms": {"name": "Today hms"}, + "tomorrow": {"name": "Tomorrow"}, + "tomorrow_hms": {"name": "Tomorrow hms"}, + "yesterday": {"name": "Yesterday"}, + "yesterday_hms": {"name": "Yesterday hms"} + } + }, + "night": { + "name": "Night", + "state_attributes": { + "today": {"name": "Today"}, + "today_hms": {"name": "Today hms"}, + "tomorrow": {"name": "Tomorrow"}, + "tomorrow_hms": {"name": "Tomorrow hms"}, + "yesterday": {"name": "Yesterday"}, + "yesterday_hms": {"name": "Yesterday hms"} + } + }, + "solar_midnight": { + "name": "Solar midnight", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "solar_noon": { + "name": "Solar noon", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "sun_phase": { + "name": "Phase", + "state": { + "astronomical_twilight": "Astronomical twilight", + "civil_twilight": "Civil twilight", + "day": "Day", + "nautical_twilight": "Nautical twilight", + "night": "Night" + }, + "state_attributes": { + "blue_hour": {"name": "Blue hour"}, + "golden_hour": {"name": "Golden hour"}, + "next_change": {"name": "Next change"}, + "rising": {"name": "Rising"} + } + }, + "sunrise": { + "name": "Rising", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "sunset": { + "name": "Setting", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + }, + "time_at_elevation": { + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } + } + } + }, + "selector": { + "direction": { + "options": { + "rising": "Rising", + "setting": "Setting" + } + }, + "misc": { + "options": { + "above_horizon": "Above horizon", + "above_neg_elev": "Above minus {elevation} °", + "above_pos_elev": "Above {elevation} °", + "elevation_at": "Elevation at {elev_time}", + "rising_neg_elev": "Rising at minus {elevation} °", + "rising_pos_elev": "Rising at {elevation} °", + "service_name": "{location} Sun", + "setting_neg_elev": "Setting at minus {elevation} °", + "setting_pos_elev": "Setting at {elevation} °" + } + } + }, + "services": { + "reload": { + "name": "Reload", + "description": "Reloads Sun2 from the YAML-configuration." + } + } +} \ No newline at end of file diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json new file mode 100644 index 0000000..313f9ac --- /dev/null +++ b/custom_components/sun2/translations/nl.json @@ -0,0 +1,459 @@ +{ + "title": "Zon2", + "config": { + "step": { + "add_entities_menu": { + "title": "Entiteiten toevoegen", + "description": "Kies het type entiteit dat u wilt toevoegen", + "menu_options": { + "done": "Klaar", + "elevation_at_time_sensor_menu": "Hoogte toevoegen bij tijdsensor", + "elevation_binary_sensor": "Binaire sensor voor elevatie toevoegen", + "time_at_elevation_sensor": "Tijd toevoegen bij hoogtesensor" + } + }, + "elevation_at_time_sensor_menu": { + "title": "Hoogte op tijdtype", + "menu_options": { + "elevation_at_time_sensor_entity": "input_datetime entiteit", + "elevation_at_time_sensor_time": "Tijd" + } + }, + "elevation_at_time_sensor_entity": { + "title": "Opties voor elevatie bij tijdsensor", + "data": { + "elevation_at_time": "input_datetime entiteit", + "name": "Naam" + } + }, + "elevation_at_time_sensor_time": { + "title": "Opties voor elevatie bij tijdsensor", + "data": { + "elevation_at_time": "Tijd", + "name": "Naam" + } + }, + "elevation_binary_sensor": { + "title": "Opties voor binaire elevatiesensoren", + "data": { + "use_horizon": "Gebruik horizon als elevatie" + } + }, + "elevation_binary_sensor_2": { + "title": "Opties voor binaire elevatiesensoren", + "data": { + "elevation": "Hoogte", + "name": "Naam" + } + }, + "entities_menu": { + "title": "Aanvullende entiteiten", + "menu_options": { + "add_entities_menu": "Entiteiten toevoegen", + "done": "Klaar" + } + }, + "location": { + "title": "Locatie Opties", + "data": { + "elevation": "Hoogtehoek", + "location": "Plaats", + "time_zone": "Tijdzone" + }, + "data_description": { + "time_zone": "Zie de kolom \"TZ identifier\" bij https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List." + } + }, + "location_name": { + "title": "Naam van de locatie", + "data": { + "name": "Naam" + } + }, + "time_at_elevation_sensor": { + "title": "Opties voor tijdsensor op hoogte", + "data": { + "time_at_elevation": "Hoogte", + "direction": "Richting van de zon", + "icon": "Pictogram", + "name": "Naam" + } + }, + "use_home": { + "data": { + "use_home": "De naam en locatie van de Home Assistant gebruiken" + } + } + }, + "error": { + "name_used": "De naam van de locatie is al gebruikt." + } + }, + "options": { + "step": { + "add_entities_menu": { + "title": "Entiteiten toevoegen", + "description": "Kies het type entiteit dat u wilt toevoegen", + "menu_options": { + "done": "Klaar", + "elevation_at_time_sensor_menu": "Hoogte toevoegen bij tijdsensor", + "elevation_binary_sensor": "Binaire sensor voor elevatie toevoegen", + "time_at_elevation_sensor": "Tijd toevoegen bij hoogtesensor" + } + }, + "elevation_at_time_sensor_menu": { + "title": "Hoogte op tijdtype", + "menu_options": { + "elevation_at_time_sensor_entity": "input_datetime entiteit", + "elevation_at_time_sensor_time": "Tijd" + } + }, + "elevation_at_time_sensor_entity": { + "title": "Opties voor elevatie bij tijdsensor", + "data": { + "elevation_at_time": "input_datetime entiteit", + "name": "Naam" + } + }, + "elevation_at_time_sensor_time": { + "title": "Opties voor elevatie bij tijdsensor", + "data": { + "elevation_at_time": "Tijd", + "name": "Naam" + } + }, + "elevation_binary_sensor": { + "title": "Opties voor binaire elevatiesensoren", + "data": { + "use_horizon": "Gebruik horizon als elevatie" + } + }, + "elevation_binary_sensor_2": { + "title": "Opties voor binaire elevatiesensoren", + "data": { + "elevation": "Hoogte", + "name": "Naam" + } + }, + "entities_menu": { + "title": "Aanvullende entiteiten", + "menu_options": { + "add_entities_menu": "Entiteiten toevoegen", + "done": "Klaar", + "remove_entities": "Entiteiten verwijderen" + } + }, + "location": { + "title": "Locatie Opties", + "data": { + "elevation": "Hoogtehoek", + "location": "Plaats", + "time_zone": "Tijdzone" + }, + "data_description": { + "time_zone": "Zie de kolom \"TZ identifier\" bij https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List." + } + }, + "remove_entities": { + "title": "Entiteiten verwijderen", + "data": { + "choices": "Entiteiten selecteren die u wilt verwijderen" + } + }, + "time_at_elevation_sensor": { + "title": "Opties voor tijdsensor op hoogte", + "data": { + "time_at_elevation": "Hoogte", + "direction": "Richting van de zon", + "icon": "Pictogram", + "name": "Naam" + } + }, + "use_home": { + "data": { + "use_home": "De naam en locatie van de Home Assistant gebruiken" + } + } + } + }, + "entity": { + "binary_sensor": { + "elevation": { + "state_attributes": { + "next_change": {"name": "Volgende wijziging"} + } + } + }, + "sensor": { + "astronomical_daylight": { + "name": "Astronomisch daglicht", + "state_attributes": { + "today": {"name": "Vandaag"}, + "today_hms": {"name": "Vandaag umt"}, + "tomorrow": {"name": "Morgen"}, + "tomorrow_hms": {"name": "Morgen umt"}, + "yesterday": {"name": "Gisteren"}, + "yesterday_hms": {"name": "Gisteren umt"} + } + }, + "astronomical_dawn": { + "name": "Astronomische dageraad", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "astronomical_dusk": { + "name": "Astronomische schemer", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "astronomical_night": { + "name": "Astronomische nacht", + "state_attributes": { + "today": {"name": "Vandaag"}, + "today_hms": {"name": "Vandaag umt"}, + "tomorrow": {"name": "Morgen"}, + "tomorrow_hms": {"name": "Morgen umt"}, + "yesterday": {"name": "Gisteren"}, + "yesterday_hms": {"name": "Gisteren umt"} + } + }, + "azimuth": {"name": "Azimut"}, + "civil_daylight": { + "name": "Civiel daglicht", + "state_attributes": { + "today": {"name": "Vandaag"}, + "today_hms": {"name": "Vandaag umt"}, + "tomorrow": {"name": "Morgen"}, + "tomorrow_hms": {"name": "Morgen umt"}, + "yesterday": {"name": "Gisteren"}, + "yesterday_hms": {"name": "Gisteren umt"} + } + }, + "civil_night": { + "name": "Civiele nacht", + "state_attributes": { + "today": {"name": "Vandaag"}, + "today_hms": {"name": "Vandaag umt"}, + "tomorrow": {"name": "Morgen"}, + "tomorrow_hms": {"name": "Morgen umt"}, + "yesterday": {"name": "Gisteren"}, + "yesterday_hms": {"name": "Gisteren umt"} + } + }, + "daylight": { + "name": "Daglicht", + "state_attributes": { + "today": {"name": "Vandaag"}, + "today_hms": {"name": "Vandaag umt"}, + "tomorrow": {"name": "Morgen"}, + "tomorrow_hms": {"name": "Morgen umt"}, + "yesterday": {"name": "Gisteren"}, + "yesterday_hms": {"name": "Gisteren umt"} + } + }, + "dawn": { + "name": "Dageraad", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "deconz_daylight": { + "name": "deCONZ daglicht", + "state": { + "dawn": "Dageraad", + "dusk": "Schemer", + "golden_hour_1": "Gouden uur 1", + "golden_hour_2": "Gouden uur 2", + "nadir": "Nadir", + "nautical_dawn": "Nautische dageraad", + "nautical_dusk": "Nautische schemer", + "night_end": "Nachteinde", + "night_start": "Nachtbegin", + "solar_noon": "Middagzon", + "sunrise_end": "Zonsopkomsteinde", + "sunrise_start": "Zonsopkomstbegin" + }, + "state_attributes": { + "daylight": {"name": "Daglicht"}, + "next_change": {"name": "Volgende wijziging"} + } + }, + "dusk": { + "name": "Schemer", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "elevation": { + "name": "Hoogtehoek", + "state_attributes": { + "next_change": {"name": "Volgende wijziging"} + } + }, + "elevation_at_time": { + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "max_elevation": { + "name": "Maximale hoogtehoek", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "min_elevation": { + "name": "Minimale hoogtehoek", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "nautical_daylight": { + "name": "Nautisch daglicht", + "state_attributes": { + "today": {"name": "Vandaag"}, + "today_hms": {"name": "Vandaag umt"}, + "tomorrow": {"name": "Morgen"}, + "tomorrow_hms": {"name": "Morgen umt"}, + "yesterday": {"name": "Gisteren"}, + "yesterday_hms": {"name": "Gisteren umt"} + } + }, + "nautical_dawn": { + "name": "Nautische dageraad", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "nautical_dusk": { + "name": "Nautische schemer", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "nautical_night": { + "name": "Nautische nacht", + "state_attributes": { + "today": {"name": "Vandaag"}, + "today_hms": {"name": "Vandaag umt"}, + "tomorrow": {"name": "Morgen"}, + "tomorrow_hms": {"name": "Morgen umt"}, + "yesterday": {"name": "Gisteren"}, + "yesterday_hms": {"name": "Gisteren umt"} + } + }, + "night": { + "name": "Nacht", + "state_attributes": { + "today": {"name": "Vandaag"}, + "today_hms": {"name": "Vandaag umt"}, + "tomorrow": {"name": "Morgen"}, + "tomorrow_hms": {"name": "Morgen umt"}, + "yesterday": {"name": "Gisteren"}, + "yesterday_hms": {"name": "Gisteren umt"} + } + }, + "solar_midnight": { + "name": "Zonne-middernacht", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "solar_noon": { + "name": "Middagzon", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "sun_phase": { + "name": "Fase", + "state": { + "astronomical_twilight": "Astronomische schemering", + "civil_twilight": "Civiele schemering", + "day": "Dag", + "nautical_twilight": "Nautische schemering", + "night": "Nacht" + }, + "state_attributes": { + "blue_hour": {"name": "Blauw uur"}, + "golden_hour": {"name": "Gouden uur"}, + "next_change": {"name": "Volgende wijziging"}, + "rising": {"name": "Zonsopkomst"} + } + }, + "sunrise": { + "name": "Zonsopkomst", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "sunset": { + "name": "Zonsondergang", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + }, + "time_at_elevation": { + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } + } + } + }, + "selector": { + "direction": { + "options": { + "rising": "Opstand", + "setting": "Montuur" + } + }, + "misc": { + "options": { + "above_horizon": "Boven horizon", + "above_neg_elev": "Boven min {elevation} °", + "above_pos_elev": "Boven {elevation} °", + "elevation_at": "Hoogte bij {elev_time}", + "rising_neg_elev": "Stijgend bij min {elevation} °", + "rising_pos_elev": "Stijgend bij {elevation} °", + "service_name": "{location} Zon", + "setting_neg_elev": "Instelling bij min {elevation} °", + "setting_pos_elev": "Instelling bij {elevation} °" + } + } + }, + "services": { + "reload": { + "name": "Herlaadt", + "description": "Herlaadt Zon2 vanuit de YAML-configuratie." + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json index 3eb42b8..7dd9a6f 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "Sun2", - "homeassistant": "2023.3.0" + "homeassistant": "2023.4.0" } diff --git a/info.md b/info.md index 3466f1f..1258977 100644 --- a/info.md +++ b/info.md @@ -2,6 +2,4 @@ Creates sensors that provide information about various sun related events. -For now configuration is done strictly in YAML. -Created entities will appear on the Entities page in the UI. -There will be no entries on the Integrations page in the UI. +Supports configuration via YAML or the UI.