From e0dd1c2ceb23a68d1cbeb88550191d2946191016 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 6 Mar 2024 14:49:32 -0600 Subject: [PATCH] Add elevation option for observer obstruction vs horizon --- custom_components/sun2/__init__.py | 12 +- custom_components/sun2/binary_sensor.py | 7 +- custom_components/sun2/config.py | 140 ++++++++-- custom_components/sun2/config_flow.py | 270 ++++++++++++++++---- custom_components/sun2/const.py | 6 + custom_components/sun2/helpers.py | 110 +++++--- custom_components/sun2/sensor.py | 17 +- custom_components/sun2/translations/en.json | 68 ++++- 8 files changed, 507 insertions(+), 123 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 766ed99..d9a3e8e 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, SIG_HA_LOC_UPDATED -from .helpers import LocData, LocParams, Sun2Data +from .helpers import LocData, Sun2Data PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] _OLD_UNIQUE_ID = re.compile(r"[0-9a-f]{32}-([0-9a-f]{32})") @@ -36,14 +36,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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), - ) - ) + loc_data = LocData.from_hass_config(hass) + cast(Sun2Data, hass.data[DOMAIN]).locations[None] = loc_data return loc_data async def process_config( diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index df402b7..ae53205 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -29,7 +29,6 @@ Num, Sun2Entity, Sun2EntityParams, - get_loc_params, nearest_second, sun2_dev_info, translate, @@ -275,12 +274,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor platform.""" - config = entry.options + options = entry.options async_add_entities( _sensors( - get_loc_params(config), + LocParams.from_entry_options(options), Sun2EntityParams(entry, sun2_dev_info(hass, entry)), - config.get(CONF_BINARY_SENSORS, []), + options.get(CONF_BINARY_SENSORS, []), hass, ), True, diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 01c54d6..4879780 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -1,6 +1,8 @@ """Sun2 config validation.""" from __future__ import annotations +from collections.abc import Mapping +import logging from typing import cast from astral import SunDirection @@ -23,21 +25,23 @@ from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_ABOVE_GROUND, CONF_DIRECTION, + CONF_DISTANCE, CONF_ELEVATION_AT_TIME, + CONF_OBS_ELV, + CONF_RELATIVE_HEIGHT, + CONF_SUNRISE_OBSTRUCTION, + CONF_SUNSET_OBSTRUCTION, CONF_TIME_AT_ELEVATION, DOMAIN, ) from .helpers import init_translations -PACKAGE_MERGE_HINT = "list" +_LOGGER = logging.getLogger(__name__) -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, -} +PACKAGE_MERGE_HINT = "list" +SUN_DIRECTIONS = [dir.lower() for dir in SunDirection.__members__] _SUN2_BINARY_SENSOR_SCHEMA = vol.Schema( { @@ -51,8 +55,9 @@ } ) -ELEVATION_AT_TIME_SCHEMA_BASE = vol.Schema( +_ELEVATION_AT_TIME_SCHEMA = vol.Schema( { + vol.Required(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_ELEVATION_AT_TIME): vol.Any( vol.All(cv.string, cv.entity_domain("input_datetime")), cv.time, @@ -62,14 +67,9 @@ } ) -_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( +_TIME_AT_ELEVATION_SCHEMA = vol.Schema( { + vol.Required(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_TIME_AT_ELEVATION): vol.All( vol.Coerce(float), vol.Range(min=-90, max=90), msg="invalid elevation" ), @@ -79,10 +79,6 @@ } ) -_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.""" @@ -93,15 +89,21 @@ def _sensor(config: ConfigType) -> ConfigType: raise vol.Invalid(f"expected {CONF_ELEVATION_AT_TIME} or {CONF_TIME_AT_ELEVATION}") -_SUN2_LOCATION_CONFIG = vol.Schema( +_SUN2_LOCATION_SCHEMA = vol.Schema( { vol.Required(CONF_UNIQUE_ID): cv.string, vol.Inclusive(CONF_LOCATION, "location"): cv.string, + vol.Inclusive(CONF_LATITUDE, "location"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "location"): cv.longitude, + vol.Inclusive(CONF_TIME_ZONE, "location"): cv.time_zone, + vol.Optional(CONF_ELEVATION): vol.Coerce(float), + vol.Optional(CONF_OBS_ELV): vol.Any( + vol.Coerce(float), dict, msg="expected a float or a dictionary" + ), 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, } ) @@ -119,7 +121,7 @@ def _unique_locations_names(configs: list[dict]) -> list[dict]: vol.Optional(DOMAIN): vol.All( lambda config: config or [], cv.ensure_list, - [_SUN2_LOCATION_CONFIG], + [_SUN2_LOCATION_SCHEMA], _unique_locations_names, ), }, @@ -127,10 +129,102 @@ def _unique_locations_names(configs: list[dict]) -> list[dict]: ) +_OBSTRUCTION_CONFIG = { + vol.Required(CONF_DISTANCE): vol.Coerce(float), + vol.Required(CONF_RELATIVE_HEIGHT): vol.Coerce(float), +} +_OBS_ELV_DICT = { + vol.Optional(CONF_ABOVE_GROUND): vol.Coerce(float), + vol.Optional(CONF_SUNRISE_OBSTRUCTION): _OBSTRUCTION_CONFIG, + vol.Optional(CONF_SUNSET_OBSTRUCTION): _OBSTRUCTION_CONFIG, +} +_OBS_ELV_KEYS = [option.schema for option in _OBS_ELV_DICT] +_OBS_ELV_INVALID_LEN_MSG = f"use exactly two of: {', '.join(_OBS_ELV_KEYS)}" +_OBS_ELV_DICT_SCHEMA = vol.All( + vol.Schema(_OBS_ELV_DICT), vol.Length(2, 2, msg=_OBS_ELV_INVALID_LEN_MSG) +) + + +def _obs_elv( + obstruction: Mapping[str, float] | None, above_ground: float | None +) -> float | list[float]: + """Determine observer elevation from obstruction or elevation above ground level.""" + if obstruction: + return [obstruction[CONF_RELATIVE_HEIGHT], obstruction[CONF_DISTANCE]] + assert above_ground is not None + return above_ground + + +def _validate_and_convert_observer( + loc_config: ConfigType, idx: int, home_elevation: int +) -> None: + """Validate observer elevation option in location config. + + If deprecated elevation option is present, warn or raise exception, + but leave as-is (i.e., do not convert to observer elevation option.) + Just continue to use elevation option until user replaces deprecated + option with new option. + + Otherwise, convert to list[float | list[float]] where + list[0] is east (sunrise) observer_elevation, + list[1] is west (sunset) observer_elevation, + observer_elevation is float or list[float] where + float is elevation above ground level or + list[0] is height of obstruction relative to observer + list[1] is distance to obstruction from observer + """ + east_obs_elv: float | list[float] + west_obs_elv: float | list[float] + + try: + if CONF_ELEVATION in loc_config: + cv.has_at_most_one_key(CONF_ELEVATION, CONF_OBS_ELV)(loc_config) + # Pass in copy of config so elevation option does not get removed. + cv.deprecated(CONF_ELEVATION, CONF_OBS_ELV)(dict(loc_config)) + return + + if CONF_OBS_ELV not in loc_config: + # TODO: Make this a repair issue??? + _LOGGER.warning( + "New config option %s missing @ data[%s][%i], " + "will use system general elevation setting", + CONF_OBS_ELV, + DOMAIN, + idx, + ) + east_obs_elv = west_obs_elv = float(home_elevation) + + elif isinstance(obs := loc_config[CONF_OBS_ELV], float): + east_obs_elv = west_obs_elv = obs + + else: + try: + _OBS_ELV_DICT_SCHEMA(obs) + except vol.Invalid as err: + err.prepend([CONF_OBS_ELV]) + raise + above_ground = obs.get(CONF_ABOVE_GROUND) + east_obs_elv = _obs_elv(obs.get(CONF_SUNRISE_OBSTRUCTION), above_ground) + west_obs_elv = _obs_elv(obs.get(CONF_SUNSET_OBSTRUCTION), above_ground) + + except vol.Invalid as err: + err.prepend([DOMAIN, idx]) + raise + + loc_config[CONF_OBS_ELV] = [east_obs_elv, west_obs_elv] + + async def async_validate_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: """Validate configuration.""" await init_translations(hass) - return cast(ConfigType, _SUN2_CONFIG_SCHEMA(config)) + config = _SUN2_CONFIG_SCHEMA(config) + if DOMAIN not in config: + return config + + home_elevation = hass.config.elevation + for idx, loc_config in enumerate(config[DOMAIN]): + _validate_and_convert_observer(loc_config, idx, home_elevation) + return config diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index dec5c02..64bf07c 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -26,6 +26,8 @@ CONF_SENSORS, CONF_TIME_ZONE, CONF_UNIQUE_ID, + DEGREE, + UnitOfLength, ) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowHandler, FlowResult @@ -49,8 +51,12 @@ from .config import SUN_DIRECTIONS from .const import ( + CONF_ABOVE_GROUND, CONF_DIRECTION, CONF_ELEVATION_AT_TIME, + CONF_OBS_ELV, + CONF_SUNRISE_OBSTRUCTION, + CONF_SUNSET_OBSTRUCTION, CONF_TIME_AT_ELEVATION, DOMAIN, ) @@ -58,6 +64,35 @@ _LOCATION_OPTIONS = [CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_TIME_ZONE] +_DEGREES_SELECTOR = NumberSelector( + NumberSelectorConfig( + min=-90, + max=90, + step="any", + unit_of_measurement=DEGREE, + mode=NumberSelectorMode.BOX, + ) +) +_INPUT_DATETIME_SELECTOR = EntitySelector(EntitySelectorConfig(domain="input_datetime")) +_METERS_SELECTOR = NumberSelector( + NumberSelectorConfig( + step="any", + unit_of_measurement=UnitOfLength.METERS, + mode=NumberSelectorMode.BOX, + ) +) +_POSITIVE_METERS_SELECTOR = NumberSelector( + NumberSelectorConfig( + min=0, + step="any", + unit_of_measurement=UnitOfLength.METERS, + mode=NumberSelectorMode.BOX, + ) +) +_SUN_DIRECTION_SELECTOR = SelectSelector( + SelectSelectorConfig(options=SUN_DIRECTIONS, translation_key="direction") +) + class Sun2Flow(FlowHandler): """Sun2 flow mixin.""" @@ -65,6 +100,11 @@ class Sun2Flow(FlowHandler): _existing_entries: list[ConfigEntry] | None = None _existing_entities: dict[str, str] | None = None + # Temporary variables between steps. + _use_map: bool + _sunrise_obstruction: bool + _sunset_obstruction: bool + @property def _entries(self) -> list[ConfigEntry]: """Get existing config entries.""" @@ -102,48 +142,198 @@ 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_menu( + self, _: dict[str, Any] | None = None + ) -> FlowResult: + """Provide options for how to enter location.""" + menu_options = ["location_map", "location_manual"] + kwargs = {} + if CONF_LATITUDE in self.options: + menu_options.append("observer_elevation") + location = f"{self.options[CONF_LATITUDE]}, {self.options[CONF_LONGITUDE]}" + kwargs["description_placeholders"] = { + "location": location, + "time_zone": self.options[CONF_TIME_ZONE], + } + return self.async_show_menu( + step_id="location_menu", menu_options=menu_options, **kwargs + ) + + async def async_step_location_map( + self, _: dict[str, Any] | None = None + ) -> FlowResult: + """Enter location via a map.""" + self._use_map = True + return await self.async_step_location() + + async def async_step_location_manual( + self, _: dict[str, Any] | None = None + ) -> FlowResult: + """Enter location manually.""" + self._use_map = False + return await self.async_step_location() + async def async_step_location( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle location options.""" + errors: dict[str, str] = {} + 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() - + location: dict[str, Any] | str = user_input.pop(CONF_LOCATION) + if isinstance(location, dict): + user_input[CONF_LATITUDE] = location[CONF_LATITUDE] + user_input[CONF_LONGITUDE] = location[CONF_LONGITUDE] + else: + try: + lat = lon = "" + with suppress(ValueError): + lat, lon = location.split(",") + lat = lat.strip() + lon = lon.strip() + if not lat or not lon: + lat, lon = location.split() + lat = lat.strip() + lon = lon.strip() + user_input[CONF_LATITUDE] = float(lat) + user_input[CONF_LONGITUDE] = float(lon) + except ValueError: + errors[CONF_LOCATION] = "invalid_location" + if not errors: + self.options.update(user_input) + return await self.async_step_observer_elevation() + + location_selector = LocationSelector if self._use_map else TextSelector data_schema = vol.Schema( { - vol.Required(CONF_LOCATION): LocationSelector(), - vol.Required(CONF_ELEVATION): NumberSelector( - NumberSelectorConfig(step="any", mode=NumberSelectorMode.BOX) - ), + vol.Required(CONF_LOCATION): location_selector(), vol.Required(CONF_TIME_ZONE): TextSelector(), } ) + if CONF_LATITUDE in self.options: + time_zone = self.options[CONF_TIME_ZONE] + latitude = self.options[CONF_LATITUDE] + longitude = self.options[CONF_LONGITUDE] + else: + time_zone = self.hass.config.time_zone + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + suggested_values = {CONF_TIME_ZONE: time_zone} + if self._use_map: + suggested_values[CONF_LOCATION] = { + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + } + else: + suggested_values[CONF_LOCATION] = f"{latitude}, {longitude}" + data_schema = self.add_suggested_values_to_schema(data_schema, suggested_values) + + return self.async_show_form( + step_id="location", data_schema=data_schema, errors=errors, last_step=False + ) + + async def async_step_observer_elevation( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle observer elevation options.""" + if user_input is not None: + self._sunrise_obstruction = user_input[CONF_SUNRISE_OBSTRUCTION] + self._sunset_obstruction = user_input[CONF_SUNSET_OBSTRUCTION] + return await self.async_step_obs_elv_values() + + data_schema = vol.Schema( + { + vol.Required(CONF_SUNRISE_OBSTRUCTION): BooleanSelector(), + vol.Required(CONF_SUNSET_OBSTRUCTION): BooleanSelector(), + } + ) + + if obs_elv := self.options.get(CONF_OBS_ELV): 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], + CONF_SUNRISE_OBSTRUCTION: isinstance(obs_elv[0], list), + CONF_SUNSET_OBSTRUCTION: isinstance(obs_elv[1], list), } 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, + CONF_SUNRISE_OBSTRUCTION: False, + CONF_SUNSET_OBSTRUCTION: False, } 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 + step_id="observer_elevation", data_schema=data_schema, last_step=False + ) + + async def async_step_obs_elv_values( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle observer elevation option values.""" + get_above_ground = not self._sunrise_obstruction or not self._sunset_obstruction + + if user_input is not None: + above_ground = user_input.get(CONF_ABOVE_GROUND, 0) + if self._sunrise_obstruction: + sunrise_obs_elv = [ + user_input["sunrise_relative_height"], + user_input["sunrise_distance"], + ] + else: + sunrise_obs_elv = above_ground + if self._sunset_obstruction: + sunset_obs_elv = [ + user_input["sunset_relative_height"], + user_input["sunset_distance"], + ] + else: + sunset_obs_elv = above_ground + self.options[CONF_OBS_ELV] = [sunrise_obs_elv, sunset_obs_elv] + # For backwards compatibility, add elevation to options if necessary. + if CONF_ELEVATION not in self.options: + self.options[CONF_ELEVATION] = above_ground + return await self.async_step_entities_menu() + + schema: dict[str, Any] = {} + if get_above_ground: + schema[vol.Required(CONF_ABOVE_GROUND)] = _POSITIVE_METERS_SELECTOR + if self._sunrise_obstruction: + schema[vol.Required("sunrise_distance")] = _POSITIVE_METERS_SELECTOR + schema[vol.Required("sunrise_relative_height")] = _METERS_SELECTOR + if self._sunset_obstruction: + schema[vol.Required("sunset_distance")] = _POSITIVE_METERS_SELECTOR + schema[vol.Required("sunset_relative_height")] = _METERS_SELECTOR + data_schema = vol.Schema(schema) + + above_ground = 0.0 + sunrise_distance = 1000.0 + sunrise_relative_height = 1000.0 + sunset_distance = 1000.0 + sunset_relative_height = 1000.0 + if obs_elv := self.options.get(CONF_OBS_ELV): + if isinstance(obs_elv[0], float): + above_ground = obs_elv[0] + else: + sunrise_relative_height, sunrise_distance = obs_elv[0] + if isinstance(obs_elv[1], float): + # If both directions use above_ground, they should be the same. + # Assume this is true and don't bother checking here. + above_ground = obs_elv[1] + else: + sunset_relative_height, sunset_distance = obs_elv[1] + suggested_values: dict[str, Any] = {} + if get_above_ground: + suggested_values[CONF_ABOVE_GROUND] = above_ground + if self._sunrise_obstruction: + suggested_values["sunrise_distance"] = sunrise_distance + suggested_values["sunrise_relative_height"] = sunrise_relative_height + if self._sunset_obstruction: + suggested_values["sunset_distance"] = sunset_distance + suggested_values["sunset_relative_height"] = sunset_relative_height + data_schema = self.add_suggested_values_to_schema(data_schema, suggested_values) + + return self.async_show_form( + step_id="obs_elv_values", data_schema=data_schema, last_step=False ) async def async_step_entities_menu( @@ -197,17 +387,15 @@ async def async_step_elevation_binary_sensor_2( data_schema = vol.Schema( { - vol.Required(CONF_ELEVATION): NumberSelector( - NumberSelectorConfig( - min=-90, max=90, step="any", mode=NumberSelectorMode.BOX - ) - ), + vol.Required(CONF_ELEVATION): _DEGREES_SELECTOR, 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, @@ -235,9 +423,7 @@ async def async_step_elevation_at_time_sensor_entity( data_schema = vol.Schema( { - vol.Required(CONF_ELEVATION_AT_TIME): EntitySelector( - EntitySelectorConfig(domain="input_datetime") - ), + vol.Required(CONF_ELEVATION_AT_TIME): _INPUT_DATETIME_SELECTOR, vol.Optional(CONF_NAME): TextSelector(), } ) @@ -275,23 +461,17 @@ async def async_step_time_at_elevation_sensor( 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.Required(CONF_TIME_AT_ELEVATION): _DEGREES_SELECTOR, + vol.Required(CONF_DIRECTION): _SUN_DIRECTION_SELECTOR, 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, @@ -365,7 +545,9 @@ 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" + "location_menu" + if CONF_LATITUDE in config_entry.options + else "entities_menu" ) return flow @@ -430,14 +612,16 @@ async def async_step_location_name( 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() + return await self.async_step_location_menu() 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, diff --git a/custom_components/sun2/const.py b/custom_components/sun2/const.py index 9ba40b3..9e91353 100644 --- a/custom_components/sun2/const.py +++ b/custom_components/sun2/const.py @@ -17,8 +17,14 @@ LOGGER = logging.getLogger(__package__) +CONF_ABOVE_GROUND = "above_ground" CONF_DIRECTION = "direction" +CONF_DISTANCE = "distance" CONF_ELEVATION_AT_TIME = "elevation_at_time" +CONF_OBS_ELV = "observer_elevation" +CONF_RELATIVE_HEIGHT = "relative_height" +CONF_SUNRISE_OBSTRUCTION = "sunrise_obstruction" +CONF_SUNSET_OBSTRUCTION = "sunset_obstruction" CONF_TIME_AT_ELEVATION = "time_at_elevation" ATTR_BLUE_HOUR = "blue_hour" diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 2cbbb13..3899d4f 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -5,7 +5,8 @@ from collections.abc import Mapping from dataclasses import dataclass, field from datetime import date, datetime, time, timedelta, tzinfo -from typing import Any, TypeVar, Union, cast +from math import copysign, fabs +from typing import Any, Self, Union, cast from astral import LocationInfo from astral.location import Location @@ -38,6 +39,7 @@ ATTR_TOMORROW_HMS, ATTR_YESTERDAY, ATTR_YESTERDAY_HMS, + CONF_OBS_ELV, DOMAIN, ONE_DAY, SIG_HA_LOC_UPDATED, @@ -50,26 +52,44 @@ class LocParams: """Location parameters.""" - elevation: Num latitude: float longitude: float time_zone: str + @classmethod + def from_entry_options(cls, options: Mapping[str, Any]) -> Self | None: + """Initialize from configuration entry options.""" + try: + return cls( + options[CONF_LATITUDE], + options[CONF_LONGITUDE], + options[CONF_TIME_ZONE], + ) + except KeyError: + return None + @dataclass(frozen=True) class LocData: """Location data.""" loc: Location - elv: Num tzi: tzinfo - def __init__(self, lp: LocParams) -> None: - """Initialize location data from location parameters.""" - loc = Location(LocationInfo("", "", lp.time_zone, lp.latitude, lp.longitude)) - object.__setattr__(self, "loc", loc) - object.__setattr__(self, "elv", lp.elevation) - object.__setattr__(self, "tzi", dt_util.get_time_zone(lp.time_zone)) + @classmethod + def from_loc_params(cls, lp: LocParams) -> Self: + """Initialize from LocParams.""" + tzi = dt_util.get_time_zone(tz := lp.time_zone) + assert tzi + return cls(Location(LocationInfo("", "", tz, lp.latitude, lp.longitude)), tzi) + + @classmethod + def from_hass_config(cls, hass: HomeAssistant) -> Self: + """Initialize from HA configuration.""" + hc = hass.config + tzi = dt_util.get_time_zone(tz := hc.time_zone) + assert tzi + return cls(Location(LocationInfo("", "", tz, hc.latitude, hc.longitude)), tzi) @dataclass @@ -81,19 +101,6 @@ class Sun2Data: language: str | None = None -def get_loc_params(config: Mapping[str, Any]) -> LocParams | None: - """Get location parameters from configuration.""" - try: - return LocParams( - config[CONF_ELEVATION], - config[CONF_LATITUDE], - config[CONF_LONGITUDE], - config[CONF_TIME_ZONE], - ) - except KeyError: - return None - - def hours_to_hms(hours: Num | None) -> str | None: """Convert hours to HH:MM:SS string.""" try: @@ -140,9 +147,6 @@ def sun2_dev_info(hass: HomeAssistant, entry: ConfigEntry) -> DeviceInfo: ) -_Num = TypeVar("_Num", bound=Num) - - def nearest_second(dttm: datetime) -> datetime: """Round dttm to nearest second.""" return dttm.replace(microsecond=0) + timedelta( @@ -161,7 +165,7 @@ class Sun2EntityParams: entry: ConfigEntry device_info: DeviceInfo - unique_id: str | None = None + unique_id: str = "" class Sun2Entity(Entity): @@ -195,8 +199,37 @@ def __init__( self._attr_unique_id = sun2_entity_params.unique_id self._attr_device_info = sun2_entity_params.device_info self._loc_params = loc_params + options = sun2_entity_params.entry.options + if obs_elv := options.get(CONF_OBS_ELV): + east_obs_elv, west_obs_elv = obs_elv + self._east_obs_elv = self._obs_elv_cfg_2_astral(east_obs_elv) + self._west_obs_elv = self._obs_elv_cfg_2_astral(west_obs_elv) + else: + self._east_obs_elv = self._west_obs_elv = options.get(CONF_ELEVATION, 0) self.async_on_remove(self._cancel_update) + @staticmethod + def _obs_elv_cfg_2_astral( + obs_elv: float | list[float], + ) -> float | tuple[float, float]: + """Convert value stored in config entry to astral observer_elevation param. + + When sun event is affected by an obstruction, the astral package says to pass + a tuple of floats in the observer_elevaton parameter, where the first element is + the relative height from the observer to the obstruction (which may be negative) + and the second element is the horizontal distance to the obstruction. + + However, due to a bug (see issue 89), it reverses the values and results in a + sign error. The code below works around that bug. + + Also, astral only accepts a tuple, not a list, which is what stored in the + config entry (since it's from a JSON file), so convert to a tuple. + """ + if isinstance(obs_elv, list): + height, distance = obs_elv + return -copysign(1, height) * distance, fabs(height) + return obs_elv + @property def _sun2_data(self) -> Sun2Data: return cast(Sun2Data, self.hass.data[DOMAIN]) @@ -225,9 +258,12 @@ def _get_loc_data(self) -> LocData: try: loc_data = self._sun2_data.locations[self._loc_params] except KeyError: - loc_data = self._sun2_data.locations[self._loc_params] = LocData( - cast(LocParams, self._loc_params) - ) + # LocData from HA's config will always be in cache, so will not get here if + # self._loc_params is None. + assert self._loc_params + loc_data = self._sun2_data.locations[ + self._loc_params + ] = LocData.from_loc_params(self._loc_params) if not self._loc_params: @@ -260,7 +296,7 @@ def _astral_event( date_or_dttm: date | datetime, event: str | None = None, /, - **kwargs: Mapping[str, Any], + **kwargs: Any, ) -> Any: """Return astral event result.""" if not event: @@ -268,15 +304,23 @@ def _astral_event( loc = self._loc_data.loc if hasattr(self, "_solar_depression"): loc.solar_depression = self._solar_depression + try: if event in ("solar_midnight", "solar_noon"): return getattr(loc, event.split("_")[1])(date_or_dttm) + if event == "time_at_elevation": return loc.time_at_elevation( kwargs["elevation"], date_or_dttm, kwargs["direction"] ) - return getattr(loc, event)( - date_or_dttm, observer_elevation=self._loc_data.elv - ) + + if event in ("sunrise", "dawn"): + kwargs = {"observer_elevation": self._east_obs_elv} + elif event in ("sunset", "dusk"): + kwargs = {"observer_elevation": self._west_obs_elv} + else: + kwargs = {} + return getattr(loc, event)(date_or_dttm, **kwargs) + except (TypeError, ValueError): return None diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index e27c2fb..03df6d9 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -71,7 +71,6 @@ Num, Sun2Entity, Sun2EntityParams, - get_loc_params, hours_to_hms, nearest_second, next_midnight, @@ -396,10 +395,10 @@ def _astral_event( date_or_dttm: date | datetime, event: str | None = None, /, - **kwargs: Mapping[str, Any], + **kwargs: Any, ) -> Any: return super()._astral_event( - date_or_dttm, direction=self._direction, elevation=self._elevation # type: ignore[arg-type] + date_or_dttm, direction=self._direction, elevation=self._elevation ) @@ -467,7 +466,7 @@ def _astral_event( date_or_dttm: date | datetime, event: str | None = None, /, - **kwargs: Mapping[str, Any], + **kwargs: Any, ) -> float | None: """Return astral event result.""" start: datetime | None @@ -512,7 +511,7 @@ def _astral_event( date_or_dttm: date | datetime, event: str | None = None, /, - **kwargs: Mapping[str, Any], + **kwargs: Any, ) -> float | None: """Return astral event result.""" return cast( @@ -893,7 +892,7 @@ def get_est_dttm(offset: timedelta | None = None) -> datetime: self._astral_event( self._cp.mid_date + offset if offset else self._cp.mid_date, "time_at_elevation", - elevation=elev, # type: ignore[arg-type] + elevation=elev, direction=SunDirection.RISING if self._cp.rising else SunDirection.SETTING, @@ -1259,12 +1258,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor platform.""" - config = entry.options + options = entry.options - loc_params = get_loc_params(config) + loc_params = LocParams.from_entry_options(options) 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, options.get(CONF_SENSORS, []), hass) + _sensors(loc_params, sun2_entity_params, _SENSOR_TYPES.keys(), hass), True, ) diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index e6540e6..2e7d2d5 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -56,7 +56,6 @@ "location": { "title": "Location Options", "data": { - "elevation": "Elevation above ground level", "location": "Location", "time_zone": "Time zone" }, @@ -64,12 +63,42 @@ "time_zone": "See the \"TZ identifier\" column at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List." } }, + "location_menu": { + "title": "Location Entry Method", + "menu_options": { + "location_manual": "Manually enter latitude, longitude", + "location_map": "Use a map to specify location" + } + }, "location_name": { "title": "Location Name", "data": { "name": "Name" } }, + "observer_elevation": { + "title": "Observer Elevation", + "description": "For each direction, choose if sun events are affected by an obstruction, such as a mountain.", + "data": { + "sunrise_obstruction": "Sunrise & dawn are affected by an obstruction", + "sunset_obstruction": "Sunset & dusk are affected by an obstruction" + } + }, + "obs_elv_values": { + "title": "Observer Elevation Values", + "data": { + "above_ground": "Elevation", + "sunrise_distance": "Distance to sunrise obstruction", + "sunrise_relative_height": "Relative height of sunrise obstruction", + "sunset_distance": "Distance to sunset obstruction", + "sunset_relative_height": "Relative height of sunset obstruction" + }, + "data_description": { + "above_ground": "Your elevation above GROUND level (not SEA level).", + "sunrise_relative_height": "Height relative to yourself, which may be negative if obstruction is below you.", + "sunset_relative_height": "Height relative to yourself, which may be negative if obstruction is below you." + } + }, "time_at_elevation_sensor": { "title": "Time at Elevation Sensor Options", "data": { @@ -86,6 +115,7 @@ } }, "error": { + "invalid_location": "Invalid location value. Enter as: latitude, longitude", "name_used": "Location name has already been used." } }, @@ -146,7 +176,6 @@ "location": { "title": "Location Options", "data": { - "elevation": "Elevation above ground level", "location": "Location", "time_zone": "Time zone" }, @@ -154,6 +183,38 @@ "time_zone": "See the \"TZ identifier\" column at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List." } }, + "location_menu": { + "title": "Location Entry Method", + "description": "Current location: {location}\nCurrent time zone: {time_zone}", + "menu_options": { + "observer_elevation": "Keep current settings", + "location_manual": "Manually enter latitude, longitude", + "location_map": "Use a map to specify location" + } + }, + "observer_elevation": { + "title": "Observer Elevation", + "description": "For each direction, choose if sun events are affected by an obstruction, such as a mountain.", + "data": { + "sunrise_obstruction": "Sunrise & dawn are affected by an obstruction", + "sunset_obstruction": "Sunset & dusk are affected by an obstruction" + } + }, + "obs_elv_values": { + "title": "Observer Elevation Values", + "data": { + "above_ground": "Elevation", + "sunrise_distance": "Distance to sunrise obstruction", + "sunrise_relative_height": "Relative height of sunrise obstruction", + "sunset_distance": "Distance to sunset obstruction", + "sunset_relative_height": "Relative height of sunset obstruction" + }, + "data_description": { + "above_ground": "Your elevation above GROUND level (not SEA level).", + "sunrise_relative_height": "Height relative to yourself, which may be negative if obstruction is below you.", + "sunset_relative_height": "Height relative to yourself, which may be negative if obstruction is below you." + } + }, "remove_entities": { "title": "Remove Entities", "data": { @@ -174,6 +235,9 @@ "use_home": "Use Home Assistant name and location" } } + }, + "error": { + "invalid_location": "Invalid location value. Enter as: latitude, longitude" } }, "entity": {