From 12b6dd5245634995af357532242c2496ecbc59eb Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 7 Nov 2023 11:27:46 -0600 Subject: [PATCH 01/59] Config flow initial implementation --- custom_components/sun2/__init__.py | 58 +++++++++++++++++++++++++ custom_components/sun2/binary_sensor.py | 14 ++++-- custom_components/sun2/manifest.json | 1 + custom_components/sun2/sensor.py | 22 ++++++---- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 28f5e4e..e6436db 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -1 +1,59 @@ """Sun2 integration.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + CONF_BINARY_SENSORS, + CONF_NAME, + CONF_SENSORS, +) +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .binary_sensor import SUN2_BINARY_SENSOR_SCHEMA +from .const import DOMAIN, LOGGER +from .helpers import LOC_PARAMS +from .sensor import SUN2_SENSOR_SCHEMA + + +def _unique_names(configs: list[dict]) -> list[dict]: + """Check that names are unique.""" + names = [config.get(CONF_NAME) for config in configs] + if len(names) != len(set(names)): + raise vol.Invalid("Names must be unique") + return configs + + +SUN2_CONFIG = vol.All( + vol.Schema( + { + vol.Optional(CONF_NAME): 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, [SUN2_SENSOR_SCHEMA] + ), + **LOC_PARAMS, + } + ), + cv.has_at_least_one_key(CONF_BINARY_SENSORS, CONF_SENSORS), +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN, default=list): vol.All( + cv.ensure_list, [SUN2_CONFIG], _unique_names + ), + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Setup composite integration.""" + LOGGER.debug("%s", config) + + return True diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 1a844e6..cb2b6e9 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -19,6 +19,7 @@ CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS, CONF_NAME, + CONF_PLATFORM, ) from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -27,7 +28,7 @@ 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 .const import ATTR_NEXT_CHANGE, DOMAIN, LOGGER, MAX_ERR_BIN, ONE_DAY, ONE_SEC, SUNSET_ELEV from .helpers import ( LOC_PARAMS, LocParams, @@ -83,7 +84,7 @@ def _val_cfg(config: str | ConfigType) -> ConfigType: return config -_BINARY_SENSOR_SCHEMA = vol.All( +SUN2_BINARY_SENSOR_SCHEMA = vol.All( vol.Any( vol.In(_SENSOR_TYPES), vol.Schema( @@ -106,7 +107,7 @@ def _val_cfg(config: str | ConfigType) -> ConfigType: PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [_BINARY_SENSOR_SCHEMA] + cv.ensure_list, [SUN2_BINARY_SENSOR_SCHEMA] ), **LOC_PARAMS, } @@ -316,6 +317,13 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up sensors.""" + LOGGER.warning( + "%s: %s under %s is deprecated. Move to %s: ...", + CONF_PLATFORM, + DOMAIN, + BINARY_SENSOR_DOMAIN, + DOMAIN, + ) loc_params = get_loc_params(config) namespace = config.get(CONF_ENTITY_NAMESPACE) sensors = [] diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index 3930edf..b1e0172 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -1,6 +1,7 @@ { "domain": "sun2", "name": "Sun2", + "config_flow": false, "codeowners": ["@pnbruckner"], "dependencies": [], "documentation": "https://github.com/pnbruckner/ha-sun2/blob/master/README.md", diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 7d278c4..328d265 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -28,6 +28,7 @@ CONF_ICON, CONF_MONITORED_CONDITIONS, CONF_NAME, + CONF_PLATFORM, DEGREE, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, @@ -59,6 +60,7 @@ CONF_DIRECTION, CONF_ELEVATION_AT_TIME, CONF_TIME_AT_ELEVATION, + DOMAIN, HALF_DAY, MAX_ERR_ELEV, ELEV_STEP, @@ -1200,17 +1202,14 @@ def _eat_defaults(config: ConfigType) -> ConfigType: _eat_defaults, ) +SUN2_SENSOR_SCHEMA = vol.Any( + TIME_AT_ELEVATION_SCHEMA, ELEVATION_AT_TIME_SCHEMA, vol.In(_SENSOR_TYPES) +) + 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), - ) - ], + cv.ensure_list, [SUN2_SENSOR_SCHEMA] ), **LOC_PARAMS, } @@ -1224,6 +1223,13 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up sensors.""" + LOGGER.warning( + "%s: %s under %s is deprecated. Move to %s: ...", + CONF_PLATFORM, + DOMAIN, + SENSOR_DOMAIN, + DOMAIN, + ) loc_params = get_loc_params(config) namespace = config.get(CONF_ENTITY_NAMESPACE) From e08a0c15b83978f77ebe25690fabdfa17fe7c5c6 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 7 Nov 2023 11:28:06 -0600 Subject: [PATCH 02/59] black --- custom_components/sun2/__init__.py | 4 +--- custom_components/sun2/binary_sensor.py | 10 +++++++++- custom_components/sun2/sensor.py | 6 ++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index e6436db..a08e513 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -33,9 +33,7 @@ def _unique_names(configs: list[dict]) -> list[dict]: vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [SUN2_BINARY_SENSOR_SCHEMA] ), - vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [SUN2_SENSOR_SCHEMA] - ), + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SUN2_SENSOR_SCHEMA]), **LOC_PARAMS, } ), diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index cb2b6e9..f19314c 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -28,7 +28,15 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import ATTR_NEXT_CHANGE, DOMAIN, LOGGER, MAX_ERR_BIN, ONE_DAY, ONE_SEC, SUNSET_ELEV +from .const import ( + ATTR_NEXT_CHANGE, + DOMAIN, + LOGGER, + MAX_ERR_BIN, + ONE_DAY, + ONE_SEC, + SUNSET_ELEV, +) from .helpers import ( LOC_PARAMS, LocParams, diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 328d265..9df3b8e 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -110,7 +110,7 @@ def __init__( 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) self._event = "solar_azimuth" @@ -704,9 +704,7 @@ 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: self._prv_dttm = None From 9139fed332bdf5ef2f8ffe70d7fbc24e64c5d35a Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 8 Nov 2023 16:03:12 -0600 Subject: [PATCH 03/59] Incremental checkin --- custom_components/sun2/__init__.py | 35 +++- custom_components/sun2/binary_sensor.py | 83 +++++++--- custom_components/sun2/config_flow.py | 25 +++ custom_components/sun2/helpers.py | 6 +- custom_components/sun2/manifest.json | 2 +- custom_components/sun2/sensor.py | 207 ++++++++++++++++-------- 6 files changed, 269 insertions(+), 89 deletions(-) create mode 100644 custom_components/sun2/config_flow.py diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index a08e513..c7a680d 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -3,10 +3,13 @@ import voluptuous as vol +from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_NAME, CONF_SENSORS, + CONF_UNIQUE_ID, + Platform, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -26,9 +29,12 @@ def _unique_names(configs: list[dict]) -> list[dict]: return configs +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] + SUN2_CONFIG = vol.All( vol.Schema( { + vol.Required(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [SUN2_BINARY_SENSOR_SCHEMA] @@ -52,6 +58,33 @@ def _unique_names(configs: list[dict]) -> list[dict]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Setup composite integration.""" - LOGGER.debug("%s", config) + regd = { + entry.unique_id: entry.entry_id + for entry in hass.config_entries.async_entries(DOMAIN) + } + cfgs = {cfg[CONF_UNIQUE_ID]: cfg for cfg in config[DOMAIN]} + + for uid in set(regd) - set(cfgs): + await hass.config_entries.async_remove(regd[uid]) + for uid in set(cfgs) - set(regd): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=cfgs[uid] + ) + ) + for uid in set(cfgs) & set(regd): + # TODO: UPDATE + LOGGER.warning("Already configured: %s", uid) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up config entry.""" + 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 f19314c..5fbfa85 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -13,8 +13,10 @@ DOMAIN as BINARY_SENSOR_DOMAIN, PLATFORM_SCHEMA, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ABOVE, + CONF_BINARY_SENSORS, CONF_ELEVATION, CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS, @@ -23,10 +25,11 @@ ) 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 homeassistant.util import dt as dt_util, slugify from .const import ( ATTR_NEXT_CHANGE, @@ -127,19 +130,22 @@ class Sun2ElevationSensor(Sun2Entity, BinarySensorEntity): def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, name: str, above: float, ) -> None: """Initialize sensor.""" - object_id = name - if namespace: - name = f"{namespace} {name}" + if platform_setup: + # Note that entity_platform will add namespace prefix to object ID. + self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{slugify(name)}" + if name_prefix: + name = f"{name_prefix} {name}" self.entity_description = BinarySensorEntityDescription( key=CONF_ELEVATION, name=name ) - super().__init__(loc_params, BINARY_SENSOR_DOMAIN, object_id) + super().__init__(loc_params) self._event = "solar_elevation" self._threshold: float = above @@ -318,6 +324,28 @@ def schedule_update(now: datetime) -> None: self._attr_extra_state_attributes = {ATTR_NEXT_CHANGE: nxt_dttm} +def _sensors( + platform_setup: bool, + loc_params: LocParams | None, + name_prefix: str | None, + sensors_config: list[str | dict], +) -> list[Entity]: + sensors = [] + for config in sensors_config: + if CONF_ELEVATION in config: + options = config[CONF_ELEVATION] + sensors.append( + Sun2ElevationSensor( + platform_setup, + loc_params, + name_prefix, + options[CONF_NAME], + options[CONF_ABOVE], + ) + ) + return sensors + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -332,15 +360,34 @@ async def async_setup_platform( BINARY_SENSOR_DOMAIN, DOMAIN, ) - 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) + + async_add_entities( + _sensors( + True, + 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.data + if not (sensors_config := config.get(CONF_BINARY_SENSORS)): + return + + async_add_entities( + _sensors( + False, + get_loc_params(config), + config.get(CONF_NAME), + sensors_config, + ), + True, + ) diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py new file mode 100644 index 0000000..4d12031 --- /dev/null +++ b/custom_components/sun2/config_flow.py @@ -0,0 +1,25 @@ +"""Config flow for Sun2 integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): + """Sun2 config flow.""" + + VERSION = 1 + + async def async_step_import(self, data: dict[str, Any]) -> FlowResult: + """Import config entry from configuration.""" + unique_id = data.pop(CONF_UNIQUE_ID) + name = data.get(CONF_NAME, self.hass.config.location_name) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=name, data=data) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index a445d6f..d4270a3 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -112,16 +112,12 @@ class Sun2Entity(Entity): _solar_depression: Num | str @abstractmethod - def __init__( - self, loc_params: LocParams | None, domain: str, object_id: str - ) -> None: + def __init__(self, loc_params: LocParams | 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 self._loc_params = loc_params diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index b1e0172..a3d3bc7 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -1,7 +1,7 @@ { "domain": "sun2", "name": "Sun2", - "config_flow": false, + "config_flow": true, "codeowners": ["@pnbruckner"], "dependencies": [], "documentation": "https://github.com/pnbruckner/ha-sun2/blob/master/README.md", diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 9df3b8e..6ead278 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -22,6 +22,7 @@ SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ICON, CONF_ENTITY_NAMESPACE, @@ -29,6 +30,7 @@ CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PLATFORM, + CONF_SENSORS, DEGREE, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, @@ -36,6 +38,7 @@ ) from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback, Event 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, @@ -95,15 +98,19 @@ class Sun2AzimuthSensor(Sun2Entity, SensorEntity): def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, sensor_type: str, icon: str | None, ) -> None: """Initialize sensor.""" + if platform_setup: + # Note that entity_platform will add namespace prefix to object ID. + self.entity_id = f"{SENSOR_DOMAIN}.{slugify(sensor_type)}" name = sensor_type.replace("_", " ").title() - if namespace: - name = f"{namespace} {name}" + if name_prefix: + name = f"{name_prefix} {name}" self.entity_description = SensorEntityDescription( key=sensor_type, icon=icon, @@ -112,7 +119,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ) - super().__init__(loc_params, SENSOR_DOMAIN, sensor_type) + super().__init__(loc_params) self._event = "solar_azimuth" def _setup_fixed_updating(self) -> None: @@ -157,8 +164,9 @@ class Sun2SensorEntity(Sun2Entity, SensorEntity, Generic[_T]): @abstractmethod def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, entity_description: SensorEntityDescription, default_solar_depression: Num | str = 0, name: str | None = None, @@ -167,14 +175,14 @@ def __init__( key = entity_description.key if name is None: name = key.replace("_", " ").title() - object_id = key - else: - object_id = slugify(name) - if namespace: - name = f"{namespace} {name}" + if platform_setup: + # Note that entity_platform will add namespace prefix to object ID. + self.entity_id = f"{SENSOR_DOMAIN}.{slugify(name)}" + if name_prefix: + name = f"{name_prefix} {name}" entity_description.name = name self.entity_description = entity_description - super().__init__(loc_params, SENSOR_DOMAIN, object_id) + super().__init__(loc_params) if any(key.startswith(sol_dep + "_") for sol_dep in _SOLAR_DEPRESSIONS): self._solar_depression, self._event = key.rsplit("_", 1) @@ -233,10 +241,11 @@ class Sun2ElevationAtTimeSensor(Sun2SensorEntity[float]): def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, - at_time: str | time, + name_prefix: str | None, name: str, + at_time: str | time, ) -> None: """Initialize sensor.""" if isinstance(at_time, str): @@ -250,7 +259,9 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ) - super().__init__(loc_params, namespace, entity_description, name=name) + super().__init__( + platform_setup, loc_params, name_prefix, entity_description, name=name + ) self._event = "solar_elevation" @property @@ -350,8 +361,9 @@ class Sun2PointInTimeSensor(Sun2SensorEntity[Union[datetime, str]]): def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, sensor_type: str, icon: str | None, name: str | None = None, @@ -362,7 +374,9 @@ def __init__( device_class=SensorDeviceClass.TIMESTAMP, icon=icon, ) - super().__init__(loc_params, namespace, entity_description, "civil", name) + super().__init__( + platform_setup, loc_params, name_prefix, entity_description, "civil", name + ) class Sun2TimeAtElevationSensor(Sun2PointInTimeSensor): @@ -370,17 +384,20 @@ class Sun2TimeAtElevationSensor(Sun2PointInTimeSensor): def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, + name: str, icon: str | None, direction: SunDirection, elevation: float, - name: str, ) -> None: """Initialize sensor.""" self._direction = direction self._elevation = elevation - super().__init__(loc_params, namespace, "time_at_elevation", icon, name) + super().__init__( + platform_setup, loc_params, name_prefix, "time_at_elevation", icon, name + ) def _astral_event( self, @@ -399,8 +416,9 @@ class Sun2PeriodOfTimeSensor(Sun2SensorEntity[float]): def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, sensor_type: str, icon: str | None, ) -> None: @@ -412,7 +430,9 @@ def __init__( native_unit_of_measurement=UnitOfTime.HOURS, suggested_display_precision=3, ) - super().__init__(loc_params, namespace, entity_description, SUN_APPARENT_RADIUS) + super().__init__( + platform_setup, loc_params, name_prefix, entity_description, SUN_APPARENT_RADIUS + ) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -453,8 +473,9 @@ class Sun2MinMaxElevationSensor(Sun2SensorEntity[float]): def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, sensor_type: str, icon: str | None, ) -> None: @@ -466,7 +487,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=3, ) - super().__init__(loc_params, namespace, entity_description) + super().__init__(platform_setup, loc_params, name_prefix, entity_description) self._event = { "min_elevation": "solar_midnight", "max_elevation": "solar_noon", @@ -517,14 +538,19 @@ class Sun2CPSensorEntity(Sun2SensorEntity[_T]): @abstractmethod def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, entity_description: SensorEntityDescription, default_solar_depression: Num | str = 0, ) -> None: """Initialize sensor.""" super().__init__( - loc_params, namespace, entity_description, default_solar_depression + platform_setup, + loc_params, + name_prefix, + entity_description, + default_solar_depression, ) self._event = "solar_elevation" @@ -682,8 +708,9 @@ class Sun2ElevationSensor(Sun2CPSensorEntity[float]): def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, sensor_type: str, icon: str | None, ) -> None: @@ -695,7 +722,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ) - super().__init__(loc_params, namespace, entity_description) + super().__init__(platform_setup, loc_params, name_prefix, entity_description) def _update(self, cur_dttm: datetime) -> None: """Update state.""" @@ -775,8 +802,9 @@ class Sun2PhaseSensorBase(Sun2CPSensorEntity[str]): @abstractmethod def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, sensor_type: str, icon: str | None, phase_data: PhaseData, @@ -792,7 +820,7 @@ def __init__( icon=icon, options=options, ) - super().__init__(loc_params, namespace, entity_description) + super().__init__(platform_setup, loc_params, name_prefix, entity_description) self._d = phase_data self._updates: list[Update] = [] @@ -965,8 +993,9 @@ class Sun2PhaseSensor(Sun2PhaseSensorBase): def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, sensor_type: str, icon: str | None, ) -> None: @@ -993,8 +1022,9 @@ def __init__( ) )[::-1] super().__init__( + platform_setup, loc_params, - namespace, + name_prefix, sensor_type, icon, PhaseData(rising_elevs, rising_states, falling_elevs, falling_states), @@ -1022,8 +1052,9 @@ class Sun2DeconzDaylightSensor(Sun2PhaseSensorBase): def __init__( self, + platform_setup: bool, loc_params: LocParams | None, - namespace: str | None, + name_prefix: str | None, sensor_type: str, icon: str | None, ) -> None: @@ -1057,8 +1088,9 @@ def __init__( ) )[::-1] super().__init__( + platform_setup, loc_params, - namespace, + name_prefix, sensor_type, icon, PhaseData(rising_elevs, rising_states, falling_elevs, falling_states), @@ -1214,50 +1246,97 @@ def _eat_defaults(config: ConfigType) -> ConfigType: ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up sensors.""" - LOGGER.warning( - "%s: %s under %s is deprecated. Move to %s: ...", - CONF_PLATFORM, - DOMAIN, - SENSOR_DOMAIN, - DOMAIN, - ) - loc_params = get_loc_params(config) - namespace = config.get(CONF_ENTITY_NAMESPACE) - +def _sensors( + platform_setup: bool, + loc_params: LocParams | None, + name_prefix: str | None, + sensors_config: list[str | dict], +) -> list[Entity]: sensors = [] - for sensor in config[CONF_MONITORED_CONDITIONS]: - if isinstance(sensor, str): + for config in sensors_config: + if isinstance(config, str): sensors.append( - _SENSOR_TYPES[sensor].cls( - loc_params, namespace, sensor, _SENSOR_TYPES[sensor].icon + _SENSOR_TYPES[config].cls( + platform_setup, + loc_params, + name_prefix, + config, + _SENSOR_TYPES[config].icon, ) ) - elif CONF_TIME_AT_ELEVATION in sensor: + elif CONF_TIME_AT_ELEVATION in config: sensors.append( Sun2TimeAtElevationSensor( + platform_setup, loc_params, - namespace, - sensor[CONF_ICON], - sensor[CONF_DIRECTION], - sensor[CONF_TIME_AT_ELEVATION], - sensor[CONF_NAME], + name_prefix, + config[CONF_NAME], + config[CONF_ICON], + config[CONF_DIRECTION], + config[CONF_TIME_AT_ELEVATION], ) ) else: + # 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( + platform_setup, loc_params, - namespace, - sensor[CONF_ELEVATION_AT_TIME], - sensor[CONF_NAME], + name_prefix, + config[CONF_NAME], + at_time, ) ) + return sensors - async_add_entities(sensors, True) + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up sensors.""" + LOGGER.warning( + "%s: %s under %s is deprecated. Move to %s: ...", + CONF_PLATFORM, + DOMAIN, + SENSOR_DOMAIN, + DOMAIN, + ) + + async_add_entities( + _sensors( + True, + 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.data + if not (sensors_config := config.get(CONF_SENSORS)): + return + + async_add_entities( + _sensors( + False, + get_loc_params(config), + config.get(CONF_NAME), + sensors_config, + ), + True, + ) From 134ee7e554c207d6c9aeeb28d2591ff6df60bcae Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 9 Nov 2023 13:48:55 -0600 Subject: [PATCH 04/59] Move settings from config data to options --- custom_components/sun2/binary_sensor.py | 2 +- custom_components/sun2/config_flow.py | 2 +- custom_components/sun2/sensor.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 5fbfa85..86dce5b 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -378,7 +378,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor platform.""" - config = entry.data + config = entry.options if not (sensors_config := config.get(CONF_BINARY_SENSORS)): return diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index 4d12031..1539db2 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -22,4 +22,4 @@ async def async_step_import(self, data: dict[str, Any]) -> FlowResult: await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - return self.async_create_entry(title=name, data=data) + return self.async_create_entry(title=name, data={}, options=data) diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 6ead278..74c113b 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -1327,7 +1327,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor platform.""" - config = entry.data + config = entry.options if not (sensors_config := config.get(CONF_SENSORS)): return From 7552a4bc0ec372a012b60a9bc69d006477202300 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 9 Nov 2023 16:54:34 -0600 Subject: [PATCH 05/59] Add service via _attr_device_info --- custom_components/sun2/binary_sensor.py | 13 +++-- custom_components/sun2/helpers.py | 14 +++++- custom_components/sun2/sensor.py | 63 ++++++++++++------------- 3 files changed, 50 insertions(+), 40 deletions(-) diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 86dce5b..9a72803 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -130,14 +130,14 @@ class Sun2ElevationSensor(Sun2Entity, BinarySensorEntity): def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, name: str, above: float, ) -> None: """Initialize sensor.""" - if platform_setup: + if not entry: # Note that entity_platform will add namespace prefix to object ID. self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{slugify(name)}" if name_prefix: @@ -145,7 +145,7 @@ def __init__( self.entity_description = BinarySensorEntityDescription( key=CONF_ELEVATION, name=name ) - super().__init__(loc_params) + super().__init__(loc_params, entry) self._event = "solar_elevation" self._threshold: float = above @@ -325,10 +325,10 @@ def schedule_update(now: datetime) -> None: def _sensors( - platform_setup: bool, loc_params: LocParams | None, name_prefix: str | None, sensors_config: list[str | dict], + entry: ConfigEntry | None = None, ) -> list[Entity]: sensors = [] for config in sensors_config: @@ -336,7 +336,7 @@ def _sensors( options = config[CONF_ELEVATION] sensors.append( Sun2ElevationSensor( - platform_setup, + entry, loc_params, name_prefix, options[CONF_NAME], @@ -363,7 +363,6 @@ async def async_setup_platform( async_add_entities( _sensors( - True, get_loc_params(config), config.get(CONF_ENTITY_NAMESPACE), config[CONF_MONITORED_CONDITIONS], @@ -384,10 +383,10 @@ async def async_setup_entry( async_add_entities( _sensors( - False, get_loc_params(config), config.get(CONF_NAME), sensors_config, + entry, ), True, ) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index d4270a3..5cebc21 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -11,6 +11,7 @@ from astral.location import Location import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ELEVATION, CONF_LATITUDE, @@ -20,6 +21,7 @@ ) from homeassistant.core import CALLBACK_TYPE, Event from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, dispatcher_send, @@ -112,7 +114,11 @@ class Sun2Entity(Entity): _solar_depression: Num | str @abstractmethod - def __init__(self, loc_params: LocParams | None) -> None: + def __init__( + self, + loc_params: LocParams | None, + entry: ConfigEntry | None, + ) -> None: """Initialize base class. self.name must be set up to return name before calling this. @@ -120,6 +126,12 @@ def __init__(self, loc_params: LocParams | None) -> None: """ self._attr_unique_id = self.name self._loc_params = loc_params + if entry: + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + ) async def async_update(self) -> None: """Update state.""" diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 74c113b..048742d 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -98,14 +98,14 @@ class Sun2AzimuthSensor(Sun2Entity, SensorEntity): def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, sensor_type: str, icon: str | None, ) -> None: """Initialize sensor.""" - if platform_setup: + if not entry: # Note that entity_platform will add namespace prefix to object ID. self.entity_id = f"{SENSOR_DOMAIN}.{slugify(sensor_type)}" name = sensor_type.replace("_", " ").title() @@ -119,7 +119,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ) - super().__init__(loc_params) + super().__init__(loc_params, entry) self._event = "solar_azimuth" def _setup_fixed_updating(self) -> None: @@ -164,7 +164,7 @@ class Sun2SensorEntity(Sun2Entity, SensorEntity, Generic[_T]): @abstractmethod def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, entity_description: SensorEntityDescription, @@ -175,14 +175,14 @@ def __init__( key = entity_description.key if name is None: name = key.replace("_", " ").title() - if platform_setup: + if not entry: # Note that entity_platform will add namespace prefix to object ID. self.entity_id = f"{SENSOR_DOMAIN}.{slugify(name)}" if name_prefix: name = f"{name_prefix} {name}" entity_description.name = name self.entity_description = entity_description - super().__init__(loc_params) + super().__init__(loc_params, entry) if any(key.startswith(sol_dep + "_") for sol_dep in _SOLAR_DEPRESSIONS): self._solar_depression, self._event = key.rsplit("_", 1) @@ -241,7 +241,7 @@ class Sun2ElevationAtTimeSensor(Sun2SensorEntity[float]): def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, name: str, @@ -260,7 +260,7 @@ def __init__( suggested_display_precision=2, ) super().__init__( - platform_setup, loc_params, name_prefix, entity_description, name=name + entry, loc_params, name_prefix, entity_description, name=name ) self._event = "solar_elevation" @@ -361,7 +361,7 @@ class Sun2PointInTimeSensor(Sun2SensorEntity[Union[datetime, str]]): def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, sensor_type: str, @@ -375,7 +375,7 @@ def __init__( icon=icon, ) super().__init__( - platform_setup, loc_params, name_prefix, entity_description, "civil", name + entry, loc_params, name_prefix, entity_description, "civil", name ) @@ -384,7 +384,7 @@ class Sun2TimeAtElevationSensor(Sun2PointInTimeSensor): def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, name: str, @@ -396,7 +396,7 @@ def __init__( self._direction = direction self._elevation = elevation super().__init__( - platform_setup, loc_params, name_prefix, "time_at_elevation", icon, name + entry, loc_params, name_prefix, "time_at_elevation", icon, name ) def _astral_event( @@ -416,7 +416,7 @@ class Sun2PeriodOfTimeSensor(Sun2SensorEntity[float]): def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, sensor_type: str, @@ -431,7 +431,7 @@ def __init__( suggested_display_precision=3, ) super().__init__( - platform_setup, loc_params, name_prefix, entity_description, SUN_APPARENT_RADIUS + entry, loc_params, name_prefix, entity_description, SUN_APPARENT_RADIUS ) @property @@ -473,7 +473,7 @@ class Sun2MinMaxElevationSensor(Sun2SensorEntity[float]): def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, sensor_type: str, @@ -487,7 +487,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=3, ) - super().__init__(platform_setup, loc_params, name_prefix, entity_description) + super().__init__(entry, loc_params, name_prefix, entity_description) self._event = { "min_elevation": "solar_midnight", "max_elevation": "solar_noon", @@ -538,7 +538,7 @@ class Sun2CPSensorEntity(Sun2SensorEntity[_T]): @abstractmethod def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, entity_description: SensorEntityDescription, @@ -546,7 +546,7 @@ def __init__( ) -> None: """Initialize sensor.""" super().__init__( - platform_setup, + entry, loc_params, name_prefix, entity_description, @@ -708,7 +708,7 @@ class Sun2ElevationSensor(Sun2CPSensorEntity[float]): def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, sensor_type: str, @@ -722,7 +722,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ) - super().__init__(platform_setup, loc_params, name_prefix, entity_description) + super().__init__(entry, loc_params, name_prefix, entity_description) def _update(self, cur_dttm: datetime) -> None: """Update state.""" @@ -802,7 +802,7 @@ class Sun2PhaseSensorBase(Sun2CPSensorEntity[str]): @abstractmethod def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, sensor_type: str, @@ -820,7 +820,7 @@ def __init__( icon=icon, options=options, ) - super().__init__(platform_setup, loc_params, name_prefix, entity_description) + super().__init__(entry, loc_params, name_prefix, entity_description) self._d = phase_data self._updates: list[Update] = [] @@ -993,7 +993,7 @@ class Sun2PhaseSensor(Sun2PhaseSensorBase): def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, sensor_type: str, @@ -1022,7 +1022,7 @@ def __init__( ) )[::-1] super().__init__( - platform_setup, + entry, loc_params, name_prefix, sensor_type, @@ -1052,7 +1052,7 @@ class Sun2DeconzDaylightSensor(Sun2PhaseSensorBase): def __init__( self, - platform_setup: bool, + entry: ConfigEntry | None, loc_params: LocParams | None, name_prefix: str | None, sensor_type: str, @@ -1088,7 +1088,7 @@ def __init__( ) )[::-1] super().__init__( - platform_setup, + entry, loc_params, name_prefix, sensor_type, @@ -1247,17 +1247,17 @@ def _eat_defaults(config: ConfigType) -> ConfigType: def _sensors( - platform_setup: bool, loc_params: LocParams | None, name_prefix: str | None, sensors_config: list[str | dict], + entry: ConfigEntry | None = None, ) -> list[Entity]: sensors = [] for config in sensors_config: if isinstance(config, str): sensors.append( _SENSOR_TYPES[config].cls( - platform_setup, + entry, loc_params, name_prefix, config, @@ -1267,7 +1267,7 @@ def _sensors( elif CONF_TIME_AT_ELEVATION in config: sensors.append( Sun2TimeAtElevationSensor( - platform_setup, + entry, loc_params, name_prefix, config[CONF_NAME], @@ -1285,7 +1285,7 @@ def _sensors( at_time = time.fromisoformat(at_time) sensors.append( Sun2ElevationAtTimeSensor( - platform_setup, + entry, loc_params, name_prefix, config[CONF_NAME], @@ -1312,7 +1312,6 @@ async def async_setup_platform( async_add_entities( _sensors( - True, get_loc_params(config), config.get(CONF_ENTITY_NAMESPACE), config[CONF_MONITORED_CONDITIONS], @@ -1333,10 +1332,10 @@ async def async_setup_entry( async_add_entities( _sensors( - False, get_loc_params(config), config.get(CONF_NAME), sensors_config, + entry, ), True, ) From c0fa25bb9126f754776f28bc02fe6c23e6826511 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 9 Nov 2023 20:51:55 -0600 Subject: [PATCH 06/59] Sort manifest.json --- custom_components/sun2/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index a3d3bc7..134db95 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -1,8 +1,8 @@ { "domain": "sun2", "name": "Sun2", - "config_flow": true, "codeowners": ["@pnbruckner"], + "config_flow": true, "dependencies": [], "documentation": "https://github.com/pnbruckner/ha-sun2/blob/master/README.md", "iot_class": "calculated", From 87c708855f6f08934a8a3d0cf91187535433dcbd Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 9 Nov 2023 21:29:10 -0600 Subject: [PATCH 07/59] Fix 2023.3 compatibility issue --- custom_components/sun2/helpers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 5cebc21..4473718 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -21,7 +21,12 @@ ) from homeassistant.core import CALLBACK_TYPE, Event from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType +# 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 from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, dispatcher_send, From c6e5a75ed1ed61da21cd3397480e18bd2ef7120c Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 11 Nov 2023 11:59:17 -0600 Subject: [PATCH 08/59] Use has_entity_name, sun2 config name is now location --- custom_components/sun2/__init__.py | 14 +-- custom_components/sun2/binary_sensor.py | 27 ++---- custom_components/sun2/config_flow.py | 14 +-- custom_components/sun2/helpers.py | 8 +- custom_components/sun2/sensor.py | 115 ++++++++---------------- 5 files changed, 66 insertions(+), 112 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index c7a680d..8dd1c37 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT from homeassistant.const import ( CONF_BINARY_SENSORS, - CONF_NAME, + CONF_LOCATION, CONF_SENSORS, CONF_UNIQUE_ID, Platform, @@ -21,11 +21,11 @@ from .sensor import SUN2_SENSOR_SCHEMA -def _unique_names(configs: list[dict]) -> list[dict]: - """Check that names are unique.""" - names = [config.get(CONF_NAME) for config in configs] +def _unique_locations(configs: list[dict]) -> list[dict]: + """Check that locations are unique.""" + names = [config.get(CONF_LOCATION) for config in configs] if len(names) != len(set(names)): - raise vol.Invalid("Names must be unique") + raise vol.Invalid(f"{CONF_LOCATION} values must be unique") return configs @@ -35,7 +35,7 @@ def _unique_names(configs: list[dict]) -> list[dict]: vol.Schema( { vol.Required(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_LOCATION): cv.string, vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [SUN2_BINARY_SENSOR_SCHEMA] ), @@ -49,7 +49,7 @@ def _unique_names(configs: list[dict]) -> list[dict]: CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default=list): vol.All( - cv.ensure_list, [SUN2_CONFIG], _unique_names + cv.ensure_list, [SUN2_CONFIG], _unique_locations ), }, extra=vol.ALLOW_EXTRA, diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 9a72803..10aeae3 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -130,22 +130,21 @@ class Sun2ElevationSensor(Sun2Entity, BinarySensorEntity): def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, name: str, above: float, ) -> None: """Initialize sensor.""" - if not entry: + if not isinstance(extra, ConfigEntry): # Note that entity_platform will add namespace prefix to object ID. self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{slugify(name)}" - if name_prefix: - name = f"{name_prefix} {name}" + if extra: + name = f"{extra} {name}" self.entity_description = BinarySensorEntityDescription( key=CONF_ELEVATION, name=name ) - super().__init__(loc_params, entry) + super().__init__(loc_params, extra if isinstance(extra, ConfigEntry) else None) self._event = "solar_elevation" self._threshold: float = above @@ -326,9 +325,8 @@ def schedule_update(now: datetime) -> None: def _sensors( loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, sensors_config: list[str | dict], - entry: ConfigEntry | None = None, ) -> list[Entity]: sensors = [] for config in sensors_config: @@ -336,11 +334,7 @@ def _sensors( options = config[CONF_ELEVATION] sensors.append( Sun2ElevationSensor( - entry, - loc_params, - name_prefix, - options[CONF_NAME], - options[CONF_ABOVE], + loc_params, extra, options[CONF_NAME], options[CONF_ABOVE] ) ) return sensors @@ -382,11 +376,6 @@ async def async_setup_entry( return async_add_entities( - _sensors( - get_loc_params(config), - config.get(CONF_NAME), - sensors_config, - entry, - ), + _sensors(get_loc_params(config), entry, sensors_config), True, ) diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index 1539db2..9207c18 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -4,9 +4,11 @@ from typing import Any from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID +from homeassistant.const import CONF_LOCATION, CONF_UNIQUE_ID from homeassistant.data_entry_flow import FlowResult +# from homeassistant.helpers.translation import async_get_translations + from .const import DOMAIN @@ -17,9 +19,11 @@ class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Import config entry from configuration.""" - unique_id = data.pop(CONF_UNIQUE_ID) - name = data.get(CONF_NAME, self.hass.config.location_name) - await self.async_set_unique_id(unique_id) + await self.async_set_unique_id(data.pop(CONF_UNIQUE_ID)) self._abort_if_unique_id_configured() - return self.async_create_entry(title=name, data={}, options=data) + # translations = await async_get_translations(self.hass, self.hass.config.language, "service_name", [DOMAIN], False) + location = data.pop(CONF_LOCATION, self.hass.config.location_name) + # title = f"{location} {translations[f'component.{DOMAIN}.service_name']}" + title = f"{location} Sun" + return self.async_create_entry(title=title, data={}, options=data) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 4473718..0aded2c 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -22,6 +22,7 @@ from homeassistant.core import CALLBACK_TYPE, Event from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType + # Device Info moved to device_registry in 2023.9 try: from homeassistant.helpers.device_registry import DeviceInfo @@ -129,14 +130,17 @@ def __init__( self.name must be set up to return name before calling this. E.g., set up self.entity_description.name first. """ - self._attr_unique_id = self.name - self._loc_params = loc_params if entry: + self._attr_has_entity_name = True self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry.entry_id)}, name=entry.title, ) + self._attr_unique_id = f"{entry.title} {self.name}" + else: + self._attr_unique_id = self.name + self._loc_params = loc_params async def async_update(self) -> None: """Update state.""" diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 048742d..d03cd67 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -98,19 +98,18 @@ class Sun2AzimuthSensor(Sun2Entity, SensorEntity): def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, sensor_type: str, icon: str | None, ) -> None: """Initialize sensor.""" - if not entry: + name = sensor_type.replace("_", " ").title() + if not isinstance(extra, ConfigEntry): # Note that entity_platform will add namespace prefix to object ID. self.entity_id = f"{SENSOR_DOMAIN}.{slugify(sensor_type)}" - name = sensor_type.replace("_", " ").title() - if name_prefix: - name = f"{name_prefix} {name}" + if extra: + name = f"{extra} {name}" self.entity_description = SensorEntityDescription( key=sensor_type, icon=icon, @@ -119,7 +118,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ) - super().__init__(loc_params, entry) + super().__init__(loc_params, extra if isinstance(extra, ConfigEntry) else None) self._event = "solar_azimuth" def _setup_fixed_updating(self) -> None: @@ -164,9 +163,8 @@ class Sun2SensorEntity(Sun2Entity, SensorEntity, Generic[_T]): @abstractmethod def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, entity_description: SensorEntityDescription, default_solar_depression: Num | str = 0, name: str | None = None, @@ -175,14 +173,14 @@ def __init__( key = entity_description.key if name is None: name = key.replace("_", " ").title() - if not entry: + if not isinstance(extra, ConfigEntry): # Note that entity_platform will add namespace prefix to object ID. self.entity_id = f"{SENSOR_DOMAIN}.{slugify(name)}" - if name_prefix: - name = f"{name_prefix} {name}" + if extra: + name = f"{extra} {name}" entity_description.name = name self.entity_description = entity_description - super().__init__(loc_params, entry) + super().__init__(loc_params, extra if isinstance(extra, ConfigEntry) else None) if any(key.startswith(sol_dep + "_") for sol_dep in _SOLAR_DEPRESSIONS): self._solar_depression, self._event = key.rsplit("_", 1) @@ -241,9 +239,8 @@ class Sun2ElevationAtTimeSensor(Sun2SensorEntity[float]): def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, name: str, at_time: str | time, ) -> None: @@ -259,9 +256,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ) - super().__init__( - entry, loc_params, name_prefix, entity_description, name=name - ) + super().__init__(loc_params, extra, entity_description, name=name) self._event = "solar_elevation" @property @@ -361,9 +356,8 @@ class Sun2PointInTimeSensor(Sun2SensorEntity[Union[datetime, str]]): def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, sensor_type: str, icon: str | None, name: str | None = None, @@ -374,9 +368,7 @@ def __init__( device_class=SensorDeviceClass.TIMESTAMP, icon=icon, ) - super().__init__( - entry, loc_params, name_prefix, entity_description, "civil", name - ) + super().__init__(loc_params, extra, entity_description, "civil", name) class Sun2TimeAtElevationSensor(Sun2PointInTimeSensor): @@ -384,9 +376,8 @@ class Sun2TimeAtElevationSensor(Sun2PointInTimeSensor): def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, name: str, icon: str | None, direction: SunDirection, @@ -395,9 +386,7 @@ def __init__( """Initialize sensor.""" self._direction = direction self._elevation = elevation - super().__init__( - entry, loc_params, name_prefix, "time_at_elevation", icon, name - ) + super().__init__(loc_params, extra, "time_at_elevation", icon, name) def _astral_event( self, @@ -416,9 +405,8 @@ class Sun2PeriodOfTimeSensor(Sun2SensorEntity[float]): def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -430,9 +418,7 @@ def __init__( native_unit_of_measurement=UnitOfTime.HOURS, suggested_display_precision=3, ) - super().__init__( - entry, loc_params, name_prefix, 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: @@ -473,9 +459,8 @@ class Sun2MinMaxElevationSensor(Sun2SensorEntity[float]): def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -487,7 +472,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=3, ) - super().__init__(entry, loc_params, name_prefix, entity_description) + super().__init__(loc_params, extra, entity_description) self._event = { "min_elevation": "solar_midnight", "max_elevation": "solar_noon", @@ -538,19 +523,14 @@ class Sun2CPSensorEntity(Sun2SensorEntity[_T]): @abstractmethod def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, entity_description: SensorEntityDescription, default_solar_depression: Num | str = 0, ) -> None: """Initialize sensor.""" super().__init__( - entry, - loc_params, - name_prefix, - entity_description, - default_solar_depression, + loc_params, extra, entity_description, default_solar_depression ) self._event = "solar_elevation" @@ -708,9 +688,8 @@ class Sun2ElevationSensor(Sun2CPSensorEntity[float]): def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -722,7 +701,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ) - super().__init__(entry, loc_params, name_prefix, entity_description) + super().__init__(loc_params, extra, entity_description) def _update(self, cur_dttm: datetime) -> None: """Update state.""" @@ -802,9 +781,8 @@ class Sun2PhaseSensorBase(Sun2CPSensorEntity[str]): @abstractmethod def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, sensor_type: str, icon: str | None, phase_data: PhaseData, @@ -820,7 +798,7 @@ def __init__( icon=icon, options=options, ) - super().__init__(entry, loc_params, name_prefix, entity_description) + super().__init__(loc_params, extra, entity_description) self._d = phase_data self._updates: list[Update] = [] @@ -993,9 +971,8 @@ class Sun2PhaseSensor(Sun2PhaseSensorBase): def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -1022,9 +999,8 @@ def __init__( ) )[::-1] super().__init__( - entry, loc_params, - name_prefix, + extra, sensor_type, icon, PhaseData(rising_elevs, rising_states, falling_elevs, falling_states), @@ -1052,9 +1028,8 @@ class Sun2DeconzDaylightSensor(Sun2PhaseSensorBase): def __init__( self, - entry: ConfigEntry | None, loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -1088,9 +1063,8 @@ def __init__( ) )[::-1] super().__init__( - entry, loc_params, - name_prefix, + extra, sensor_type, icon, PhaseData(rising_elevs, rising_states, falling_elevs, falling_states), @@ -1248,28 +1222,22 @@ def _eat_defaults(config: ConfigType) -> ConfigType: def _sensors( loc_params: LocParams | None, - name_prefix: str | None, + extra: ConfigEntry | str | None, sensors_config: list[str | dict], - entry: ConfigEntry | None = None, ) -> list[Entity]: sensors = [] for config in sensors_config: if isinstance(config, str): sensors.append( _SENSOR_TYPES[config].cls( - entry, - loc_params, - name_prefix, - config, - _SENSOR_TYPES[config].icon, + loc_params, extra, config, _SENSOR_TYPES[config].icon ) ) elif CONF_TIME_AT_ELEVATION in config: sensors.append( Sun2TimeAtElevationSensor( - entry, loc_params, - name_prefix, + extra, config[CONF_NAME], config[CONF_ICON], config[CONF_DIRECTION], @@ -1284,13 +1252,7 @@ def _sensors( with suppress(ValueError): at_time = time.fromisoformat(at_time) sensors.append( - Sun2ElevationAtTimeSensor( - entry, - loc_params, - name_prefix, - config[CONF_NAME], - at_time, - ) + Sun2ElevationAtTimeSensor(loc_params, extra, config[CONF_NAME], at_time) ) return sensors @@ -1331,11 +1293,6 @@ async def async_setup_entry( return async_add_entities( - _sensors( - get_loc_params(config), - config.get(CONF_NAME), - sensors_config, - entry, - ), + _sensors(get_loc_params(config), entry, sensors_config), True, ) From 75e1d1d9df2a9f7558b1a2b5fb1909c887411f33 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 11 Nov 2023 13:51:26 -0600 Subject: [PATCH 09/59] Update config entries per configuration --- custom_components/sun2/__init__.py | 10 ++++------ custom_components/sun2/config_flow.py | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 8dd1c37..22614af 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -16,7 +16,8 @@ from homeassistant.helpers.typing import ConfigType from .binary_sensor import SUN2_BINARY_SENSOR_SCHEMA -from .const import DOMAIN, LOGGER +from .config_flow import config_entry_params +from .const import DOMAIN from .helpers import LOC_PARAMS from .sensor import SUN2_SENSOR_SCHEMA @@ -59,13 +60,11 @@ def _unique_locations(configs: list[dict]) -> list[dict]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Setup composite integration.""" regd = { - entry.unique_id: entry.entry_id + entry.unique_id: entry for entry in hass.config_entries.async_entries(DOMAIN) } cfgs = {cfg[CONF_UNIQUE_ID]: cfg for cfg in config[DOMAIN]} - for uid in set(regd) - set(cfgs): - await hass.config_entries.async_remove(regd[uid]) for uid in set(cfgs) - set(regd): hass.async_create_task( hass.config_entries.flow.async_init( @@ -73,8 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) for uid in set(cfgs) & set(regd): - # TODO: UPDATE - LOGGER.warning("Already configured: %s", uid) + hass.config_entries.async_update_entry(regd[uid], **config_entry_params(hass, cfgs[uid])) return True diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index 9207c18..9d98864 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -5,6 +5,7 @@ from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_LOCATION, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult # from homeassistant.helpers.translation import async_get_translations @@ -12,6 +13,16 @@ from .const import DOMAIN +def config_entry_params(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Get config entry parameters from configuration data.""" + data = data.copy() + # translations = await async_get_translations(hass, hass.config.language, "service_name", [DOMAIN], False) + location = data.pop(CONF_LOCATION, hass.config.location_name) + # title = f"{location} {translations[f'component.{DOMAIN}.service_name']}" + title = f"{location} Sun" + return {"title": title, "options": data} + + class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): """Sun2 config flow.""" @@ -22,8 +33,4 @@ async def async_step_import(self, data: dict[str, Any]) -> FlowResult: await self.async_set_unique_id(data.pop(CONF_UNIQUE_ID)) self._abort_if_unique_id_configured() - # translations = await async_get_translations(self.hass, self.hass.config.language, "service_name", [DOMAIN], False) - location = data.pop(CONF_LOCATION, self.hass.config.location_name) - # title = f"{location} {translations[f'component.{DOMAIN}.service_name']}" - title = f"{location} Sun" - return self.async_create_entry(title=title, data={}, options=data) + return self.async_create_entry(data={}, **config_entry_params(self.hass, data)) From eb8fc1577b148a544d3e0a57fffe9fa9a96b7f15 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 11 Nov 2023 14:42:39 -0600 Subject: [PATCH 10/59] First translation implementation --- custom_components/sun2/__init__.py | 6 +++--- custom_components/sun2/config_flow.py | 17 ++++++++++------- custom_components/sun2/helpers.py | 2 +- custom_components/sun2/sensor.py | 11 ++++++----- custom_components/sun2/translations/en.json | 18 ++++++++++++++++++ 5 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 custom_components/sun2/translations/en.json diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 22614af..b62e374 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -60,8 +60,7 @@ def _unique_locations(configs: list[dict]) -> list[dict]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Setup composite integration.""" regd = { - entry.unique_id: entry - for entry in hass.config_entries.async_entries(DOMAIN) + entry.unique_id: entry for entry in hass.config_entries.async_entries(DOMAIN) } cfgs = {cfg[CONF_UNIQUE_ID]: cfg for cfg in config[DOMAIN]} @@ -72,7 +71,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) for uid in set(cfgs) & set(regd): - hass.config_entries.async_update_entry(regd[uid], **config_entry_params(hass, cfgs[uid])) + params = await config_entry_params(hass, cfgs[uid]) + hass.config_entries.async_update_entry(regd[uid], **params) return True diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index 9d98864..3e37fac 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -7,19 +7,21 @@ from homeassistant.const import CONF_LOCATION, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult - -# from homeassistant.helpers.translation import async_get_translations +from homeassistant.helpers.translation import async_get_translations from .const import DOMAIN -def config_entry_params(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def config_entry_params( + hass: HomeAssistant, data: dict[str, Any] +) -> dict[str, Any]: """Get config entry parameters from configuration data.""" data = data.copy() - # translations = await async_get_translations(hass, hass.config.language, "service_name", [DOMAIN], False) + translations = await async_get_translations( + hass, hass.config.language, "service_name", [DOMAIN], False + ) location = data.pop(CONF_LOCATION, hass.config.location_name) - # title = f"{location} {translations[f'component.{DOMAIN}.service_name']}" - title = f"{location} Sun" + title = f"{location} {translations[f'component.{DOMAIN}.service_name']}" return {"title": title, "options": data} @@ -33,4 +35,5 @@ async def async_step_import(self, data: dict[str, Any]) -> FlowResult: await self.async_set_unique_id(data.pop(CONF_UNIQUE_ID)) self._abort_if_unique_id_configured() - return self.async_create_entry(data={}, **config_entry_params(self.hass, data)) + params = await config_entry_params(self.hass, data) + return self.async_create_entry(data={}, **params) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 0aded2c..d536613 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -137,7 +137,7 @@ def __init__( identifiers={(DOMAIN, entry.entry_id)}, name=entry.title, ) - self._attr_unique_id = f"{entry.title} {self.name}" + self._attr_unique_id = f"{entry.title} {self.entity_description.name}" else: self._attr_unique_id = self.name self._loc_params = loc_params diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index d03cd67..70f52ff 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -978,11 +978,11 @@ def __init__( ) -> 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( @@ -998,6 +998,7 @@ def __init__( zip(elevs[1:], states[:-1]), ) )[::-1] + self._attr_translation_key = sensor_type super().__init__( loc_params, extra, diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json new file mode 100644 index 0000000..36539a8 --- /dev/null +++ b/custom_components/sun2/translations/en.json @@ -0,0 +1,18 @@ +{ + "title": "Sun2", + "service_name": "Sun", + "entity": { + "sensor": { + "sun_phase": { + "name": "Phase", + "state": { + "astronomical_twilight": "Astronomical Twilight", + "civil_twilight": "Civil Twilight", + "day": "Day", + "nautical_twilight": "Nautical Twilight", + "night": "Night" + } + } + } + } +} \ No newline at end of file From 2fc94956f2e7ca17954258ba0aaa08fad3ab9090 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 11 Nov 2023 16:11:35 -0600 Subject: [PATCH 11/59] Add remaining translations --- custom_components/sun2/helpers.py | 1 + custom_components/sun2/sensor.py | 3 +- custom_components/sun2/translations/en.json | 83 +++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index d536613..4cbb64c 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -131,6 +131,7 @@ def __init__( E.g., set up self.entity_description.name first. """ if entry: + self._attr_translation_key = self.entity_description.key self._attr_has_entity_name = True self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 70f52ff..82a6e12 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -386,7 +386,7 @@ def __init__( """Initialize sensor.""" self._direction = direction self._elevation = elevation - super().__init__(loc_params, extra, "time_at_elevation", icon, name) + super().__init__(loc_params, extra, CONF_TIME_AT_ELEVATION, icon, name) def _astral_event( self, @@ -998,7 +998,6 @@ def __init__( zip(elevs[1:], states[:-1]), ) )[::-1] - self._attr_translation_key = sensor_type super().__init__( loc_params, extra, diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index 36539a8..70686ec 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -3,6 +3,83 @@ "service_name": "Sun", "entity": { "sensor": { + "astronomical_daylight": { + "name": "Astronomical Daylight" + }, + "astronomical_dawn": { + "name": "Astronomical Dawn" + }, + "astronomical_dusk": { + "name": "Astronomical Dusk" + }, + "astronomical_night": { + "name": "Astronomical Night" + }, + "azimuth": { + "name": "Azimuth" + }, + "civil_daylight": { + "name": "Civil Daylight" + }, + "civil_night": { + "name": "Civil Night" + }, + "daylight": { + "name": "Daylight" + }, + "dawn": { + "name": "Dawn" + }, + "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" + } + }, + "dusk": { + "name": "Dusk" + }, + "elevation": { + "name": "Elevation" + }, + "max_elevation": { + "name": "Maximum Elevation" + }, + "min_elevation": { + "name": "Minimum Elevation" + }, + "nautical_daylight": { + "name": "Nautical Daylight" + }, + "nautical_dawn": { + "name": "Nautical Dawn" + }, + "nautical_dusk": { + "name": "Nautical Dusk" + }, + "nautical_night": { + "name": "Nautical Night" + }, + "night": { + "name": "Night" + }, + "solar_midnight": { + "name": "Solar Midnight" + }, + "solar_noon": { + "name": "Solar Noon" + }, "sun_phase": { "name": "Phase", "state": { @@ -12,6 +89,12 @@ "nautical_twilight": "Nautical Twilight", "night": "Night" } + }, + "sunrise": { + "name": "Rising" + }, + "sunset": { + "name": "Setting" } } } From ab1d6d1f7916d0259dcf504e972ec3c9fe087d0a Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 11 Nov 2023 21:52:00 -0600 Subject: [PATCH 12/59] Simplify config entry updating --- custom_components/sun2/__init__.py | 13 ++-------- custom_components/sun2/config_flow.py | 34 ++++++++++++--------------- 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index b62e374..f3d4215 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -16,7 +16,6 @@ from homeassistant.helpers.typing import ConfigType from .binary_sensor import SUN2_BINARY_SENSOR_SCHEMA -from .config_flow import config_entry_params from .const import DOMAIN from .helpers import LOC_PARAMS from .sensor import SUN2_SENSOR_SCHEMA @@ -59,20 +58,12 @@ def _unique_locations(configs: list[dict]) -> list[dict]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Setup composite integration.""" - regd = { - entry.unique_id: entry for entry in hass.config_entries.async_entries(DOMAIN) - } - cfgs = {cfg[CONF_UNIQUE_ID]: cfg for cfg in config[DOMAIN]} - - for uid in set(cfgs) - set(regd): + for conf in config[DOMAIN]: hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=cfgs[uid] + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf.copy() ) ) - for uid in set(cfgs) & set(regd): - params = await config_entry_params(hass, cfgs[uid]) - hass.config_entries.async_update_entry(regd[uid], **params) return True diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index 3e37fac..bdc08dd 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -5,26 +5,12 @@ from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_LOCATION, CONF_UNIQUE_ID -from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.translation import async_get_translations from .const import DOMAIN -async def config_entry_params( - hass: HomeAssistant, data: dict[str, Any] -) -> dict[str, Any]: - """Get config entry parameters from configuration data.""" - data = data.copy() - translations = await async_get_translations( - hass, hass.config.language, "service_name", [DOMAIN], False - ) - location = data.pop(CONF_LOCATION, hass.config.location_name) - title = f"{location} {translations[f'component.{DOMAIN}.service_name']}" - return {"title": title, "options": data} - - class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): """Sun2 config flow.""" @@ -32,8 +18,18 @@ class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Import config entry from configuration.""" - await self.async_set_unique_id(data.pop(CONF_UNIQUE_ID)) - self._abort_if_unique_id_configured() - - params = await config_entry_params(self.hass, data) - return self.async_create_entry(data={}, **params) + translations = await async_get_translations( + self.hass, self.hass.config.language, "service_name", [DOMAIN], False + ) + location = data.pop(CONF_LOCATION, self.hass.config.location_name) + title = f"{location} {translations[f'component.{DOMAIN}.service_name']}" + if existing_entry := await self.async_set_unique_id(data.pop(CONF_UNIQUE_ID)): + 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(data={}, title=title, options=data) From 5df731e1197c5322245fd05b8a7f2a13452e98f0 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sun, 12 Nov 2023 09:12:35 -0600 Subject: [PATCH 13/59] Remove simple entity types from sun2 config Create entity registry entries for all simple types with most disabled. --- custom_components/sun2/__init__.py | 34 ++++++++++++------------- custom_components/sun2/binary_sensor.py | 4 +-- custom_components/sun2/helpers.py | 5 +--- custom_components/sun2/sensor.py | 31 +++++++++++++++++----- 4 files changed, 44 insertions(+), 30 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index f3d4215..ab28b9d 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -18,11 +18,11 @@ from .binary_sensor import SUN2_BINARY_SENSOR_SCHEMA from .const import DOMAIN from .helpers import LOC_PARAMS -from .sensor import SUN2_SENSOR_SCHEMA +from .sensor import ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA -def _unique_locations(configs: list[dict]) -> list[dict]: - """Check that locations are unique.""" +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") @@ -31,25 +31,25 @@ def _unique_locations(configs: list[dict]) -> list[dict]: PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -SUN2_CONFIG = vol.All( - vol.Schema( - { - vol.Required(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_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, [SUN2_SENSOR_SCHEMA]), - **LOC_PARAMS, - } - ), - cv.has_at_least_one_key(CONF_BINARY_SENSORS, CONF_SENSORS), +SUN2_CONFIG = vol.Schema( + { + vol.Required(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_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, + [vol.Any(ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA)], + ), + **LOC_PARAMS, + } ) CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default=list): vol.All( - cv.ensure_list, [SUN2_CONFIG], _unique_locations + cv.ensure_list, [SUN2_CONFIG], _unique_locations_names ), }, extra=vol.ALLOW_EXTRA, diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 10aeae3..5a6557f 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -3,7 +3,7 @@ from datetime import datetime from numbers import Real -from typing import cast +from typing import Any, Iterable, cast import voluptuous as vol @@ -326,7 +326,7 @@ def schedule_update(now: datetime) -> None: def _sensors( loc_params: LocParams | None, extra: ConfigEntry | str | None, - sensors_config: list[str | dict], + sensors_config: Iterable[str | dict[str, Any]], ) -> list[Entity]: sensors = [] for config in sensors_config: diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 4cbb64c..9241dba 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -142,6 +142,7 @@ def __init__( else: self._attr_unique_id = self.name self._loc_params = loc_params + self.async_on_remove(self._cancel_update) async def async_update(self) -> None: """Update state.""" @@ -153,10 +154,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: diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 82a6e12..7600347 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta, time from math import ceil, floor -from typing import Any, Generic, Optional, TypeVar, Union, cast +from typing import Any, Generic, Iterable, Optional, TypeVar, Union, cast from astral import SunDirection from astral.sun import SUN_APPARENT_RADIUS @@ -84,6 +84,16 @@ next_midnight, ) +_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) @@ -112,6 +122,7 @@ def __init__( name = f"{extra} {name}" 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, @@ -173,14 +184,18 @@ def __init__( key = entity_description.key if name is None: name = key.replace("_", " ").title() - if not isinstance(extra, ConfigEntry): + if isinstance(extra, ConfigEntry): + entity_description.entity_registry_enabled_default = key in _ENABLED_SENSORS + entry = extra + else: # 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}" + entry = None entity_description.name = name self.entity_description = entity_description - super().__init__(loc_params, extra if isinstance(extra, ConfigEntry) else None) + super().__init__(loc_params, entry) if any(key.startswith(sol_dep + "_") for sol_dep in _SOLAR_DEPRESSIONS): self._solar_depression, self._event = key.rsplit("_", 1) @@ -1206,14 +1221,14 @@ def _eat_defaults(config: ConfigType) -> ConfigType: _eat_defaults, ) -SUN2_SENSOR_SCHEMA = vol.Any( +_SUN2_SENSOR_SCHEMA = vol.Any( TIME_AT_ELEVATION_SCHEMA, ELEVATION_AT_TIME_SCHEMA, vol.In(_SENSOR_TYPES) ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [SUN2_SENSOR_SCHEMA] + cv.ensure_list, [_SUN2_SENSOR_SCHEMA] ), **LOC_PARAMS, } @@ -1223,7 +1238,7 @@ def _eat_defaults(config: ConfigType) -> ConfigType: def _sensors( loc_params: LocParams | None, extra: ConfigEntry | str | None, - sensors_config: list[str | dict], + sensors_config: Iterable[str | dict[str, Any]], ) -> list[Entity]: sensors = [] for config in sensors_config: @@ -1292,7 +1307,9 @@ async def async_setup_entry( if not (sensors_config := config.get(CONF_SENSORS)): return + loc_params = get_loc_params(config) async_add_entities( - _sensors(get_loc_params(config), entry, sensors_config), + _sensors(loc_params, entry, sensors_config) + + _sensors(loc_params, entry, _SENSOR_TYPES.keys()), True, ) From 5fe55ede569a714468ddd56cf32761790fc9e5ac Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sun, 12 Nov 2023 09:29:52 -0600 Subject: [PATCH 14/59] Add nl.json. Thanks metbril! --- custom_components/sun2/translations/nl.json | 101 ++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 custom_components/sun2/translations/nl.json diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json new file mode 100644 index 0000000..e348f6b --- /dev/null +++ b/custom_components/sun2/translations/nl.json @@ -0,0 +1,101 @@ +{ + "title": "Zon2", + "service_name": "Zon", + "entity": { + "sensor": { + "astronomical_daylight": { + "name": "Astronomisch daglicht" + }, + "astronomical_dawn": { + "name": "Astronomische dageraad" + }, + "astronomical_dusk": { + "name": "Astronomische schemer" + }, + "astronomical_night": { + "name": "Astronomische nacht" + }, + "azimuth": { + "name": "Azimut" + }, + "civil_daylight": { + "name": "Civiel daglicht" + }, + "civil_night": { + "name": "Civiele nacht" + }, + "daylight": { + "name": "Daglicht" + }, + "dawn": { + "name": "Dageraad" + }, + "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" + } + }, + "dusk": { + "name": "Schemer" + }, + "elevation": { + "name": "Hoogtehoek" + }, + "max_elevation": { + "name": "Maximale hoogtehoek" + }, + "min_elevation": { + "name": "Minimale hoogtehoek" + }, + "nautical_daylight": { + "name": "Nautisch daglicht" + }, + "nautical_dawn": { + "name": "Nautische dageraad" + }, + "nautical_dusk": { + "name": "Nautische schemer" + }, + "nautical_night": { + "name": "Nautische nacht" + }, + "night": { + "name": "Nacht" + }, + "solar_midnight": { + "name": "Zonne-middernacht" + }, + "solar_noon": { + "name": "Middagzon" + }, + "sun_phase": { + "name": "Fase", + "state": { + "astronomical_twilight": "Astronomische schemering", + "civil_twilight": "Civiele schemering", + "day": "Dag", + "nautical_twilight": "Nautische schemering", + "night": "Nacht" + } + }, + "sunrise": { + "name": "Zonsopkomst" + }, + "sunset": { + "name": "Zonsondergang" + } + } + } +} \ No newline at end of file From ce13b9293575bd94e7dd1b69860ed3e2128b990d Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sun, 12 Nov 2023 11:03:53 -0600 Subject: [PATCH 15/59] Update info.md --- info.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/info.md b/info.md index 3466f1f..88849d4 100644 --- a/info.md +++ b/info.md @@ -2,6 +2,5 @@ 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. +For now configuration is done strictly in YAML, +although there will be corresponding entries on the Integrations, Devices and Entities pages. From eac6d4137805e403b188bc276aa154bb2fac3371 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sun, 12 Nov 2023 12:03:07 -0600 Subject: [PATCH 16/59] Update README.md --- README.md | 275 +++++++++++++++++++++++++----------------------------- 1 file changed, 128 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index dbf0a56..38b1462 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,8 @@ 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 +sun2: + - unique_id: home ``` ## Installation @@ -33,69 +25,62 @@ 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. -## Sensors -### Configuration variables +## Configuration variables -- **`monitored_conditions`**: A list of sensor types to create. One or more of the following: +A list of one or more dictionaries with the following options. -#### 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) +Key | Optional | Description +-|-|- +`unique_id` | no | Unique identifier for group of options. +`location` | yes | Name of location. Default is Home Assistant's current location name. +`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 configuration. + +### Binary Sensor Configurations + +A list of one or more of the following. -##### Time at Elevation Sensor +#### `elevation` + +`'on'` when sun's elevation is above threshold, `'off'` when at or below threshold. Can be specified in any of the following ways: + +```yaml +elevation + +elevation: THRESHOLD -key | optional | description +elevation: + above: THRESHOLD + name: FRIENDLY_NAME +``` + +Default THRESHOLD (as with first format) is -0.833 (same as sunrise/sunset). + +Default FRIENDLY_NAME is "Above Horizon" if THRESHOLD is -0.833, "Above minus THRESHOLD" if THRESHOLD is negative, otherwise "Above THRESHOLD". + +### Sensor Configurations + +A list of one or more of the following. + +#### Time at Elevation Sensor + +Key | Optional | Description -|-|- `time_at_elevation` | no | Elevation `direction` | yes | `rising` (default) or `setting` @@ -117,11 +102,9 @@ Would be equivalent to: 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 " @@ -131,119 +114,117 @@ If the date is not present, the result will be the sun's elevation at the given 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 - -###### 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° - -###### 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° - -## Binary Sensors -### Configuration variables - -- **`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: +## Aditional Sensors -```yaml -elevation +Besides any sensors specified in the configuration, the following will also be created. -elevation: THRESHOLD +### Point in Time Sensors -elevation: - above: THRESHOLD - name: FRIENDLY_NAME -``` +Some of these will be enabled by default. The rest will be disabled by default. -Default THRESHOLD (as with first format) is -0.833 (same as sunrise/sunset). +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. -Default FRIENDLY_NAME is "Above Horizon" if THRESHOLD is -0.833, "Above minus THRESHOLD" if THRESHOLD is negative, otherwise "Above THRESHOLD". +### Other Sensors -`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). +These are also all disabled by default. -## Optional Location +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) -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. +##### Sun Phase Sensor -### Configuration variables +###### Possible states -type | description +State | 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.) +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° -## Entity Namespace +###### Attributes -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". +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° ## Example Full Configuration ```yaml -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 +sun2: + - unique_id: home + binary_sensors: - elevation - - min_elevation - - max_elevation - - sun_phase - - deconz_daylight + - elevation: 3 + - elevation: + above: -6 + name: Above Civil Dawn + sensors: - time_at_elevation: 10 - time_at_elevation: -10 direction: setting icon: mdi:weather-sunset-down name: Setting past 10 deg below horizon - elevation_at_time: '12:00' + name: Elv @ noon - elevation_at_time: input_datetime.test - name: Elv @ test time - - platform: sun2 - entity_namespace: London + name: Elv @ test var + + - 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: + binary_sensors: - elevation - elevation: 3 - elevation: above: -6 name: Above Civil Dawn + sensors: + - time_at_elevation: 10 + - time_at_elevation: -10 + direction: setting + icon: mdi:weather-sunset-down + name: Setting past 10 deg below horizon + - elevation_at_time: '12:00' + name: Elv @ noon + - elevation_at_time: input_datetime.test + name: Elv @ test var ``` From 9bedd1167792c93b481301eace43306869604dd6 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sun, 12 Nov 2023 20:25:30 -0600 Subject: [PATCH 17/59] Bump version to 3.0.0b0 & point doc to branch --- custom_components/sun2/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index 134db95..62e05c8 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -4,9 +4,9 @@ "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.0b0" } From df1c623fde02e92593f3d18645edad0df7d8feef Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 13 Nov 2023 13:59:26 -0600 Subject: [PATCH 18/59] Fix config bug when no sensors listed --- custom_components/sun2/sensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 7600347..c2dcf3f 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -1304,12 +1304,10 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" config = entry.options - if not (sensors_config := config.get(CONF_SENSORS)): - return loc_params = get_loc_params(config) async_add_entities( - _sensors(loc_params, entry, sensors_config) + _sensors(loc_params, entry, config.get(CONF_SENSORS, [])) + _sensors(loc_params, entry, _SENSOR_TYPES.keys()), True, ) From d6ae93367f51fc5e03130972e8743bade598baa2 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 13 Nov 2023 14:00:29 -0600 Subject: [PATCH 19/59] Bump version to 3.0.0b1 --- custom_components/sun2/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index 62e05c8..340bc50 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/pnbruckner/ha-sun2/issues", "requirements": [], - "version": "3.0.0b0" + "version": "3.0.0b1" } From 28a6b0c13e7879a98adb5aed9e992bdc41828e6f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 13 Nov 2023 17:31:58 -0600 Subject: [PATCH 20/59] Fix core config update handling --- custom_components/sun2/helpers.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 9241dba..6d8c76c 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -5,6 +5,7 @@ from collections.abc import Mapping from dataclasses import dataclass from datetime import date, datetime, time, timedelta, tzinfo +from functools import partial from typing import Any, TypeVar, Union, cast from astral import LocationInfo @@ -19,7 +20,7 @@ CONF_TIME_ZONE, EVENT_CORE_CONFIG_UPDATE, ) -from homeassistant.core import CALLBACK_TYPE, Event +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType @@ -34,7 +35,7 @@ ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util, slugify +from homeassistant.util import dt as dt_util from .const import DOMAIN, ONE_DAY, SIG_HA_LOC_UPDATED @@ -168,22 +169,26 @@ def _get_loc_data(self) -> LocData: if DOMAIN not in self.hass.data: self.hass.data[DOMAIN] = {} - def update_local_loc_data(event: Event | None = None) -> None: + def update_local_loc_data( + hass: HomeAssistant, event: Event | None = None + ) -> None: """Update local location data from HA's config.""" - self.hass.data[DOMAIN][None] = loc_data = LocData( + 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), + hass.config.elevation, + hass.config.latitude, + hass.config.longitude, + str(hass.config.time_zone), ) ) if event: # Signal all instances that location data has changed. - dispatcher_send(self.hass, SIG_HA_LOC_UPDATED, loc_data) + dispatcher_send(hass, SIG_HA_LOC_UPDATED, loc_data) - update_local_loc_data() - self.hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_local_loc_data) + update_local_loc_data(self.hass) + self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, partial(update_local_loc_data, self.hass) + ) try: loc_data = cast(LocData, self.hass.data[DOMAIN][self._loc_params]) From 643bf3e1cf7903dc13c51ccee33306f203cfc8ea Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 13 Nov 2023 17:32:39 -0600 Subject: [PATCH 21/59] Bump version to 3.0.0b2 --- custom_components/sun2/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index 340bc50..8edfc77 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/pnbruckner/ha-sun2/issues", "requirements": [], - "version": "3.0.0b1" + "version": "3.0.0b2" } From 927c1657ab374f9d55f5687c5a767728c3e948db Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 14 Nov 2023 06:43:47 -0600 Subject: [PATCH 22/59] Move processing of HA location config to __init__ --- custom_components/sun2/__init__.py | 27 +++++++++++++++++++++--- custom_components/sun2/helpers.py | 33 ++---------------------------- 2 files changed, 26 insertions(+), 34 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index ab28b9d..e979b3c 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -9,15 +9,17 @@ CONF_LOCATION, CONF_SENSORS, CONF_UNIQUE_ID, + EVENT_CORE_CONFIG_UPDATE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType from .binary_sensor import SUN2_BINARY_SENSOR_SCHEMA -from .const import DOMAIN -from .helpers import LOC_PARAMS +from .const import DOMAIN, SIG_HA_LOC_UPDATED +from .helpers import LOC_PARAMS, LocData, LocParams from .sensor import ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA @@ -58,6 +60,25 @@ def _unique_locations_names(configs: list[dict]) -> list[dict]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Setup composite integration.""" + hass.data[DOMAIN] = {} + + def update_local_loc_data(event: Event | None = None) -> None: + """Update local location data from HA's config.""" + hass.data[DOMAIN][None] = loc_data = LocData( + LocParams( + hass.config.elevation, + hass.config.latitude, + hass.config.longitude, + str(hass.config.time_zone), + ) + ) + if event: + # Signal all instances that location data has changed. + dispatcher_send(hass, SIG_HA_LOC_UPDATED, loc_data) + + update_local_loc_data() + hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_local_loc_data) + for conf in config[DOMAIN]: hass.async_create_task( hass.config_entries.flow.async_init( diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 6d8c76c..e14a96a 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -5,7 +5,6 @@ from collections.abc import Mapping from dataclasses import dataclass from datetime import date, datetime, time, timedelta, tzinfo -from functools import partial from typing import Any, TypeVar, Union, cast from astral import LocationInfo @@ -18,9 +17,8 @@ CONF_LATITUDE, CONF_LONGITUDE, CONF_TIME_ZONE, - EVENT_CORE_CONFIG_UPDATE, ) -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant +from homeassistant.core import CALLBACK_TYPE from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType @@ -29,10 +27,7 @@ from homeassistant.helpers.device_registry import DeviceInfo except ImportError: from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -166,30 +161,6 @@ 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( - hass: HomeAssistant, event: Event | None = None - ) -> None: - """Update local location data from HA's config.""" - hass.data[DOMAIN][None] = loc_data = LocData( - LocParams( - hass.config.elevation, - hass.config.latitude, - hass.config.longitude, - str(hass.config.time_zone), - ) - ) - if event: - # Signal all instances that location data has changed. - dispatcher_send(hass, SIG_HA_LOC_UPDATED, loc_data) - - update_local_loc_data(self.hass) - self.hass.bus.async_listen( - EVENT_CORE_CONFIG_UPDATE, partial(update_local_loc_data, self.hass) - ) - try: loc_data = cast(LocData, self.hass.data[DOMAIN][self._loc_params]) except KeyError: From 66341164b4e405c97b58782af6abf4e0f7d8866b Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 14 Nov 2023 10:59:06 -0600 Subject: [PATCH 23/59] Fix time_at_elevation when config removed from YAML --- custom_components/sun2/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index c2dcf3f..604e959 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -1255,7 +1255,7 @@ def _sensors( extra, config[CONF_NAME], config[CONF_ICON], - config[CONF_DIRECTION], + SunDirection(config[CONF_DIRECTION]), config[CONF_TIME_AT_ELEVATION], ) ) From b94f47c252da5fde80087ad31c6693ed77063f2c Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 14 Nov 2023 11:01:09 -0600 Subject: [PATCH 24/59] Bump version to 3.0.0b3 --- custom_components/sun2/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index 8edfc77..c6b01ed 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/pnbruckner/ha-sun2/issues", "requirements": [], - "version": "3.0.0b2" + "version": "3.0.0b3" } From 19a355cd8c463a9f98707aa6bc2893fc85ff4f5f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 14 Nov 2023 11:21:58 -0600 Subject: [PATCH 25/59] Translations require HA 2023.4.0 or newer --- README.md | 2 +- hacs.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 38b1462..517a0e1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ where `` is your Home Assistant configuration directory. ### Versions -This custom integration supports HomeAssistant versions 2023.3 or newer. +This custom integration supports HomeAssistant versions 2023.4.0 or newer. ## Configuration variables 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" } From 9a3d5b30170a3afe09f324a8d24cd4ce1cb6c371 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 14 Nov 2023 20:40:18 -0600 Subject: [PATCH 26/59] Move translation lookup to __init__ --- custom_components/sun2/__init__.py | 14 +++++++++++--- custom_components/sun2/config_flow.py | 12 ++++++------ custom_components/sun2/helpers.py | 16 ++++++++++++++-- custom_components/sun2/translations/en.json | 4 +++- custom_components/sun2/translations/nl.json | 4 +++- 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index e979b3c..8502083 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -1,6 +1,8 @@ """Sun2 integration.""" from __future__ import annotations +from typing import cast + import voluptuous as vol from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT @@ -15,11 +17,12 @@ from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType from .binary_sensor import SUN2_BINARY_SENSOR_SCHEMA from .const import DOMAIN, SIG_HA_LOC_UPDATED -from .helpers import LOC_PARAMS, LocData, LocParams +from .helpers import LOC_PARAMS, LocData, LocParams, Sun2Data from .sensor import ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA @@ -60,11 +63,16 @@ def _unique_locations_names(configs: list[dict]) -> list[dict]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Setup composite integration.""" - hass.data[DOMAIN] = {} + hass.data[DOMAIN] = Sun2Data( + locations={}, + translations=await async_get_translations( + hass, hass.config.language, "misc", [DOMAIN], False + ), + ) def update_local_loc_data(event: Event | None = None) -> None: """Update local location data from HA's config.""" - hass.data[DOMAIN][None] = loc_data = LocData( + cast(Sun2Data, hass.data[DOMAIN]).locations[None] = loc_data = LocData( LocParams( hass.config.elevation, hass.config.latitude, diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index bdc08dd..ff26c8f 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -1,14 +1,14 @@ """Config flow for Sun2 integration.""" from __future__ import annotations -from typing import Any +from typing import cast, Any from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_LOCATION, CONF_UNIQUE_ID from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.translation import async_get_translations from .const import DOMAIN +from .helpers import Sun2Data class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): @@ -18,11 +18,11 @@ class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Import config entry from configuration.""" - translations = await async_get_translations( - self.hass, self.hass.config.language, "service_name", [DOMAIN], False - ) location = data.pop(CONF_LOCATION, self.hass.config.location_name) - title = f"{location} {translations[f'component.{DOMAIN}.service_name']}" + service_name = cast(Sun2Data, self.hass.data[DOMAIN]).translations[ + f"component.{DOMAIN}.misc.service_name" + ] + title = f"{location} {service_name}" if existing_entry := await self.async_set_unique_id(data.pop(CONF_UNIQUE_ID)): self.hass.config_entries.async_update_entry( existing_entry, title=title, options=data diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index e14a96a..e69f366 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -70,6 +70,14 @@ def __init__(self, lp: LocParams) -> None: object.__setattr__(self, "tzi", dt_util.get_time_zone(lp.time_zone)) +@dataclass +class Sun2Data: + """Sun2 shared data.""" + + locations: dict[LocParams | None, LocData] + translations: dict[str, str] + + def get_loc_params(config: ConfigType) -> LocParams | None: """Get location parameters from configuration.""" try: @@ -140,6 +148,10 @@ def __init__( 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.""" if not self._loc_data: @@ -162,9 +174,9 @@ def _get_loc_data(self) -> LocData: loc_params = None -> Use location parameters from HA's config. """ 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) ) diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index 70686ec..bb464ea 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -1,6 +1,8 @@ { "title": "Sun2", - "service_name": "Sun", + "misc": { + "service_name": "Sun" + }, "entity": { "sensor": { "astronomical_daylight": { diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json index e348f6b..8afd374 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -1,6 +1,8 @@ { "title": "Zon2", - "service_name": "Zon", + "misc": { + "service_name": "Zon" + }, "entity": { "sensor": { "astronomical_daylight": { From f36bb6dee2d74c8bfe6279c174f6a4faf733db36 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 15 Nov 2023 14:26:56 -0600 Subject: [PATCH 27/59] Move config validation to config.py Add translations for parts of default entity names. All sun2: in YAML to be empty to get just entities for home. --- custom_components/sun2/__init__.py | 57 +-------- custom_components/sun2/binary_sensor.py | 43 ++++--- custom_components/sun2/config.py | 130 ++++++++++++++++++++ custom_components/sun2/sensor.py | 50 ++++---- custom_components/sun2/translations/en.json | 3 + custom_components/sun2/translations/nl.json | 3 + 6 files changed, 189 insertions(+), 97 deletions(-) create mode 100644 custom_components/sun2/config.py diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 8502083..c5d5c9d 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -3,72 +3,21 @@ from typing import cast -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT -from homeassistant.const import ( - CONF_BINARY_SENSORS, - CONF_LOCATION, - CONF_SENSORS, - CONF_UNIQUE_ID, - EVENT_CORE_CONFIG_UPDATE, - Platform, -) +from homeassistant.const import EVENT_CORE_CONFIG_UPDATE, Platform from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType -from .binary_sensor import SUN2_BINARY_SENSOR_SCHEMA from .const import DOMAIN, SIG_HA_LOC_UPDATED -from .helpers import LOC_PARAMS, LocData, LocParams, Sun2Data -from .sensor import ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA - - -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 +from .helpers import LocData, LocParams, Sun2Data PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -SUN2_CONFIG = vol.Schema( - { - vol.Required(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_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, - [vol.Any(ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA)], - ), - **LOC_PARAMS, - } -) - -CONFIG_SCHEMA = vol.Schema( - { - vol.Optional(DOMAIN, default=list): vol.All( - cv.ensure_list, [SUN2_CONFIG], _unique_locations_names - ), - }, - extra=vol.ALLOW_EXTRA, -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Setup composite integration.""" - hass.data[DOMAIN] = Sun2Data( - locations={}, - translations=await async_get_translations( - hass, hass.config.language, "misc", [DOMAIN], False - ), - ) def update_local_loc_data(event: Event | None = None) -> None: """Update local location data from HA's config.""" @@ -87,7 +36,7 @@ def update_local_loc_data(event: Event | None = None) -> None: update_local_loc_data() hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_local_loc_data) - for conf in config[DOMAIN]: + for conf in config.get(DOMAIN, []): hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=conf.copy() diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 5a6557f..b888c5f 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -65,7 +65,7 @@ # name: -def _val_cfg(config: str | ConfigType) -> ConfigType: +def val_bs_cfg(config: str | ConfigType) -> ConfigType: """Validate configuration.""" if isinstance(config, str): config = {config: {}} @@ -81,6 +81,14 @@ def _val_cfg(config: str | ConfigType) -> ConfigType: raise vol.Invalid(f"{key} not allowed for {CONF_ELEVATION}") if CONF_ABOVE not in options: options[CONF_ABOVE] = DEFAULT_ELEVATION_ABOVE + return config + + +def _val_cfg(config: str | ConfigType) -> ConfigType: + """Validate configuration including name.""" + config = val_bs_cfg(config) + if CONF_ELEVATION in config: + options = config[CONF_ELEVATION] if CONF_NAME not in options: above = options[CONF_ABOVE] if above == DEFAULT_ELEVATION_ABOVE: @@ -95,30 +103,29 @@ def _val_cfg(config: str | ConfigType) -> ConfigType: return config -SUN2_BINARY_SENSOR_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, - } - ), +SUN2_BINARY_SENSOR_SCHEMA = 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, + } ), - } - ), + ), + } ), - _val_cfg, ) +_SUN2_BINARY_SENSOR_SCHEMA_W_DEFAULTS = vol.All(SUN2_BINARY_SENSOR_SCHEMA, _val_cfg) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [SUN2_BINARY_SENSOR_SCHEMA] + cv.ensure_list, [_SUN2_BINARY_SENSOR_SCHEMA_W_DEFAULTS] ), **LOC_PARAMS, } diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py new file mode 100644 index 0000000..0140d19 --- /dev/null +++ b/custom_components/sun2/config.py @@ -0,0 +1,130 @@ +"""Sun2 config validation.""" +from __future__ import annotations + +from typing import cast + +import voluptuous as vol + +from homeassistant.const import ( + CONF_ABOVE, + CONF_BINARY_SENSORS, + CONF_ELEVATION, + CONF_LOCATION, + CONF_NAME, + CONF_SENSORS, + CONF_UNIQUE_ID, +) +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.translation import async_get_translations +from homeassistant.helpers.typing import ConfigType + +from .binary_sensor import ( + DEFAULT_ELEVATION_ABOVE, + SUN2_BINARY_SENSOR_SCHEMA, + val_bs_cfg, +) +from .const import CONF_ELEVATION_AT_TIME, DOMAIN +from .helpers import LOC_PARAMS, Sun2Data +from .sensor import ( + _eat_defaults, + _tae_defaults, + ELEVATION_AT_TIME_SCHEMA, + TIME_AT_ELEVATION_SCHEMA, +) + +_SUN2_LOCATION_CONFIG = vol.Schema( + { + vol.Required(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_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, + [vol.Any(ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA)], + ), + **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, +) + + +def _val_bs_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: + """Validate binary_sensor name.""" + if CONF_ELEVATION in config: + options = config[CONF_ELEVATION] + if CONF_NAME not in options: + above = options[CONF_ABOVE] + if above == DEFAULT_ELEVATION_ABOVE: + name = cast(Sun2Data, hass.data[DOMAIN]).translations[ + f"component.{DOMAIN}.misc.above_horizon" + ] + else: + above_str = cast(Sun2Data, hass.data[DOMAIN]).translations[ + f"component.{DOMAIN}.misc.above" + ] + if above < 0: + minus_str = cast(Sun2Data, hass.data[DOMAIN]).translations[ + f"component.{DOMAIN}.misc.minus" + ] + name = f"{above_str} {minus_str} {-above}" + else: + name = f"{above_str} {above}" + options[CONF_NAME] = name + return config + + +async def async_validate_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType | None: + """Validate configuration.""" + hass.data[DOMAIN] = Sun2Data( + locations={}, + translations=await async_get_translations( + hass, hass.config.language, "misc", [DOMAIN], False + ), + ) + + config = _SUN2_CONFIG_SCHEMA(config) + if DOMAIN not in config: + return config + if not config[DOMAIN]: + config[DOMAIN] = [{CONF_UNIQUE_ID: "home"}] + return config + + for loc_config in config[DOMAIN]: + if CONF_BINARY_SENSORS in loc_config: + loc_config[CONF_BINARY_SENSORS] = [ + _val_bs_name(hass, val_bs_cfg(cfg)) + for cfg in loc_config[CONF_BINARY_SENSORS] + ] + if CONF_SENSORS in loc_config: + sensor_configs = [] + for sensor_config in loc_config[CONF_SENSORS]: + if CONF_ELEVATION_AT_TIME in sensor_config: + sensor_configs.append(_eat_defaults(sensor_config)) + else: + sensor_configs.append(_tae_defaults(sensor_config)) + loc_config[CONF_SENSORS] = sensor_configs + return config diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 604e959..95171f9 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -1193,36 +1193,36 @@ def _eat_defaults(config: ConfigType) -> ConfigType: 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, +TIME_AT_ELEVATION_SCHEMA = 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, + } ) -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, +_TIME_AT_ELEVATION_SCHEMA_W_DEFAULTS = vol.All(TIME_AT_ELEVATION_SCHEMA, _tae_defaults) + +ELEVATION_AT_TIME_SCHEMA = 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, + } ) +_ELEVATION_AT_TIME_SCHEMA_W_DEFAULTS = vol.All(ELEVATION_AT_TIME_SCHEMA, _eat_defaults) + _SUN2_SENSOR_SCHEMA = vol.Any( - TIME_AT_ELEVATION_SCHEMA, ELEVATION_AT_TIME_SCHEMA, vol.In(_SENSOR_TYPES) + _TIME_AT_ELEVATION_SCHEMA_W_DEFAULTS, + _ELEVATION_AT_TIME_SCHEMA_W_DEFAULTS, + vol.In(_SENSOR_TYPES), ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index bb464ea..a02e83c 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -1,6 +1,9 @@ { "title": "Sun2", "misc": { + "above": "Above", + "above_horizon": "Above Horizon", + "minus": "minus", "service_name": "Sun" }, "entity": { diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json index 8afd374..2a19489 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -1,6 +1,9 @@ { "title": "Zon2", "misc": { + "above": "Boven", + "above_horizon": "Boven Horizon", + "minus": "min", "service_name": "Zon" }, "entity": { From ed7cdef4042d9665430aadd1e0c732058589168e Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 15 Nov 2023 16:17:03 -0600 Subject: [PATCH 28/59] More translations of default entity names --- custom_components/sun2/config.py | 69 +++++++++++++++------ custom_components/sun2/sensor.py | 35 ++++++----- custom_components/sun2/translations/en.json | 5 +- custom_components/sun2/translations/nl.json | 5 +- 4 files changed, 80 insertions(+), 34 deletions(-) diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 0140d19..226fc4d 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -3,6 +3,7 @@ from typing import cast +from astral import SunDirection import voluptuous as vol from homeassistant.const import ( @@ -24,14 +25,14 @@ SUN2_BINARY_SENSOR_SCHEMA, val_bs_cfg, ) -from .const import CONF_ELEVATION_AT_TIME, DOMAIN -from .helpers import LOC_PARAMS, Sun2Data -from .sensor import ( - _eat_defaults, - _tae_defaults, - ELEVATION_AT_TIME_SCHEMA, - TIME_AT_ELEVATION_SCHEMA, +from .const import ( + CONF_DIRECTION, + CONF_ELEVATION_AT_TIME, + CONF_TIME_AT_ELEVATION, + DOMAIN, ) +from .helpers import LOC_PARAMS, Sun2Data +from .sensor import val_tae_cfg, ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA _SUN2_LOCATION_CONFIG = vol.Schema( { @@ -70,6 +71,13 @@ def _unique_locations_names(configs: list[dict]) -> list[dict]: ) +def _translation(hass: HomeAssistant, key: str) -> str: + """Sun2 translations.""" + return cast(Sun2Data, hass.data[DOMAIN]).translations[ + f"component.{DOMAIN}.misc.{key}" + ] + + def _val_bs_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: """Validate binary_sensor name.""" if CONF_ELEVATION in config: @@ -77,17 +85,11 @@ def _val_bs_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: if CONF_NAME not in options: above = options[CONF_ABOVE] if above == DEFAULT_ELEVATION_ABOVE: - name = cast(Sun2Data, hass.data[DOMAIN]).translations[ - f"component.{DOMAIN}.misc.above_horizon" - ] + name = _translation(hass, "above_horizon") else: - above_str = cast(Sun2Data, hass.data[DOMAIN]).translations[ - f"component.{DOMAIN}.misc.above" - ] + above_str = _translation(hass, "above") if above < 0: - minus_str = cast(Sun2Data, hass.data[DOMAIN]).translations[ - f"component.{DOMAIN}.misc.minus" - ] + minus_str = _translation(hass, "minus") name = f"{above_str} {minus_str} {-above}" else: name = f"{above_str} {above}" @@ -95,6 +97,35 @@ def _val_bs_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: return config +def _val_eat_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: + """Validate elevation_at_time name.""" + if config.get(CONF_NAME): + return config + + config[ + CONF_NAME + ] = f"{_translation(hass, 'elevation_at')} {config[CONF_ELEVATION_AT_TIME]}" + + return config + + +def _val_tae_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: + """Validate time_at_elevation name.""" + if config.get(CONF_NAME): + return config + + direction = SunDirection(config[CONF_DIRECTION]) + elevation = cast(float, config[CONF_TIME_AT_ELEVATION]) + + if elevation >= 0: + elev_str = str(elevation) + else: + elev_str = f"{_translation(hass, 'minus')} {-elevation}" + config[CONF_NAME] = f"{_translation(hass, direction.name.lower())} at {elev_str} °" + + return config + + async def async_validate_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: @@ -123,8 +154,10 @@ async def async_validate_config( sensor_configs = [] for sensor_config in loc_config[CONF_SENSORS]: if CONF_ELEVATION_AT_TIME in sensor_config: - sensor_configs.append(_eat_defaults(sensor_config)) + sensor_configs.append(_val_eat_name(hass, sensor_config)) else: - sensor_configs.append(_tae_defaults(sensor_config)) + sensor_configs.append( + _val_tae_name(hass, val_tae_cfg(sensor_config)) + ) loc_config[CONF_SENSORS] = sensor_configs return config diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 95171f9..072697e 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -1164,29 +1164,36 @@ class SensorParams: } +def val_tae_cfg(config: ConfigType) -> ConfigType: + """Validate time_at_elevation config.""" + direction = SunDirection(config[CONF_DIRECTION]) + if not config.get(CONF_ICON): + config[CONF_ICON] = _DIR_TO_ICON[direction] + return config + + def _tae_defaults(config: ConfigType) -> ConfigType: - """Fill in defaults.""" + """Fill in defaults including name.""" + config = val_tae_cfg(config) - elevation = cast(float, config[CONF_TIME_AT_ELEVATION]) - direction = cast(SunDirection, config[CONF_DIRECTION]) + if config.get(CONF_NAME): + return config - if not config.get(CONF_ICON): - config[CONF_ICON] = _DIR_TO_ICON[direction] + direction = SunDirection(config[CONF_DIRECTION]) + elevation = cast(float, config[CONF_TIME_AT_ELEVATION]) - if not config.get(CONF_NAME): - 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} °" + 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.""" - + """Fill in defaults including name.""" if not config.get(CONF_NAME): config[CONF_NAME] = f"Elevation at {config[CONF_ELEVATION_AT_TIME]}" diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index a02e83c..fa39597 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -3,8 +3,11 @@ "misc": { "above": "Above", "above_horizon": "Above Horizon", + "elevation_at": "Elevation at", "minus": "minus", - "service_name": "Sun" + "rising": "Rising", + "service_name": "Sun", + "setting": "Setting" }, "entity": { "sensor": { diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json index 2a19489..e6a860e 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -3,8 +3,11 @@ "misc": { "above": "Boven", "above_horizon": "Boven Horizon", + "elevation_at": "Hoogte bij", "minus": "min", - "service_name": "Zon" + "rising": "Zonsopkomst", + "service_name": "Zon", + "setting": "Zonsondergang" }, "entity": { "sensor": { From 28c256e2593db9f159a38894329e2825772c7e04 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 15 Nov 2023 16:26:59 -0600 Subject: [PATCH 29/59] Bump version to 3.0.0b4 --- custom_components/sun2/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index c6b01ed..dbf35bf 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/pnbruckner/ha-sun2/issues", "requirements": [], - "version": "3.0.0b3" + "version": "3.0.0b4" } From 16c9c29cf8ac30d9b5feadd988a9a2698d24d099 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 16 Nov 2023 11:34:11 -0600 Subject: [PATCH 30/59] More translations, including attribute names & states --- custom_components/sun2/translations/en.json | 232 ++++++++++++++++---- custom_components/sun2/translations/nl.json | 208 +++++++++++++++--- 2 files changed, 376 insertions(+), 64 deletions(-) diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index fa39597..231a02f 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -2,7 +2,7 @@ "title": "Sun2", "misc": { "above": "Above", - "above_horizon": "Above Horizon", + "above_horizon": "Above horizon", "elevation_at": "Elevation at", "minus": "minus", "rising": "Rising", @@ -10,99 +10,255 @@ "setting": "Setting" }, "entity": { + "binary_sensor": { + "elevation": { + "state_attributes": { + "next_change": {"name": "Next change"} + } + } + }, "sensor": { "astronomical_daylight": { - "name": "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" + "name": "Astronomical dawn", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } }, "astronomical_dusk": { - "name": "Astronomical Dusk" + "name": "Astronomical dusk", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } }, "astronomical_night": { - "name": "Astronomical Night" - }, - "azimuth": { - "name": "Azimuth" + "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" + "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" + "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" + "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" + "name": "Dawn", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } }, "deconz_daylight": { - "name": "deCONZ Daylight", + "name": "deCONZ daylight", "state": { "dawn": "Dawn", "dusk": "Dusk", - "golden_hour_1": "Golden Hour 1", - "golden_hour_2": "Golden Hour 2", + "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" + "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" + "name": "Dusk", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } }, "elevation": { - "name": "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" + "name": "Maximum elevation", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } }, "min_elevation": { - "name": "Minimum Elevation" + "name": "Minimum elevation", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } }, "nautical_daylight": { - "name": "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" + "name": "Nautical dawn", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } }, "nautical_dusk": { - "name": "Nautical Dusk" + "name": "Nautical dusk", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } }, "nautical_night": { - "name": "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" + "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" + "name": "Solar midnight", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } }, "solar_noon": { - "name": "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", + "astronomical_twilight": "Astronomical twilight", + "civil_twilight": "Civil twilight", "day": "Day", - "nautical_twilight": "Nautical Twilight", + "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" + "name": "Rising", + "state_attributes": { + "today": {"name": "Today"}, + "tomorrow": {"name": "Tomorrow"}, + "yesterday": {"name": "Yesterday"} + } }, "sunset": { - "name": "Setting" + "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"} + } } } } diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json index e6a860e..4b3ab46 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -2,7 +2,7 @@ "title": "Zon2", "misc": { "above": "Boven", - "above_horizon": "Boven Horizon", + "above_horizon": "Boven horizon", "elevation_at": "Hoogte bij", "minus": "min", "rising": "Zonsopkomst", @@ -10,36 +10,96 @@ "setting": "Zonsondergang" }, "entity": { + "binary_sensor": { + "elevation": { + "state_attributes": { + "next_change": {"name": "Volgende wijziging"} + } + } + }, "sensor": { "astronomical_daylight": { - "name": "Astronomisch daglicht" + "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" + "name": "Astronomische dageraad", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "astronomical_dusk": { - "name": "Astronomische schemer" + "name": "Astronomische schemer", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "astronomical_night": { - "name": "Astronomische nacht" - }, - "azimuth": { - "name": "Azimut" + "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" + "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" + "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" + "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" + "name": "Dageraad", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "deconz_daylight": { - "name": "deCONZ Daglicht", + "name": "deCONZ daglicht", "state": { "dawn": "Dageraad", "dusk": "Schemer", @@ -53,40 +113,113 @@ "solar_noon": "Middagzon", "sunrise_end": "Zonsopkomsteinde", "sunrise_start": "Zonsopkomstbegin" + }, + "state_attributes": { + "daylight": {"name": "Daglicht"}, + "next_change": {"name": "Volgende wijziging"} } }, "dusk": { - "name": "Schemer" + "name": "Schemer", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "elevation": { - "name": "Hoogtehoek" + "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" + "name": "Maximale hoogtehoek", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "min_elevation": { - "name": "Minimale hoogtehoek" + "name": "Minimale hoogtehoek", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "nautical_daylight": { - "name": "Nautisch daglicht" + "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" + "name": "Nautische dageraad", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "nautical_dusk": { - "name": "Nautische schemer" + "name": "Nautische schemer", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "nautical_night": { - "name": "Nautische nacht" + "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" + "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" + "name": "Zonne-middernacht", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "solar_noon": { - "name": "Middagzon" + "name": "Middagzon", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "sun_phase": { "name": "Fase", @@ -96,13 +229,36 @@ "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" + "name": "Zonsopkomst", + "state_attributes": { + "today": {"name": "Vandaag"}, + "tomorrow": {"name": "Morgen"}, + "yesterday": {"name": "Gisteren"} + } }, "sunset": { - "name": "Zonsondergang" + "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"} + } } } } From e497d26add523097b9d0acec4e9c35da5565a9bd Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 16 Nov 2023 11:37:32 -0600 Subject: [PATCH 31/59] Add PACKAGE_MERGE_HINT --- custom_components/sun2/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 226fc4d..d9ad2dd 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -34,6 +34,8 @@ from .helpers import LOC_PARAMS, Sun2Data from .sensor import val_tae_cfg, ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA +PACKAGE_MERGE_HINT = "list" + _SUN2_LOCATION_CONFIG = vol.Schema( { vol.Required(CONF_UNIQUE_ID): cv.string, From e3fa93358634150196f741aee99ed6cb5aec556d Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 16 Nov 2023 12:04:49 -0600 Subject: [PATCH 32/59] Add _unreported_attributes --- custom_components/sun2/helpers.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index e69f366..177a3c3 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -32,7 +32,17 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .const import DOMAIN, ONE_DAY, SIG_HA_LOC_UPDATED +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] @@ -117,6 +127,16 @@ def next_midnight(dttm: datetime) -> datetime: class Sun2Entity(Entity): """Sun2 Entity.""" + _unreported_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 From 29e3c633783515b90a0556f833770a8b8c51d0c6 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 16 Nov 2023 13:10:14 -0600 Subject: [PATCH 33/59] Make elevation binary sensor config similar to others --- custom_components/sun2/binary_sensor.py | 26 +++++++++--- custom_components/sun2/config.py | 56 ++++++++++++++----------- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index b888c5f..6cf5063 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -65,7 +65,7 @@ # name: -def val_bs_cfg(config: str | ConfigType) -> ConfigType: +def _val_bs_cfg(config: str | ConfigType) -> ConfigType: """Validate configuration.""" if isinstance(config, str): config = {config: {}} @@ -86,7 +86,7 @@ def val_bs_cfg(config: str | ConfigType) -> ConfigType: def _val_cfg(config: str | ConfigType) -> ConfigType: """Validate configuration including name.""" - config = val_bs_cfg(config) + config = _val_bs_cfg(config) if CONF_ELEVATION in config: options = config[CONF_ELEVATION] if CONF_NAME not in options: @@ -330,7 +330,7 @@ def schedule_update(now: datetime) -> None: self._attr_extra_state_attributes = {ATTR_NEXT_CHANGE: nxt_dttm} -def _sensors( +def _sensors_old( loc_params: LocParams | None, extra: ConfigEntry | str | None, sensors_config: Iterable[str | dict[str, Any]], @@ -347,6 +347,22 @@ def _sensors( return sensors +def _sensors_new( + loc_params: LocParams | None, + extra: ConfigEntry | str | None, + sensors_config: Iterable[str | dict[str, Any]], +) -> list[Entity]: + sensors = [] + for config in sensors_config: + if CONF_ELEVATION in config: + sensors.append( + Sun2ElevationSensor( + loc_params, extra, config[CONF_NAME], config[CONF_ELEVATION] + ) + ) + return sensors + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -363,7 +379,7 @@ async def async_setup_platform( ) async_add_entities( - _sensors( + _sensors_old( get_loc_params(config), config.get(CONF_ENTITY_NAMESPACE), config[CONF_MONITORED_CONDITIONS], @@ -383,6 +399,6 @@ async def async_setup_entry( return async_add_entities( - _sensors(get_loc_params(config), entry, sensors_config), + _sensors_new(get_loc_params(config), entry, sensors_config), True, ) diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index d9ad2dd..37b8dae 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -7,7 +7,6 @@ import voluptuous as vol from homeassistant.const import ( - CONF_ABOVE, CONF_BINARY_SENSORS, CONF_ELEVATION, CONF_LOCATION, @@ -20,28 +19,34 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType -from .binary_sensor import ( - DEFAULT_ELEVATION_ABOVE, - SUN2_BINARY_SENSOR_SCHEMA, - val_bs_cfg, -) from .const import ( CONF_DIRECTION, CONF_ELEVATION_AT_TIME, CONF_TIME_AT_ELEVATION, DOMAIN, + SUNSET_ELEV, ) from .helpers import LOC_PARAMS, Sun2Data from .sensor import val_tae_cfg, ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA PACKAGE_MERGE_HINT = "list" +DEFAULT_ELEVATION = SUNSET_ELEV + +_SUN2_BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_ELEVATION): vol.Any( + vol.All(vol.Lower, "horizon"), vol.Coerce(float) + ), + vol.Optional(CONF_NAME): cv.string, + } +) _SUN2_LOCATION_CONFIG = vol.Schema( { vol.Required(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_LOCATION): cv.string, vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [SUN2_BINARY_SENSOR_SCHEMA] + cv.ensure_list, [_SUN2_BINARY_SENSOR_SCHEMA] ), vol.Optional(CONF_SENSORS): vol.All( cv.ensure_list, @@ -80,22 +85,24 @@ def _translation(hass: HomeAssistant, key: str) -> str: ] -def _val_bs_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: - """Validate binary_sensor name.""" - if CONF_ELEVATION in config: - options = config[CONF_ELEVATION] - if CONF_NAME not in options: - above = options[CONF_ABOVE] - if above == DEFAULT_ELEVATION_ABOVE: - name = _translation(hass, "above_horizon") - else: - above_str = _translation(hass, "above") - if above < 0: - minus_str = _translation(hass, "minus") - name = f"{above_str} {minus_str} {-above}" - else: - name = f"{above_str} {above}" - options[CONF_NAME] = name +def _val_bs_elevation(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: + """Validate elevation binary_sensor.""" + if config[CONF_ELEVATION] == "horizon": + config[CONF_ELEVATION] = DEFAULT_ELEVATION + + if config.get(CONF_NAME): + return config + + if (elv := config[CONF_ELEVATION]) == DEFAULT_ELEVATION: + name = _translation(hass, "above_horizon") + else: + above_str = _translation(hass, "above") + if elv < 0: + minus_str = _translation(hass, "minus") + name = f"{above_str} {minus_str} {-elv}" + else: + name = f"{above_str} {elv}" + config[CONF_NAME] = name return config @@ -149,8 +156,7 @@ async def async_validate_config( for loc_config in config[DOMAIN]: if CONF_BINARY_SENSORS in loc_config: loc_config[CONF_BINARY_SENSORS] = [ - _val_bs_name(hass, val_bs_cfg(cfg)) - for cfg in loc_config[CONF_BINARY_SENSORS] + _val_bs_elevation(hass, cfg) for cfg in loc_config[CONF_BINARY_SENSORS] ] if CONF_SENSORS in loc_config: sensor_configs = [] From 5234667628e9dd8d1fafa796e22a0daf021dc3c7 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 16 Nov 2023 20:31:04 -0600 Subject: [PATCH 34/59] Add required unique_id for non-simple entity configs --- custom_components/sun2/binary_sensor.py | 12 +++++++++-- custom_components/sun2/config.py | 9 ++++++++ custom_components/sun2/helpers.py | 5 ++++- custom_components/sun2/sensor.py | 28 ++++++++++++++++++++----- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 6cf5063..ba230f4 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -22,6 +22,7 @@ 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 @@ -141,6 +142,7 @@ def __init__( extra: ConfigEntry | str | None, name: str, above: float, + unique_id: str | None = None, ) -> None: """Initialize sensor.""" if not isinstance(extra, ConfigEntry): @@ -151,7 +153,9 @@ def __init__( self.entity_description = BinarySensorEntityDescription( key=CONF_ELEVATION, name=name ) - super().__init__(loc_params, extra if isinstance(extra, ConfigEntry) else None) + super().__init__( + loc_params, extra if isinstance(extra, ConfigEntry) else None, unique_id + ) self._event = "solar_elevation" self._threshold: float = above @@ -357,7 +361,11 @@ def _sensors_new( if CONF_ELEVATION in config: sensors.append( Sun2ElevationSensor( - loc_params, extra, config[CONF_NAME], config[CONF_ELEVATION] + loc_params, + extra, + config[CONF_NAME], + config[CONF_ELEVATION], + config[CONF_UNIQUE_ID], ) ) return sensors diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 37b8dae..42039ee 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -34,6 +34,7 @@ _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) ), @@ -41,6 +42,14 @@ } ) +ELEVATION_AT_TIME_SCHEMA = ELEVATION_AT_TIME_SCHEMA.extend( + {vol.Required(CONF_UNIQUE_ID): cv.string} +) + +TIME_AT_ELEVATION_SCHEMA = TIME_AT_ELEVATION_SCHEMA.extend( + {vol.Required(CONF_UNIQUE_ID): cv.string} +) + _SUN2_LOCATION_CONFIG = vol.Schema( { vol.Required(CONF_UNIQUE_ID): cv.string, diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 177a3c3..849b198 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -148,6 +148,7 @@ def __init__( self, loc_params: LocParams | None, entry: ConfigEntry | None, + unique_id: str | None = None, ) -> None: """Initialize base class. @@ -162,7 +163,9 @@ def __init__( identifiers={(DOMAIN, entry.entry_id)}, name=entry.title, ) - self._attr_unique_id = f"{entry.title} {self.entity_description.name}" + self._attr_unique_id = ( + f"{entry.unique_id}-{unique_id or self.entity_description.key}" + ) else: self._attr_unique_id = self.name self._loc_params = loc_params diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 072697e..9afabcd 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -31,6 +31,7 @@ CONF_NAME, CONF_PLATFORM, CONF_SENSORS, + CONF_UNIQUE_ID, DEGREE, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, @@ -179,6 +180,7 @@ def __init__( entity_description: SensorEntityDescription, default_solar_depression: Num | str = 0, name: str | None = None, + unique_id: str | None = None, ) -> None: """Initialize sensor.""" key = entity_description.key @@ -195,7 +197,7 @@ def __init__( entry = None entity_description.name = name self.entity_description = entity_description - super().__init__(loc_params, entry) + super().__init__(loc_params, entry, unique_id) if any(key.startswith(sol_dep + "_") for sol_dep in _SOLAR_DEPRESSIONS): self._solar_depression, self._event = key.rsplit("_", 1) @@ -258,6 +260,7 @@ def __init__( extra: ConfigEntry | str | None, name: str, at_time: str | time, + unique_id: str | None, ) -> None: """Initialize sensor.""" if isinstance(at_time, str): @@ -271,7 +274,9 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ) - super().__init__(loc_params, extra, entity_description, name=name) + super().__init__( + loc_params, extra, entity_description, name=name, unique_id=unique_id + ) self._event = "solar_elevation" @property @@ -376,6 +381,7 @@ def __init__( sensor_type: str, icon: str | None, name: str | None = None, + unique_id: str | None = None, ) -> None: """Initialize sensor.""" entity_description = SensorEntityDescription( @@ -383,7 +389,9 @@ def __init__( device_class=SensorDeviceClass.TIMESTAMP, icon=icon, ) - super().__init__(loc_params, extra, entity_description, "civil", name) + super().__init__( + loc_params, extra, entity_description, "civil", name, unique_id + ) class Sun2TimeAtElevationSensor(Sun2PointInTimeSensor): @@ -397,11 +405,14 @@ def __init__( icon: str | None, direction: SunDirection, elevation: float, + unique_id: str | None, ) -> None: """Initialize sensor.""" self._direction = direction self._elevation = elevation - super().__init__(loc_params, extra, CONF_TIME_AT_ELEVATION, icon, name) + super().__init__( + loc_params, extra, CONF_TIME_AT_ELEVATION, icon, name, unique_id + ) def _astral_event( self, @@ -1264,6 +1275,7 @@ def _sensors( config[CONF_ICON], SunDirection(config[CONF_DIRECTION]), config[CONF_TIME_AT_ELEVATION], + config.get(CONF_UNIQUE_ID), ) ) else: @@ -1274,7 +1286,13 @@ def _sensors( with suppress(ValueError): at_time = time.fromisoformat(at_time) sensors.append( - Sun2ElevationAtTimeSensor(loc_params, extra, config[CONF_NAME], at_time) + Sun2ElevationAtTimeSensor( + loc_params, + extra, + config[CONF_NAME], + at_time, + config.get(CONF_UNIQUE_ID), + ) ) return sensors From d176d55d0049dc266dec74c2f74117b8088a6f21 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 17 Nov 2023 13:54:52 -0600 Subject: [PATCH 35/59] Better handle core config update --- custom_components/sun2/__init__.py | 36 +++++++++++++++++++-------- custom_components/sun2/config.py | 9 +++---- custom_components/sun2/config_flow.py | 4 +-- custom_components/sun2/helpers.py | 6 ++--- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index c5d5c9d..10fab3c 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -7,6 +7,7 @@ from homeassistant.const import EVENT_CORE_CONFIG_UPDATE, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, SIG_HA_LOC_UPDATED @@ -19,7 +20,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Setup composite integration.""" - def update_local_loc_data(event: Event | None = None) -> None: + 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( @@ -29,19 +30,34 @@ def update_local_loc_data(event: Event | None = None) -> None: str(hass.config.time_zone), ) ) - if event: - # Signal all instances that location data has changed. - dispatcher_send(hass, SIG_HA_LOC_UPDATED, loc_data) + return loc_data update_local_loc_data() - hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_local_loc_data) - for conf in config.get(DOMAIN, []): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf.copy() + def process_config(config: ConfigType) -> None: + """Process sun2 config.""" + for conf in config.get(DOMAIN, []): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf.copy() + ) ) - ) + + 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 + + process_config(await async_integration_yaml_config(hass, DOMAIN)) + + hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, handle_core_config_update) + process_config(config) return True diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 42039ee..05daea2 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -148,11 +148,10 @@ async def async_validate_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: """Validate configuration.""" - hass.data[DOMAIN] = Sun2Data( - locations={}, - translations=await async_get_translations( - hass, hass.config.language, "misc", [DOMAIN], False - ), + hass.data.setdefault( + DOMAIN, Sun2Data() + ).translations = await async_get_translations( + hass, hass.config.language, "misc", [DOMAIN], False ) config = _SUN2_CONFIG_SCHEMA(config) diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index ff26c8f..14af27c 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -18,11 +18,11 @@ class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Import config entry from configuration.""" - location = data.pop(CONF_LOCATION, self.hass.config.location_name) + location_name = data.pop(CONF_LOCATION, self.hass.config.location_name) service_name = cast(Sun2Data, self.hass.data[DOMAIN]).translations[ f"component.{DOMAIN}.misc.service_name" ] - title = f"{location} {service_name}" + title = f"{location_name} {service_name}" if existing_entry := await self.async_set_unique_id(data.pop(CONF_UNIQUE_ID)): self.hass.config_entries.async_update_entry( existing_entry, title=title, options=data diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 849b198..caa086f 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -3,7 +3,7 @@ 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 @@ -84,8 +84,8 @@ def __init__(self, lp: LocParams) -> None: class Sun2Data: """Sun2 shared data.""" - locations: dict[LocParams | None, LocData] - translations: dict[str, str] + locations: dict[LocParams | None, LocData] = field(default_factory=dict) + translations: dict[str, str] = field(default_factory=dict) def get_loc_params(config: ConfigType) -> LocParams | None: From 78fa1380d295298f736d23de72be8d9bd693c217 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 17 Nov 2023 14:57:57 -0600 Subject: [PATCH 36/59] Update README.md --- README.md | 148 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 85 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 517a0e1..d3bc472 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ Follow the installation instructions below. Then add the desired configuration. Here is an example of a typical configuration: ```yaml sun2: - - unique_id: home ``` ## Installation @@ -37,18 +36,18 @@ This custom integration supports HomeAssistant versions 2023.4.0 or newer. ## Configuration variables -A list of one or more dictionaries with the following options. +A list of configuration options for one or more "locations". Each location is defined by the following options. Key | Optional | Description -|-|- -`unique_id` | no | Unique identifier for group of options. +`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. Default is Home Assistant's current location name. -`latitude` | yes* | The location's latitude (in degrees.) -`longitude` | yes* | The location's longitude (in degrees.) +`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). +`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 configuration. @@ -58,21 +57,28 @@ A list of 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: +`'on'` when sun's elevation is above threshold, `'off'` when at or below threshold. -```yaml -elevation +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 -elevation: THRESHOLD +For example, this: -elevation: - above: THRESHOLD - name: FRIENDLY_NAME +```yaml +- unique_id: bs1 + elevation: horizon ``` -Default THRESHOLD (as with first format) is -0.833 (same as sunrise/sunset). +Would be equivalent to: -Default FRIENDLY_NAME is "Above Horizon" if THRESHOLD is -0.833, "Above minus THRESHOLD" if THRESHOLD is negative, otherwise "Above THRESHOLD". +```yaml +- unique_id: bs1 + elevation: -0.833 + name: Above horizon +``` ### Sensor Configurations @@ -82,21 +88,24 @@ A list of one or more of the following. 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 ° @@ -106,8 +115,9 @@ Would be equivalent to: 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. @@ -125,15 +135,15 @@ Some of these will be enabled by default. The rest will be disabled by default. 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. +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. +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. +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) @@ -141,14 +151,14 @@ 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. +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 @@ -156,11 +166,11 @@ These are also all disabled by default. 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). +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) ##### Sun Phase Sensor @@ -179,7 +189,7 @@ Day | Sun is above -0.833° Attribute | Description -|- -`rising` | `True` if sun is rising. +`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° @@ -189,20 +199,26 @@ Attribute | Description sun2: - unique_id: home binary_sensors: - - elevation - - elevation: 3 - - elevation: - above: -6 - name: Above Civil Dawn + - unique_id: bs1 + elevation: horizon + - unique_id: bs2 + elevation: 3 + - unique_id: bs3 + elevation: -6 + name: Above Civil Dawn sensors: - - time_at_elevation: 10 - - time_at_elevation: -10 + - 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 - - elevation_at_time: '12:00' + - unique_id: s3 + elevation_at_time: '12:00' name: Elv @ noon - - elevation_at_time: input_datetime.test + - unique_id: s4 + elevation_at_time: input_datetime.test name: Elv @ test var - unique_id: london @@ -212,19 +228,25 @@ sun2: time_zone: Europe/London elevation: 11 binary_sensors: - - elevation - - elevation: 3 - - elevation: - above: -6 - name: Above Civil Dawn + - unique_id: bs1 + elevation + - unique_id: bs2 + elevation: 3 + - unique_id: bs3 + elevation: -6 + name: Above Civil Dawn sensors: - - time_at_elevation: 10 - - time_at_elevation: -10 + - 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 - - elevation_at_time: '12:00' + - unique_id: s3 + elevation_at_time: '12:00' name: Elv @ noon - - elevation_at_time: input_datetime.test + - unique_id: s4 + elevation_at_time: input_datetime.test name: Elv @ test var ``` From de8e2477c47d75a8e9e9fc96908eb7b5d255c73c Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 17 Nov 2023 14:59:10 -0600 Subject: [PATCH 37/59] Bump version to 3.0.0b5 --- custom_components/sun2/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index dbf35bf..204eb53 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/pnbruckner/ha-sun2/issues", "requirements": [], - "version": "3.0.0b4" + "version": "3.0.0b5" } From 5cab09a216f36f26af2655c0498a55baca8a29a9 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 17 Nov 2023 17:06:15 -0600 Subject: [PATCH 38/59] Add reload service --- custom_components/sun2/__init__.py | 15 +++++++++++---- custom_components/sun2/services.yaml | 1 + custom_components/sun2/translations/en.json | 6 ++++++ custom_components/sun2/translations/nl.json | 6 ++++++ 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 custom_components/sun2/services.yaml diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 10fab3c..13b3005 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -4,10 +4,11 @@ from typing import cast from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT -from homeassistant.const import EVENT_CORE_CONFIG_UPDATE, Platform -from homeassistant.core import Event, HomeAssistant +from homeassistant.const import EVENT_CORE_CONFIG_UPDATE, Platform, SERVICE_RELOAD +from homeassistant.core import Event, HomeAssistant, ServiceCall 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 DOMAIN, SIG_HA_LOC_UPDATED @@ -43,6 +44,12 @@ def process_config(config: ConfigType) -> None: ) ) + process_config(config) + + async def reload_config(call: ServiceCall | None = None) -> None: + """Reload configuration.""" + 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: @@ -54,10 +61,10 @@ async def handle_core_config_update(event: Event) -> None: dispatcher_send(hass, SIG_HA_LOC_UPDATED, loc_data) return - process_config(await async_integration_yaml_config(hass, DOMAIN)) + await reload_config() + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, reload_config) hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, handle_core_config_update) - process_config(config) return True diff --git a/custom_components/sun2/services.yaml b/custom_components/sun2/services.yaml new file mode 100644 index 0000000..c983a10 --- /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 index 231a02f..7f33cc4 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -261,5 +261,11 @@ } } } + }, + "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 index 4b3ab46..716aa54 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -261,5 +261,11 @@ } } } + }, + "services": { + "reload": { + "name": "Herlaadt", + "description": "Herlaadt Zon2 vanuit de YAML-configuratie." + } } } \ No newline at end of file From 3fcd7613a0a468a6f292068f2cf5b49a0a58adcd Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 18 Nov 2023 07:38:06 -0600 Subject: [PATCH 39/59] Update README.md --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/README.md b/README.md index d3bc472..3a13bb1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ where `` is your Home Assistant configuration directory. This custom integration supports HomeAssistant versions 2023.4.0 or newer. +## Services + +### `sun2.reload` + +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. @@ -250,3 +256,67 @@ sun2: elevation_at_time: input_datetime.test name: Elv @ test var ``` + +## Converting from `platform` configuration + +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. + +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: + - dawn + - sunrise + - sunset + - dusk + - 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 +``` + +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 + 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. From 162843942095486a0d47a517594e8906e3e540c4 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 18 Nov 2023 07:39:51 -0600 Subject: [PATCH 40/59] Bump version to 3.0.0b6 --- custom_components/sun2/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index 204eb53..0d696f9 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/pnbruckner/ha-sun2/issues", "requirements": [], - "version": "3.0.0b5" + "version": "3.0.0b6" } From 82572d290ff8769ad977c5b434c171f5c8ba79e7 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 18 Nov 2023 10:28:28 -0600 Subject: [PATCH 41/59] Use placeholders in misc translations --- custom_components/sun2/config.py | 34 +++++++-------------- custom_components/sun2/config_flow.py | 12 ++++---- custom_components/sun2/helpers.py | 16 +++++++++- custom_components/sun2/translations/en.json | 14 +++++---- custom_components/sun2/translations/nl.json | 14 +++++---- 5 files changed, 48 insertions(+), 42 deletions(-) diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 05daea2..7f2176d 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -26,7 +26,7 @@ DOMAIN, SUNSET_ELEV, ) -from .helpers import LOC_PARAMS, Sun2Data +from .helpers import LOC_PARAMS, Sun2Data, translation from .sensor import val_tae_cfg, ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA PACKAGE_MERGE_HINT = "list" @@ -87,13 +87,6 @@ def _unique_locations_names(configs: list[dict]) -> list[dict]: ) -def _translation(hass: HomeAssistant, key: str) -> str: - """Sun2 translations.""" - return cast(Sun2Data, hass.data[DOMAIN]).translations[ - f"component.{DOMAIN}.misc.{key}" - ] - - def _val_bs_elevation(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: """Validate elevation binary_sensor.""" if config[CONF_ELEVATION] == "horizon": @@ -102,15 +95,13 @@ def _val_bs_elevation(hass: HomeAssistant, config: str | ConfigType) -> ConfigTy if config.get(CONF_NAME): return config - if (elv := config[CONF_ELEVATION]) == DEFAULT_ELEVATION: - name = _translation(hass, "above_horizon") + if (elevation := config[CONF_ELEVATION]) == DEFAULT_ELEVATION: + name = translation(hass, "above_horizon") else: - above_str = _translation(hass, "above") - if elv < 0: - minus_str = _translation(hass, "minus") - name = f"{above_str} {minus_str} {-elv}" + if elevation < 0: + name = translation(hass, "above_neg_elev", {"elevation": str(-elevation)}) else: - name = f"{above_str} {elv}" + name = translation(hass, "above_pos_elev", {"elevation": str(elevation)}) config[CONF_NAME] = name return config @@ -120,9 +111,9 @@ def _val_eat_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: if config.get(CONF_NAME): return config - config[ - CONF_NAME - ] = f"{_translation(hass, 'elevation_at')} {config[CONF_ELEVATION_AT_TIME]}" + config[CONF_NAME] = translation( + hass, "elevation_at", {"elev_time": str(config[CONF_ELEVATION_AT_TIME])} + ) return config @@ -135,11 +126,8 @@ def _val_tae_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: direction = SunDirection(config[CONF_DIRECTION]) elevation = cast(float, config[CONF_TIME_AT_ELEVATION]) - if elevation >= 0: - elev_str = str(elevation) - else: - elev_str = f"{_translation(hass, 'minus')} {-elevation}" - config[CONF_NAME] = f"{_translation(hass, direction.name.lower())} at {elev_str} °" + trans_key = f"{direction.name.lower()}_{'neg' if elevation < 0 else 'pos'}_elev" + config[CONF_NAME] = translation(hass, trans_key, {"elevation": str(abs(elevation))}) return config diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index 14af27c..e236194 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -8,7 +8,7 @@ from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN -from .helpers import Sun2Data +from .helpers import Sun2Data, translation class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): @@ -18,11 +18,11 @@ class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Import config entry from configuration.""" - location_name = data.pop(CONF_LOCATION, self.hass.config.location_name) - service_name = cast(Sun2Data, self.hass.data[DOMAIN]).translations[ - f"component.{DOMAIN}.misc.service_name" - ] - title = f"{location_name} {service_name}" + title = translation( + self.hass, + "service_name", + {"location": data.pop(CONF_LOCATION, self.hass.config.location_name)}, + ) if existing_entry := await self.async_set_unique_id(data.pop(CONF_UNIQUE_ID)): self.hass.config_entries.async_update_entry( existing_entry, title=title, options=data diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index caa086f..ba4e17f 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -18,7 +18,7 @@ CONF_LONGITUDE, CONF_TIME_ZONE, ) -from homeassistant.core import CALLBACK_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType @@ -109,6 +109,20 @@ def hours_to_hms(hours: Num | None) -> str | None: return None +def translation( + hass: HomeAssistant, key: str, placeholders: dict[str, str] | None = None +) -> str: + """Sun2 translations.""" + trans = cast(Sun2Data, hass.data[DOMAIN]).translations[ + f"component.{DOMAIN}.misc.{key}" + ] + if not placeholders: + return trans + for key, val in placeholders.items(): + trans = trans.replace(f"{{{key}}}", val) + return trans + + _Num = TypeVar("_Num", bound=Num) diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index 7f33cc4..316d998 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -1,13 +1,15 @@ { "title": "Sun2", "misc": { - "above": "Above", "above_horizon": "Above horizon", - "elevation_at": "Elevation at", - "minus": "minus", - "rising": "Rising", - "service_name": "Sun", - "setting": "Setting" + "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} °" }, "entity": { "binary_sensor": { diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json index 716aa54..3a68dd3 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -1,13 +1,15 @@ { "title": "Zon2", "misc": { - "above": "Boven", "above_horizon": "Boven horizon", - "elevation_at": "Hoogte bij", - "minus": "min", - "rising": "Zonsopkomst", - "service_name": "Zon", - "setting": "Zonsondergang" + "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} °" }, "entity": { "binary_sensor": { From 71438905a1437d085c4c3c8345227cd21f31ecdb Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 18 Nov 2023 10:32:54 -0600 Subject: [PATCH 42/59] Bump version to 3.0.0b7 --- custom_components/sun2/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index 0d696f9..fda6276 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/pnbruckner/ha-sun2/issues", "requirements": [], - "version": "3.0.0b6" + "version": "3.0.0b7" } From 75d6c5d28d95007b92f7e1ba917657fd7d159a14 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 21 Nov 2023 15:35:25 -0600 Subject: [PATCH 43/59] Add sun2 integration via UI Do not add "home" location when sun2 YAML config is empty. Remove imported configs no longer in YAML. Reload configs whenever updated (e.g., when config title changed.) --- custom_components/sun2/__init__.py | 58 ++++++- custom_components/sun2/binary_sensor.py | 59 +++---- custom_components/sun2/config.py | 23 ++- custom_components/sun2/config_flow.py | 170 ++++++++++++++++++-- custom_components/sun2/helpers.py | 37 +++-- custom_components/sun2/sensor.py | 119 +++++++------- custom_components/sun2/services.yaml | 2 +- custom_components/sun2/translations/en.json | 49 ++++++ custom_components/sun2/translations/nl.json | 49 ++++++ 9 files changed, 425 insertions(+), 141 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 13b3005..878d6a5 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -1,10 +1,17 @@ """Sun2 integration.""" from __future__ import annotations +import asyncio from typing import cast from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT -from homeassistant.const import EVENT_CORE_CONFIG_UPDATE, Platform, SERVICE_RELOAD +from homeassistant.const import ( + CONF_LATITUDE, + CONF_UNIQUE_ID, + EVENT_CORE_CONFIG_UPDATE, + Platform, + SERVICE_RELOAD, +) from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.reload import async_integration_yaml_config @@ -33,22 +40,37 @@ def update_local_loc_data() -> LocData: ) return loc_data - update_local_loc_data() - - def process_config(config: ConfigType) -> None: + async def process_config(config: ConfigType, run_immediately: bool = True) -> None: """Process sun2 config.""" - for conf in config.get(DOMAIN, []): - hass.async_create_task( + configs = config.get(DOMAIN, []) + unique_ids = [config[CONF_UNIQUE_ID] for config in configs] + tasks = [] + + 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() ) ) - process_config(config) + 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.""" - process_config(await async_integration_yaml_config(hass, DOMAIN)) + await process_config(await async_integration_yaml_config(hass, DOMAIN)) async def handle_core_config_update(event: Event) -> None: """Handle core config update.""" @@ -56,21 +78,41 @@ async def handle_core_config_update(event: Event) -> None: 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.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up config entry.""" + entry.async_on_unload(entry.add_update_listener(entry_updated)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index ba230f4..994f822 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -45,7 +45,9 @@ LOC_PARAMS, LocParams, Num, + sun2_dev_info, Sun2Entity, + Sun2EntityParams, get_loc_params, nearest_second, ) @@ -139,23 +141,21 @@ class Sun2ElevationSensor(Sun2Entity, BinarySensorEntity): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, name: str, above: float, - unique_id: str | None = None, ) -> None: """Initialize sensor.""" - if not isinstance(extra, ConfigEntry): + 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, extra if isinstance(extra, ConfigEntry) else None, unique_id - ) + super().__init__(loc_params, extra) self._event = "solar_elevation" self._threshold: float = above @@ -334,40 +334,23 @@ def schedule_update(now: datetime) -> None: self._attr_extra_state_attributes = {ATTR_NEXT_CHANGE: nxt_dttm} -def _sensors_old( +def _sensors( loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, sensors_config: Iterable[str | dict[str, Any]], ) -> list[Entity]: + """Create list of entities to add.""" sensors = [] for config in sensors_config: if CONF_ELEVATION in config: - options = config[CONF_ELEVATION] - sensors.append( - Sun2ElevationSensor( - loc_params, extra, options[CONF_NAME], options[CONF_ABOVE] - ) - ) - return sensors - - -def _sensors_new( - loc_params: LocParams | None, - extra: ConfigEntry | str | None, - sensors_config: Iterable[str | dict[str, Any]], -) -> list[Entity]: - sensors = [] - for config in sensors_config: - if CONF_ELEVATION in config: - sensors.append( - Sun2ElevationSensor( - loc_params, - extra, - config[CONF_NAME], - config[CONF_ELEVATION], - config[CONF_UNIQUE_ID], - ) - ) + if isinstance(extra, Sun2EntityParams): + extra.unique_id = config[CONF_UNIQUE_ID] + name = config[CONF_NAME] + above = config[CONF_ELEVATION] + else: + name = config[CONF_ELEVATION][CONF_NAME] + above = config[CONF_ELEVATION][CONF_ABOVE] + sensors.append(Sun2ElevationSensor(loc_params, extra, name, above)) return sensors @@ -387,7 +370,7 @@ async def async_setup_platform( ) async_add_entities( - _sensors_old( + _sensors( get_loc_params(config), config.get(CONF_ENTITY_NAMESPACE), config[CONF_MONITORED_CONDITIONS], @@ -407,6 +390,10 @@ async def async_setup_entry( return async_add_entities( - _sensors_new(get_loc_params(config), entry, sensors_config), + _sensors( + get_loc_params(config), + Sun2EntityParams(entry, sun2_dev_info(hass, entry)), + sensors_config, + ), True, ) diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 7f2176d..70096b1 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -24,6 +24,7 @@ CONF_ELEVATION_AT_TIME, CONF_TIME_AT_ELEVATION, DOMAIN, + LOGGER, SUNSET_ELEV, ) from .helpers import LOC_PARAMS, Sun2Data, translation @@ -36,7 +37,9 @@ { vol.Required(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_ELEVATION): vol.Any( - vol.All(vol.Lower, "horizon"), vol.Coerce(float) + vol.All(vol.Lower, "horizon"), + vol.Coerce(float), + msg="must be a float or the word horizon", ), vol.Optional(CONF_NAME): cv.string, } @@ -50,6 +53,16 @@ {vol.Required(CONF_UNIQUE_ID): cv.string} ) + +def _sensor(config: ConfigType) -> ConfigType: + """Validate sensor config.""" + if CONF_ELEVATION_AT_TIME in config: + return ELEVATION_AT_TIME_SCHEMA(config) + if CONF_TIME_AT_ELEVATION in config: + return TIME_AT_ELEVATION_SCHEMA(config) + raise vol.Invalid("expected elevation_at_time or time_at_elevation") + + _SUN2_LOCATION_CONFIG = vol.Schema( { vol.Required(CONF_UNIQUE_ID): cv.string, @@ -57,10 +70,7 @@ vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [_SUN2_BINARY_SENSOR_SCHEMA] ), - vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, - [vol.Any(ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA)], - ), + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [_sensor]), **LOC_PARAMS, } ) @@ -145,9 +155,6 @@ async def async_validate_config( config = _SUN2_CONFIG_SCHEMA(config) if DOMAIN not in config: return config - if not config[DOMAIN]: - config[DOMAIN] = [{CONF_UNIQUE_ID: "home"}] - return config for loc_config in config[DOMAIN]: if CONF_BINARY_SENSORS in loc_config: diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index e236194..a278664 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -1,35 +1,171 @@ """Config flow for Sun2 integration.""" from __future__ import annotations -from typing import cast, Any +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import Any -from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_LOCATION, CONF_UNIQUE_ID +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, + SOURCE_IMPORT, +) +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, + CONF_TIME_ZONE, + CONF_UNIQUE_ID, +) +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv from .const import DOMAIN -from .helpers import Sun2Data, translation +_LOCATION_OPTIONS = [CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_TIME_ZONE] + + +class Sun2Flow(ABC): + """Sun2 flow mixin.""" + + def _any_using_ha_loc(self) -> bool: + """Determine if a config is using Home Assistant location.""" + entries = self.hass.config_entries.async_entries(DOMAIN) + return any(CONF_LATITUDE not in entry.options for entry in entries) + + @abstractmethod + def create_entry(self) -> FlowResult: + """Finish the flow.""" + + 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.options = {k: v for k, v in self.options.items() if k not in _LOCATION_OPTIONS} + return self.create_entry() + # return await self.async_step_entities() + return await self.async_step_location() -class Sun2ConfigFlow(ConfigFlow, domain=DOMAIN): + schema = { + vol.Required("use_home", default=CONF_LATITUDE not in self.options): bool + } + return self.async_show_form(step_id="use_home", data_schema=vol.Schema(schema)) + + 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]) + self.options.update(user_input) + return self.create_entry() + # return await self.async_step_entities() + + schema = { + vol.Required( + CONF_LATITUDE, default=self.options.get(CONF_LATITUDE) or vol.UNDEFINED + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, + default=self.options.get(CONF_LONGITUDE) or vol.UNDEFINED, + ): cv.longitude, + vol.Required( + CONF_ELEVATION, + default=self.options.get(CONF_ELEVATION) or vol.UNDEFINED, + ): vol.Coerce(float), + vol.Required( + CONF_TIME_ZONE, + default=self.options.get(CONF_TIME_ZONE) or vol.UNDEFINED, + ): cv.string, + } + return self.async_show_form(step_id="location", data_schema=vol.Schema(schema)) + + +class Sun2ConfigFlow(ConfigFlow, Sun2Flow, domain=DOMAIN): """Sun2 config flow.""" VERSION = 1 + _location_name: str + options: dict[str, Any] = {} + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> Sun2OptionsFlow: + """Get the options flow for this handler.""" + return Sun2OptionsFlow(config_entry) + + @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 cls.async_get_options_flow is not ConfigFlow.async_get_options_flow + async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Import config entry from configuration.""" - title = translation( - self.hass, - "service_name", - {"location": data.pop(CONF_LOCATION, self.hass.config.location_name)}, - ) + self._location_name = data.pop(CONF_LOCATION, self.hass.config.location_name) if existing_entry := await self.async_set_unique_id(data.pop(CONF_UNIQUE_ID)): - 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) - ) + if not self.hass.config_entries.async_update_entry( + existing_entry, title=self._location_name, 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(data={}, title=title, options=data) + self.options = data + return self.create_entry() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Start user config flow.""" + self._location_name = self.hass.config.location_name + 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_location_name( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Get location name.""" + if user_input is not None: + self._location_name = user_input[CONF_NAME] + return await self.async_step_location() + + schema = {vol.Required(CONF_NAME): cv.string} + return self.async_show_form( + step_id="location_name", data_schema=vol.Schema(schema) + ) + + def create_entry(self) -> FlowResult: + """Finish the flow.""" + return self.async_create_entry( + title=self._location_name, data={}, options=self.options + ) + + +class Sun2OptionsFlow(OptionsFlowWithConfigEntry, Sun2Flow): + """Sun2 integration options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Start options flow.""" + if CONF_LATITUDE not in self.options or not self._any_using_ha_loc(): + return await self.async_step_use_home() + return await self.async_step_location() + + def create_entry(self) -> 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 ba4e17f..3b23559 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -27,6 +27,7 @@ from homeassistant.helpers.device_registry import DeviceInfo except ImportError: from homeassistant.helpers.entity import DeviceInfo + from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType @@ -40,6 +41,7 @@ ATTR_YESTERDAY, ATTR_YESTERDAY_HMS, DOMAIN, + LOGGER, ONE_DAY, SIG_HA_LOC_UPDATED, ) @@ -123,6 +125,15 @@ def translation( 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=translation(hass, "service_name", {"location": entry.title}), + ) + + _Num = TypeVar("_Num", bound=Num) @@ -138,6 +149,15 @@ 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.""" @@ -161,25 +181,22 @@ class Sun2Entity(Entity): def __init__( self, loc_params: LocParams | None, - entry: ConfigEntry | None, - unique_id: str | None = 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. """ - if entry: - self._attr_translation_key = self.entity_description.key + if sun2_entity_params: self._attr_has_entity_name = True - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.title, - ) + self._attr_translation_key = self.entity_description.key + entry = sun2_entity_params.entry + unique_id = sun2_entity_params.unique_id self._attr_unique_id = ( - f"{entry.unique_id}-{unique_id or self.entity_description.key}" + f"{entry.entry_id}-{unique_id or self.entity_description.key}" ) + self._attr_device_info = sun2_entity_params.device_info else: self._attr_unique_id = self.name self._loc_params = loc_params diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 9afabcd..2bc7973 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -78,7 +78,9 @@ LocData, LocParams, Num, + sun2_dev_info, Sun2Entity, + Sun2EntityParams, get_loc_params, hours_to_hms, nearest_second, @@ -110,17 +112,18 @@ class Sun2AzimuthSensor(Sun2Entity, SensorEntity): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: """Initialize sensor.""" name = sensor_type.replace("_", " ").title() - if not isinstance(extra, ConfigEntry): + 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, @@ -130,7 +133,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ) - super().__init__(loc_params, extra if isinstance(extra, ConfigEntry) else None) + super().__init__(loc_params, extra) self._event = "solar_azimuth" def _setup_fixed_updating(self) -> None: @@ -176,28 +179,26 @@ class Sun2SensorEntity(Sun2Entity, SensorEntity, Generic[_T]): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, entity_description: SensorEntityDescription, default_solar_depression: Num | str = 0, name: str | None = None, - unique_id: str | None = None, ) -> None: """Initialize sensor.""" key = entity_description.key if name is None: name = key.replace("_", " ").title() - if isinstance(extra, ConfigEntry): + if isinstance(extra, Sun2EntityParams): entity_description.entity_registry_enabled_default = key in _ENABLED_SENSORS - entry = extra else: # 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}" - entry = None + extra = None entity_description.name = name self.entity_description = entity_description - super().__init__(loc_params, entry, unique_id) + super().__init__(loc_params, extra) if any(key.startswith(sol_dep + "_") for sol_dep in _SOLAR_DEPRESSIONS): self._solar_depression, self._event = key.rsplit("_", 1) @@ -257,10 +258,9 @@ class Sun2ElevationAtTimeSensor(Sun2SensorEntity[float]): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, name: str, at_time: str | time, - unique_id: str | None, ) -> None: """Initialize sensor.""" if isinstance(at_time, str): @@ -274,9 +274,7 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ) - super().__init__( - loc_params, extra, entity_description, name=name, unique_id=unique_id - ) + super().__init__(loc_params, extra, entity_description, name=name) self._event = "solar_elevation" @property @@ -377,11 +375,10 @@ class Sun2PointInTimeSensor(Sun2SensorEntity[Union[datetime, str]]): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, name: str | None = None, - unique_id: str | None = None, ) -> None: """Initialize sensor.""" entity_description = SensorEntityDescription( @@ -389,9 +386,7 @@ def __init__( device_class=SensorDeviceClass.TIMESTAMP, icon=icon, ) - super().__init__( - loc_params, extra, entity_description, "civil", name, unique_id - ) + super().__init__(loc_params, extra, entity_description, "civil", name) class Sun2TimeAtElevationSensor(Sun2PointInTimeSensor): @@ -400,19 +395,16 @@ class Sun2TimeAtElevationSensor(Sun2PointInTimeSensor): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, name: str, icon: str | None, direction: SunDirection, elevation: float, - unique_id: str | None, ) -> None: """Initialize sensor.""" self._direction = direction self._elevation = elevation - super().__init__( - loc_params, extra, CONF_TIME_AT_ELEVATION, icon, name, unique_id - ) + super().__init__(loc_params, extra, CONF_TIME_AT_ELEVATION, icon, name) def _astral_event( self, @@ -432,7 +424,7 @@ class Sun2PeriodOfTimeSensor(Sun2SensorEntity[float]): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -486,7 +478,7 @@ class Sun2MinMaxElevationSensor(Sun2SensorEntity[float]): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -550,7 +542,7 @@ class Sun2CPSensorEntity(Sun2SensorEntity[_T]): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, entity_description: SensorEntityDescription, default_solar_depression: Num | str = 0, ) -> None: @@ -715,7 +707,7 @@ class Sun2ElevationSensor(Sun2CPSensorEntity[float]): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -808,7 +800,7 @@ class Sun2PhaseSensorBase(Sun2CPSensorEntity[str]): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, phase_data: PhaseData, @@ -998,7 +990,7 @@ class Sun2PhaseSensor(Sun2PhaseSensorBase): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -1055,7 +1047,7 @@ class Sun2DeconzDaylightSensor(Sun2PhaseSensorBase): def __init__( self, loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, sensor_type: str, icon: str | None, ) -> None: @@ -1229,7 +1221,7 @@ def _eat_defaults(config: ConfigType) -> ConfigType: 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", + msg="expected input_datetime entity ID or time string", ), vol.Optional(CONF_NAME): cv.string, } @@ -1255,45 +1247,49 @@ def _eat_defaults(config: ConfigType) -> ConfigType: def _sensors( loc_params: LocParams | None, - extra: ConfigEntry | str | None, + extra: Sun2EntityParams | str | None, sensors_config: Iterable[str | dict[str, Any]], ) -> 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 = None sensors.append( _SENSOR_TYPES[config].cls( loc_params, extra, config, _SENSOR_TYPES[config].icon ) ) - elif CONF_TIME_AT_ELEVATION in config: - sensors.append( - Sun2TimeAtElevationSensor( - loc_params, - extra, - config[CONF_NAME], - config[CONF_ICON], - SunDirection(config[CONF_DIRECTION]), - config[CONF_TIME_AT_ELEVATION], - config.get(CONF_UNIQUE_ID), - ) - ) else: - # 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, - config[CONF_NAME], - at_time, - config.get(CONF_UNIQUE_ID), + if isinstance(extra, Sun2EntityParams): + extra.unique_id = config[CONF_UNIQUE_ID] + if CONF_TIME_AT_ELEVATION in config: + sensors.append( + Sun2TimeAtElevationSensor( + loc_params, + extra, + config[CONF_NAME], + config[CONF_ICON], + SunDirection(config[CONF_DIRECTION]), + config[CONF_TIME_AT_ELEVATION], + ) + ) + else: + # 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, + config[CONF_NAME], + at_time, + ) ) - ) return sensors @@ -1331,8 +1327,9 @@ async def async_setup_entry( 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, entry, config.get(CONF_SENSORS, [])) - + _sensors(loc_params, entry, _SENSOR_TYPES.keys()), + _sensors(loc_params, sun2_entity_params, config.get(CONF_SENSORS, [])) + + _sensors(loc_params, sun2_entity_params, _SENSOR_TYPES.keys()), True, ) diff --git a/custom_components/sun2/services.yaml b/custom_components/sun2/services.yaml index c983a10..50ece8f 100644 --- a/custom_components/sun2/services.yaml +++ b/custom_components/sun2/services.yaml @@ -1 +1 @@ -reload: +reload: {} diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index 316d998..0fd09eb 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -11,6 +11,55 @@ "setting_neg_elev": "Setting at minus {elevation} °", "setting_pos_elev": "Setting at {elevation} °" }, + "config": { + "step": { + "use_home": { + "data": { + "use_home": "Use Home Assistant name and location?" + } + }, + "location_name": { + "title": "Location Name", + "data": { + "name": "Name" + } + }, + "location": { + "title": "Location Options", + "data": { + "elevation": "Elevation", + "latitude": "Latitude", + "longitude": "Longitude", + "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." + } + } + } + }, + "options": { + "step": { + "use_home": { + "data": { + "use_home": "Use Home Assistant name and location?" + } + }, + "location": { + "title": "Location Options", + "data": { + "elevation": "Elevation", + "latitude": "Latitude", + "location": "Location", + "longitude": "Longitude", + "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." + } + } + } + }, "entity": { "binary_sensor": { "elevation": { diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json index 3a68dd3..8bc2f1c 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -11,6 +11,55 @@ "setting_neg_elev": "Instelling bij min {elevation} °", "setting_pos_elev": "Instelling bij {elevation} °" }, + "config": { + "step": { + "use_home": { + "data": { + "use_home": "De naam en locatie van de Home Assistant gebruiken?" + } + }, + "location_name": { + "title": "Naam van de locatie", + "data": { + "name": "Naam" + } + }, + "location": { + "title": "Locatie Opties", + "data": { + "elevation": "Hoogtehoek", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "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." + } + } + } + }, + "options": { + "step": { + "use_home": { + "data": { + "use_home": "De naam en locatie van de Home Assistant gebruiken?" + } + }, + "location": { + "title": "Locatie Opties", + "data": { + "elevation": "Hoogtehoek", + "latitude": "Breedtegraad", + "location": "Plaats", + "longitude": "Lengtegraad", + "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." + } + } + } + }, "entity": { "binary_sensor": { "elevation": { From 34b775fd745ac3fc383a12f5b6893eba64e787aa Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 28 Nov 2023 15:33:42 -0600 Subject: [PATCH 44/59] Add non-simple sensors via UI Location name must be unique when added via UI. Miscellaneous fixes. --- custom_components/sun2/binary_sensor.py | 2 +- custom_components/sun2/config.py | 162 +++++++--- custom_components/sun2/config_flow.py | 312 ++++++++++++++++---- custom_components/sun2/helpers.py | 23 +- custom_components/sun2/sensor.py | 106 ++----- custom_components/sun2/translations/en.json | 140 ++++++++- custom_components/sun2/translations/nl.json | 140 ++++++++- 7 files changed, 673 insertions(+), 212 deletions(-) diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 994f822..ae7513a 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -32,6 +32,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify +from .config import LOC_PARAMS from .const import ( ATTR_NEXT_CHANGE, DOMAIN, @@ -42,7 +43,6 @@ SUNSET_ELEV, ) from .helpers import ( - LOC_PARAMS, LocParams, Num, sun2_dev_info, diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 70096b1..5f7d348 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -1,6 +1,7 @@ """Sun2 config validation.""" from __future__ import annotations +from collections.abc import Callable from typing import cast from astral import SunDirection @@ -9,14 +10,17 @@ 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.translation import async_get_translations from homeassistant.helpers.typing import ConfigType from .const import ( @@ -24,15 +28,20 @@ CONF_ELEVATION_AT_TIME, CONF_TIME_AT_ELEVATION, DOMAIN, - LOGGER, SUNSET_ELEV, ) -from .helpers import LOC_PARAMS, Sun2Data, translation -from .sensor import val_tae_cfg, ELEVATION_AT_TIME_SCHEMA, TIME_AT_ELEVATION_SCHEMA +from .helpers import init_translations, translate PACKAGE_MERGE_HINT = "list" DEFAULT_ELEVATION = SUNSET_ELEV +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, @@ -45,11 +54,37 @@ } ) -ELEVATION_AT_TIME_SCHEMA = ELEVATION_AT_TIME_SCHEMA.extend( +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} ) -TIME_AT_ELEVATION_SCHEMA = TIME_AT_ELEVATION_SCHEMA.extend( +val_elevation = vol.All( + vol.Coerce(float), vol.Range(min=-90, max=90), msg="invalid elevation" +) + +TIME_AT_ELEVATION_SCHEMA_BASE = vol.Schema( + { + vol.Required(CONF_TIME_AT_ELEVATION): val_elevation, + 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, + } +) + +TIME_AT_ELEVATION_SCHEMA = TIME_AT_ELEVATION_SCHEMA_BASE.extend( {vol.Required(CONF_UNIQUE_ID): cv.string} ) @@ -60,7 +95,7 @@ def _sensor(config: ConfigType) -> ConfigType: return ELEVATION_AT_TIME_SCHEMA(config) if CONF_TIME_AT_ELEVATION in config: return TIME_AT_ELEVATION_SCHEMA(config) - raise vol.Invalid("expected elevation_at_time or time_at_elevation") + raise vol.Invalid(f"expected {CONF_ELEVATION_AT_TIME} or {CONF_TIME_AT_ELEVATION}") _SUN2_LOCATION_CONFIG = vol.Schema( @@ -97,78 +132,111 @@ def _unique_locations_names(configs: list[dict]) -> list[dict]: ) -def _val_bs_elevation(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: - """Validate elevation binary_sensor.""" - if config[CONF_ELEVATION] == "horizon": - config[CONF_ELEVATION] = DEFAULT_ELEVATION +def val_bs_elevation(hass: HomeAssistant | None = None) -> Callable[[dict], dict]: + """Validate elevation binary_sensor config.""" - if config.get(CONF_NAME): - return config + def validate(config: ConfigType) -> ConfigType: + """Validate the config.""" + if config[CONF_ELEVATION] == "horizon": + config[CONF_ELEVATION] = DEFAULT_ELEVATION + + if config.get(CONF_NAME): + return config - if (elevation := config[CONF_ELEVATION]) == DEFAULT_ELEVATION: - name = translation(hass, "above_horizon") - else: - if elevation < 0: - name = translation(hass, "above_neg_elev", {"elevation": str(-elevation)}) + if (elevation := config[CONF_ELEVATION]) == DEFAULT_ELEVATION: + name = translate(hass, "above_horizon") else: - name = translation(hass, "above_pos_elev", {"elevation": str(elevation)}) - config[CONF_NAME] = name - return config + if elevation < 0: + name = translate(hass, "above_neg_elev", {"elevation": str(-elevation)}) + else: + name = translate(hass, "above_pos_elev", {"elevation": str(elevation)}) + config[CONF_NAME] = name + return config + return validate -def _val_eat_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: - """Validate elevation_at_time name.""" - if config.get(CONF_NAME): + +def val_elevation_at_time(hass: HomeAssistant | None = None) -> Callable[[dict], dict]: + """Validate elevation_at_time sensor config.""" + + def validate(config: ConfigType) -> ConfigType: + """Validate the config.""" + if config.get(CONF_NAME): + return config + + at_time = config[CONF_ELEVATION_AT_TIME] + if hass: + name = translate(hass, "elevation_at", {"elev_time": str(at_time)}) + else: + name = f"Elevation at {at_time}" + config[CONF_NAME] = name return config - config[CONF_NAME] = translation( - hass, "elevation_at", {"elev_time": str(config[CONF_ELEVATION_AT_TIME])} - ) + return validate - return config +_DIR_TO_ICON = { + SunDirection.RISING: "mdi:weather-sunset-up", + SunDirection.SETTING: "mdi:weather-sunset-down", +} -def _val_tae_name(hass: HomeAssistant, config: str | ConfigType) -> ConfigType: - """Validate time_at_elevation name.""" - if config.get(CONF_NAME): - return config - direction = SunDirection(config[CONF_DIRECTION]) - elevation = cast(float, config[CONF_TIME_AT_ELEVATION]) +def val_time_at_elevation(hass: HomeAssistant | None = None) -> Callable[[dict], dict]: + """Validate time_at_elevation sensor config.""" - trans_key = f"{direction.name.lower()}_{'neg' if elevation < 0 else 'pos'}_elev" - config[CONF_NAME] = translation(hass, trans_key, {"elevation": str(abs(elevation))}) + def validate(config: ConfigType) -> ConfigType: + """Validate the config.""" + direction = SunDirection(config[CONF_DIRECTION]) + if not config.get(CONF_ICON): + config[CONF_ICON] = _DIR_TO_ICON[direction] - return config + if config.get(CONF_NAME): + return config + + elevation = cast(float, config[CONF_TIME_AT_ELEVATION]) + if hass: + name = translate( + hass, + f"{direction.name.lower()}_{'neg' if elevation < 0 else 'pos'}_elev", + {"elevation": str(abs(elevation))}, + ) + else: + dir_str = direction.name.title() + if elevation >= 0: + elev_str = str(elevation) + else: + elev_str = f"minus {-elevation}" + name = f"{dir_str} at {elev_str} °" + config[CONF_NAME] = name + return config + + return validate async def async_validate_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: """Validate configuration.""" - hass.data.setdefault( - DOMAIN, Sun2Data() - ).translations = await async_get_translations( - hass, hass.config.language, "misc", [DOMAIN], False - ) + await init_translations(hass) config = _SUN2_CONFIG_SCHEMA(config) if DOMAIN not in config: return config + _val_bs_elevation = val_bs_elevation(hass) + _val_elevation_at_time = val_elevation_at_time(hass) + _val_time_at_elevation = val_time_at_elevation(hass) for loc_config in config[DOMAIN]: if CONF_BINARY_SENSORS in loc_config: loc_config[CONF_BINARY_SENSORS] = [ - _val_bs_elevation(hass, cfg) for cfg in loc_config[CONF_BINARY_SENSORS] + _val_bs_elevation(cfg) for cfg in loc_config[CONF_BINARY_SENSORS] ] if CONF_SENSORS in loc_config: sensor_configs = [] for sensor_config in loc_config[CONF_SENSORS]: if CONF_ELEVATION_AT_TIME in sensor_config: - sensor_configs.append(_val_eat_name(hass, sensor_config)) + sensor_configs.append(_val_elevation_at_time(sensor_config)) else: - sensor_configs.append( - _val_tae_name(hass, val_tae_cfg(sensor_config)) - ) + sensor_configs.append(_val_time_at_elevation(sensor_config)) loc_config[CONF_SENSORS] = sensor_configs return config diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index a278664..fb61a90 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -2,9 +2,11 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Mapping -from typing import Any +from collections.abc import Callable +from contextlib import suppress +from typing import Any, cast +from astral import SunDirection import voluptuous as vol from homeassistant.config_entries import ( @@ -14,19 +16,47 @@ SOURCE_IMPORT, ) 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.core import callback, HomeAssistant from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + IconSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + TextSelector, + TimeSelector, +) +from homeassistant.util.uuid import random_uuid_hex -from .const import DOMAIN +from .config import ( + val_bs_elevation, + val_elevation, + val_elevation_at_time, + val_time_at_elevation, +) +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] @@ -34,30 +64,18 @@ class Sun2Flow(ABC): """Sun2 flow mixin.""" - def _any_using_ha_loc(self) -> bool: - """Determine if a config is using Home Assistant location.""" - entries = self.hass.config_entries.async_entries(DOMAIN) - return any(CONF_LATITUDE not in entry.options for entry in entries) + _existing_entries: list[ConfigEntry] | None = None - @abstractmethod - def create_entry(self) -> FlowResult: - """Finish the flow.""" + @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 - 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.options = {k: v for k, v in self.options.items() if k not in _LOCATION_OPTIONS} - return self.create_entry() - # return await self.async_step_entities() - return await self.async_step_location() - - schema = { - vol.Required("use_home", default=CONF_LATITUDE not in self.options): bool - } - return self.async_show_form(step_id="use_home", data_schema=vol.Schema(schema)) + 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 @@ -66,27 +84,181 @@ async def async_step_location( if user_input is not None: user_input[CONF_TIME_ZONE] = cv.time_zone(user_input[CONF_TIME_ZONE]) self.options.update(user_input) - return self.create_entry() - # return await self.async_step_entities() + return await self.async_step_entities_menu() schema = { vol.Required( - CONF_LATITUDE, default=self.options.get(CONF_LATITUDE) or vol.UNDEFINED + CONF_LATITUDE, default=self.options.get(CONF_LATITUDE, vol.UNDEFINED) ): cv.latitude, vol.Required( CONF_LONGITUDE, - default=self.options.get(CONF_LONGITUDE) or vol.UNDEFINED, + default=self.options.get(CONF_LONGITUDE, vol.UNDEFINED), ): cv.longitude, vol.Required( CONF_ELEVATION, - default=self.options.get(CONF_ELEVATION) or vol.UNDEFINED, - ): vol.Coerce(float), + default=self.options.get(CONF_ELEVATION, vol.UNDEFINED), + ): val_elevation, vol.Required( CONF_TIME_ZONE, - default=self.options.get(CONF_TIME_ZONE) or vol.UNDEFINED, + default=self.options.get(CONF_TIME_ZONE, vol.UNDEFINED), ): cv.string, } - return self.async_show_form(step_id="location", data_schema=vol.Schema(schema)) + return self.async_show_form( + step_id="location", data_schema=vol.Schema(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 = [ + "elevation_binary_sensor", + "elevation_at_time_sensor_menu", + "time_at_elevation_sensor", + "done", + ] + return self.async_show_menu(step_id="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"}, val_bs_elevation, 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", default=False): bool}), + 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, val_bs_elevation, CONF_BINARY_SENSORS + ) + + schema = { + vol.Required(CONF_ELEVATION, default=0.0): NumberSelector( + NumberSelectorConfig( + min=-90, max=90, step="any", mode=NumberSelectorMode.BOX + ) + ), + vol.Optional(CONF_NAME): TextSelector(), + } + return self.async_show_form( + step_id="elevation_binary_sensor_2", + data_schema=vol.Schema(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, val_elevation_at_time, CONF_SENSORS + ) + + 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=vol.Schema(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, val_elevation_at_time, CONF_SENSORS + ) + + 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=vol.Schema(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: + user_input[CONF_DIRECTION] = vol.All(vol.Upper, cv.enum(SunDirection))( + user_input[CONF_DIRECTION] + ) + return await self.async_finish_sensor( + user_input, val_time_at_elevation, CONF_SENSORS + ) + + schema = { + vol.Required(CONF_TIME_AT_ELEVATION, default=0.0): NumberSelector( + NumberSelectorConfig( + min=-90, max=90, step="any", mode=NumberSelectorMode.BOX + ) + ), + vol.Required(CONF_DIRECTION): SelectSelector( + SelectSelectorConfig( + options=["rising", "setting"], translation_key="direction" + ) + ), + vol.Optional(CONF_ICON): IconSelector(), + vol.Optional(CONF_NAME): TextSelector(), + } + return self.async_show_form( + step_id="time_at_elevation_sensor", + data_schema=vol.Schema(schema), + last_step=False, + ) + + async def async_finish_sensor( + self, + config: dict[str, Any], + validator: Callable[[HomeAssistant], Callable[[dict], dict]], + sensor_type: str, + ) -> FlowResult: + """Finish elevation binary sensor.""" + sensor_option = validator(self.hass)(config) + sensor_option[CONF_UNIQUE_ID] = random_uuid_hex() + self.options.setdefault(sensor_type, []).append(sensor_option) + return await self.async_step_entities_menu() + + @abstractmethod + async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: + """Finish the flow.""" class Sun2ConfigFlow(ConfigFlow, Sun2Flow, domain=DOMAIN): @@ -94,14 +266,21 @@ class Sun2ConfigFlow(ConfigFlow, Sun2Flow, domain=DOMAIN): VERSION = 1 - _location_name: str - options: dict[str, Any] = {} + _location_name: str | vol.UNDEFINED = vol.UNDEFINED + + def __init__(self) -> None: + """Initialize config flow.""" + self.options = {} @staticmethod @callback def async_get_options_flow(config_entry: ConfigEntry) -> Sun2OptionsFlow: """Get the options flow for this handler.""" - return Sun2OptionsFlow(config_entry) + flow = Sun2OptionsFlow(config_entry) + flow.init_step = ( + "location" if CONF_LATITUDE in config_entry.options else "entities_menu" + ) + return flow @classmethod @callback @@ -109,11 +288,13 @@ 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 cls.async_get_options_flow is not ConfigFlow.async_get_options_flow + return True async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Import config entry from configuration.""" - self._location_name = data.pop(CONF_LOCATION, self.hass.config.location_name) + self._location_name = 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=self._location_name, options=data @@ -123,32 +304,55 @@ async def async_step_import(self, data: dict[str, Any]) -> FlowResult: ) return self.async_abort(reason="already_configured") - self.options = data - return self.create_entry() + self.options.clear() + self.options.update(data) + return await self.async_step_done() - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_user(self, _: dict[str, Any] | None = None) -> FlowResult: """Start user config flow.""" - self._location_name = self.hass.config.location_name 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() + + schema = {vol.Required("use_home", default=True): bool} + return self.async_show_form( + step_id="use_home", data_schema=vol.Schema(schema), 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 = user_input[CONF_NAME] - return await self.async_step_location() + if not any(entry.title == self._location_name for entry in self._entries): + return await self.async_step_location() + errors[CONF_NAME] = "name_used" - schema = {vol.Required(CONF_NAME): cv.string} + schema = {vol.Required(CONF_NAME, default=self._location_name): TextSelector()} return self.async_show_form( - step_id="location_name", data_schema=vol.Schema(schema) + step_id="location_name", + data_schema=vol.Schema(schema), + errors=errors, + last_step=False, ) - def create_entry(self) -> FlowResult: + async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: """Finish the flow.""" return self.async_create_entry( title=self._location_name, data={}, options=self.options @@ -158,14 +362,6 @@ def create_entry(self) -> FlowResult: class Sun2OptionsFlow(OptionsFlowWithConfigEntry, Sun2Flow): """Sun2 integration options flow.""" - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Start options flow.""" - if CONF_LATITUDE not in self.options or not self._any_using_ha_loc(): - return await self.async_step_use_home() - return await self.async_step_location() - - def create_entry(self) -> FlowResult: + 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 3b23559..b41b930 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -9,7 +9,6 @@ from astral import LocationInfo from astral.location import Location -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,7 +18,6 @@ CONF_TIME_ZONE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType # Device Info moved to device_registry in 2023.9 @@ -30,6 +28,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity +from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -48,12 +47,6 @@ 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) @@ -88,6 +81,7 @@ class Sun2Data: 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: ConfigType) -> LocParams | None: @@ -111,7 +105,16 @@ def hours_to_hms(hours: Num | None) -> str | None: return None -def translation( +async def init_translations(hass: HomeAssistant) -> None: + """Initialize translations.""" + data = cast(Sun2Data, hass.data.setdefault(DOMAIN, Sun2Data())) + if data.language != hass.config.language: + data.translations = await async_get_translations( + hass, hass.config.language, "misc", [DOMAIN], False + ) + + +def translate( hass: HomeAssistant, key: str, placeholders: dict[str, str] | None = None ) -> str: """Sun2 translations.""" @@ -130,7 +133,7 @@ def sun2_dev_info(hass: HomeAssistant, entry: ConfigEntry) -> DeviceInfo: return DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry.entry_id)}, - name=translation(hass, "service_name", {"location": entry.title}), + name=translate(hass, "service_name", {"location": entry.title}), ) diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 2bc7973..29675b9 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -49,6 +49,13 @@ 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, + val_elevation_at_time, + val_time_at_elevation, +) from .const import ( ATTR_BLUE_HOUR, ATTR_DAYLIGHT, @@ -74,7 +81,6 @@ SUNSET_ELEV, ) from .helpers import ( - LOC_PARAMS, LocData, LocParams, Num, @@ -1161,78 +1167,14 @@ class SensorParams: "deconz_daylight": SensorParams(Sun2DeconzDaylightSensor, None), } -_DIR_TO_ICON = { - SunDirection.RISING: "mdi:weather-sunset-up", - SunDirection.SETTING: "mdi:weather-sunset-down", -} - - -def val_tae_cfg(config: ConfigType) -> ConfigType: - """Validate time_at_elevation config.""" - direction = SunDirection(config[CONF_DIRECTION]) - if not config.get(CONF_ICON): - config[CONF_ICON] = _DIR_TO_ICON[direction] - return config - - -def _tae_defaults(config: ConfigType) -> ConfigType: - """Fill in defaults including name.""" - config = val_tae_cfg(config) - - if config.get(CONF_NAME): - return config - - direction = SunDirection(config[CONF_DIRECTION]) - elevation = cast(float, config[CONF_TIME_AT_ELEVATION]) - - 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 including name.""" - if not config.get(CONF_NAME): - config[CONF_NAME] = f"Elevation at {config[CONF_ELEVATION_AT_TIME]}" - - return config - - -TIME_AT_ELEVATION_SCHEMA = 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, - } +_ELEVATION_AT_TIME_SCHEMA = vol.All( + ELEVATION_AT_TIME_SCHEMA_BASE, val_elevation_at_time() ) - -_TIME_AT_ELEVATION_SCHEMA_W_DEFAULTS = vol.All(TIME_AT_ELEVATION_SCHEMA, _tae_defaults) - -ELEVATION_AT_TIME_SCHEMA = 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, - } +_TIME_AT_ELEVATION_SCHEMA = vol.All( + TIME_AT_ELEVATION_SCHEMA_BASE, val_time_at_elevation() ) - -_ELEVATION_AT_TIME_SCHEMA_W_DEFAULTS = vol.All(ELEVATION_AT_TIME_SCHEMA, _eat_defaults) - _SUN2_SENSOR_SCHEMA = vol.Any( - _TIME_AT_ELEVATION_SCHEMA_W_DEFAULTS, - _ELEVATION_AT_TIME_SCHEMA_W_DEFAULTS, - vol.In(_SENSOR_TYPES), + _ELEVATION_AT_TIME_SCHEMA, _TIME_AT_ELEVATION_SCHEMA, vol.In(_SENSOR_TYPES) ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -1264,18 +1206,7 @@ def _sensors( else: if isinstance(extra, Sun2EntityParams): extra.unique_id = config[CONF_UNIQUE_ID] - if CONF_TIME_AT_ELEVATION in config: - sensors.append( - Sun2TimeAtElevationSensor( - loc_params, - extra, - config[CONF_NAME], - config[CONF_ICON], - SunDirection(config[CONF_DIRECTION]), - config[CONF_TIME_AT_ELEVATION], - ) - ) - else: + 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] @@ -1290,6 +1221,17 @@ def _sensors( at_time, ) ) + else: + sensors.append( + Sun2TimeAtElevationSensor( + loc_params, + extra, + config[CONF_NAME], + config[CONF_ICON], + SunDirection(config[CONF_DIRECTION]), + config[CONF_TIME_AT_ELEVATION], + ) + ) return sensors diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index 0fd09eb..a6d0276 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -13,17 +13,49 @@ }, "config": { "step": { - "use_home": { + "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 string" + } + }, + "elevation_at_time_sensor_entity": { + "title": "Elevation at Time Sensor Options", "data": { - "use_home": "Use Home Assistant name and location?" + "elevation_at_time": "input_datetime entity ID", + "name": "Name" } }, - "location_name": { - "title": "Location 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": { + "done": "Done", + "elevation_at_time_sensor_menu": "Add elevation at time sensor", + "elevation_binary_sensor": "Add elevation binary sensor", + "time_at_elevation_sensor": "Add time at elevation sensor" + } + }, "location": { "title": "Location Options", "data": { @@ -35,14 +67,81 @@ "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" + } + }, + "remove_entity": { + "title": "Remove {name}?", + "data": { + "remove": "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?" + } } + }, + "error": { + "name_used": "Location name has already been used." } }, "options": { "step": { - "use_home": { + "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 string" + } + }, + "elevation_at_time_sensor_entity": { + "title": "Elevation at Time Sensor Options", "data": { - "use_home": "Use Home Assistant name and location?" + "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": { + "done": "Done", + "elevation_at_time_sensor_menu": "Add elevation at time sensor", + "elevation_binary_sensor": "Add elevation binary sensor", + "time_at_elevation_sensor": "Add time at elevation sensor" } }, "location": { @@ -50,13 +149,32 @@ "data": { "elevation": "Elevation", "latitude": "Latitude", - "location": "Location", "longitude": "Longitude", "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_entity": { + "title": "Remove {name}?", + "data": { + "remove": "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?" + } } } }, @@ -313,6 +431,14 @@ } } }, + "selector": { + "direction": { + "options": { + "rising": "Rising", + "setting": "Setting" + } + } + }, "services": { "reload": { "name": "Reload", diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json index 8bc2f1c..18b4c66 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -13,17 +13,49 @@ }, "config": { "step": { - "use_home": { + "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": { - "use_home": "De naam en locatie van de Home Assistant gebruiken?" + "elevation_at_time": "input_datetime entiteit", + "name": "Naam" } }, - "location_name": { - "title": "Naam van de locatie", + "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": "Horizon als elevatie gebruiken?" + } + }, + "elevation_binary_sensor_2": { + "title": "Opties voor binaire elevatiesensoren", + "data": { + "elevation": "Hoogte", + "name": "Naam" + } + }, + "entities_menu": { + "title": "Aanvullende entiteiten", + "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" + } + }, "location": { "title": "Locatie Opties", "data": { @@ -35,14 +67,81 @@ "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" + } + }, + "remove_entity": { + "title": "{name} verwijderen?", + "data": { + "remove": "ReVerwijderenmove" + } + }, + "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": { - "use_home": { + "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": { - "use_home": "De naam en locatie van de Home Assistant gebruiken?" + "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": "Horizon als elevatie gebruiken?" + } + }, + "elevation_binary_sensor_2": { + "title": "Opties voor binaire elevatiesensoren", + "data": { + "elevation": "Hoogte", + "name": "Naam" + } + }, + "entities_menu": { + "title": "Aanvullende entiteiten", + "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" } }, "location": { @@ -50,13 +149,32 @@ "data": { "elevation": "Hoogtehoek", "latitude": "Breedtegraad", - "location": "Plaats", "longitude": "Lengtegraad", "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_entity": { + "title": "{name} verwijderen?", + "data": { + "remove": "ReVerwijderenmove" + } + }, + "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?" + } } } }, @@ -313,6 +431,14 @@ } } }, + "selector": { + "direction": { + "options": { + "rising": "Opstand", + "setting": "Montuur" + } + } + }, "services": { "reload": { "name": "Herlaadt", From 87aa5f74c49410af982c01c5d0b3a47217a0307b Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 28 Nov 2023 15:39:24 -0600 Subject: [PATCH 45/59] Update info.md --- info.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/info.md b/info.md index 88849d4..1258977 100644 --- a/info.md +++ b/info.md @@ -2,5 +2,4 @@ Creates sensors that provide information about various sun related events. -For now configuration is done strictly in YAML, -although there will be corresponding entries on the Integrations, Devices and Entities pages. +Supports configuration via YAML or the UI. From fb9bd2cb93c74e44ab8ee04690e189981d798bb9 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 28 Nov 2023 18:27:09 -0600 Subject: [PATCH 46/59] Update README.md --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3a13bb1..8537af5 100644 --- a/README.md +++ b/README.md @@ -3,10 +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 -sun2: -``` +Then add one or more locations with desired sensors either via YAML, the UI or both. ## Installation ### With HACS @@ -44,6 +41,8 @@ Reloads Sun2 from the YAML-configuration. Also adds `SUN2` to the Developers Too 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. @@ -132,7 +131,7 @@ Also in this case, the `sensor` entity will not have `yesterday`, `today` and `t ## Aditional Sensors -Besides any sensors specified in the configuration, the following will also be created. +Besides the sensors described above, the following will also be created automatically. Simply enable or disable these entities as desired. ### Point in Time Sensors From 94210d7968bea6b21c349099594d87a9c288ed18 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 29 Nov 2023 07:37:39 -0600 Subject: [PATCH 47/59] Move misc translations to satisfy hassfest --- custom_components/sun2/helpers.py | 22 ++++++++++++------- custom_components/sun2/translations/en.json | 24 +++++++++++---------- custom_components/sun2/translations/nl.json | 24 +++++++++++---------- 3 files changed, 40 insertions(+), 30 deletions(-) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index b41b930..86be9e9 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -105,26 +105,32 @@ def hours_to_hms(hours: Num | None) -> str | None: 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: - data.translations = await async_get_translations( - hass, hass.config.language, "misc", [DOMAIN], False + 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, str] | None = None + hass: HomeAssistant, key: str, placeholders: dict[str, Any] | None = None ) -> str: """Sun2 translations.""" - trans = cast(Sun2Data, hass.data[DOMAIN]).translations[ - f"component.{DOMAIN}.misc.{key}" - ] + trans = cast(Sun2Data, hass.data[DOMAIN]).translations[key] if not placeholders: return trans - for key, val in placeholders.items(): - trans = trans.replace(f"{{{key}}}", val) + for ph_key, val in placeholders.items(): + trans = trans.replace(f"{{{ph_key}}}", str(val)) return trans diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index a6d0276..b0488fd 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -1,16 +1,5 @@ { "title": "Sun2", - "misc": { - "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} °" - }, "config": { "step": { "elevation_at_time_sensor_menu": { @@ -437,6 +426,19 @@ "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": { diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json index 18b4c66..0ee0570 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -1,16 +1,5 @@ { "title": "Zon2", - "misc": { - "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} °" - }, "config": { "step": { "elevation_at_time_sensor_menu": { @@ -437,6 +426,19 @@ "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": { From 6dce3d67ec383b9c6ca8e49ae71ed886dd205563 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 1 Dec 2023 13:05:01 -0600 Subject: [PATCH 48/59] Update names of additional entities when language is changed Simplify config storage data. --- custom_components/sun2/__init__.py | 16 ++- custom_components/sun2/binary_sensor.py | 130 +++++++++++------------- custom_components/sun2/config.py | 126 ++--------------------- custom_components/sun2/config_flow.py | 31 ++---- custom_components/sun2/sensor.py | 93 +++++++++++++---- 5 files changed, 166 insertions(+), 230 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 878d6a5..09f7fbc 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -4,9 +4,12 @@ import asyncio from typing import cast +from astral import SunDirection + from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT from homeassistant.const import ( CONF_LATITUDE, + CONF_SENSORS, CONF_UNIQUE_ID, EVENT_CORE_CONFIG_UPDATE, Platform, @@ -18,7 +21,7 @@ from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, SIG_HA_LOC_UPDATED +from .const import CONF_DIRECTION, CONF_TIME_AT_ELEVATION, DOMAIN, SIG_HA_LOC_UPDATED from .helpers import LocData, LocParams, Sun2Data @@ -112,6 +115,17 @@ async def entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: 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) + entry.async_on_unload(entry.add_update_listener(entry_updated)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index ae7513a..69c83e4 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime -from numbers import Real from typing import Any, Iterable, cast import voluptuous as vol @@ -50,16 +49,12 @@ Sun2EntityParams, get_loc_params, nearest_second, + 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: @@ -68,67 +63,43 @@ # name: -def _val_bs_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 - return config - - -def _val_cfg(config: str | ConfigType) -> ConfigType: - """Validate configuration including name.""" - config = _val_bs_cfg(config) - if CONF_ELEVATION in config: - options = config[CONF_ELEVATION] - 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 -SUN2_BINARY_SENSOR_SCHEMA = vol.Any( - vol.In(_SENSOR_TYPES), - vol.Schema( +_ELEVATION_SCHEMA = vol.All( + vol.Any( + CONF_ELEVATION, { - vol.Required(vol.In(_SENSOR_TYPES)): vol.Any( + vol.Required(CONF_ELEVATION): vol.Any( vol.Coerce(float), - vol.Schema( - { - vol.Optional(CONF_ABOVE): vol.Coerce(float), - vol.Optional(CONF_NAME): cv.string, - } - ), - ), - } + { + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_NAME): cv.string, + }, + ) + }, ), + _val_elevation, ) -_SUN2_BINARY_SENSOR_SCHEMA_W_DEFAULTS = vol.All(SUN2_BINARY_SENSOR_SCHEMA, _val_cfg) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [_SUN2_BINARY_SENSOR_SCHEMA_W_DEFAULTS] + cv.ensure_list, [_ELEVATION_SCHEMA] ), **LOC_PARAMS, } @@ -143,7 +114,7 @@ def __init__( loc_params: LocParams | None, extra: Sun2EntityParams | str | None, name: str, - above: float, + threshold: float | str, ) -> None: """Initialize sensor.""" if not isinstance(extra, Sun2EntityParams): @@ -158,7 +129,10 @@ def __init__( super().__init__(loc_params, 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 @@ -308,7 +282,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) @@ -334,23 +308,43 @@ def schedule_update(now: datetime) -> None: 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[str | dict[str, Any]], + hass: HomeAssistant | None = None, ) -> list[Entity]: """Create list of entities to add.""" sensors = [] for config in sensors_config: - if CONF_ELEVATION in config: - if isinstance(extra, Sun2EntityParams): - extra.unique_id = config[CONF_UNIQUE_ID] - name = config[CONF_NAME] - above = config[CONF_ELEVATION] - else: - name = config[CONF_ELEVATION][CONF_NAME] - above = config[CONF_ELEVATION][CONF_ABOVE] - sensors.append(Sun2ElevationSensor(loc_params, extra, name, above)) + if isinstance(extra, Sun2EntityParams): + extra.unique_id = config[CONF_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 @@ -386,14 +380,12 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" config = entry.options - if not (sensors_config := config.get(CONF_BINARY_SENSORS)): - return - async_add_entities( _sensors( get_loc_params(config), Sun2EntityParams(entry, sun2_dev_info(hass, entry)), - sensors_config, + config.get(CONF_BINARY_SENSORS, []), + hass, ), True, ) diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 5f7d348..833b06c 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -1,9 +1,6 @@ """Sun2 config validation.""" from __future__ import annotations -from collections.abc import Callable -from typing import cast - from astral import SunDirection import voluptuous as vol @@ -28,12 +25,10 @@ CONF_ELEVATION_AT_TIME, CONF_TIME_AT_ELEVATION, DOMAIN, - SUNSET_ELEV, ) -from .helpers import init_translations, translate +from .helpers import init_translations PACKAGE_MERGE_HINT = "list" -DEFAULT_ELEVATION = SUNSET_ELEV LOC_PARAMS = { vol.Inclusive(CONF_ELEVATION, "location"): vol.Coerce(float), @@ -65,7 +60,7 @@ } ) -ELEVATION_AT_TIME_SCHEMA = ELEVATION_AT_TIME_SCHEMA_BASE.extend( +_ELEVATION_AT_TIME_SCHEMA = ELEVATION_AT_TIME_SCHEMA_BASE.extend( {vol.Required(CONF_UNIQUE_ID): cv.string} ) @@ -73,18 +68,18 @@ vol.Coerce(float), vol.Range(min=-90, max=90), msg="invalid elevation" ) +_DIRECTIONS = [dir.lower() for dir in SunDirection.__members__] + TIME_AT_ELEVATION_SCHEMA_BASE = vol.Schema( { vol.Required(CONF_TIME_AT_ELEVATION): val_elevation, - vol.Optional(CONF_DIRECTION, default=SunDirection.RISING.name): vol.All( - vol.Upper, cv.enum(SunDirection) - ), + vol.Optional(CONF_DIRECTION, default=_DIRECTIONS[0]): vol.In(_DIRECTIONS), vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_NAME): cv.string, } ) -TIME_AT_ELEVATION_SCHEMA = TIME_AT_ELEVATION_SCHEMA_BASE.extend( +_TIME_AT_ELEVATION_SCHEMA = TIME_AT_ELEVATION_SCHEMA_BASE.extend( {vol.Required(CONF_UNIQUE_ID): cv.string} ) @@ -92,16 +87,16 @@ def _sensor(config: ConfigType) -> ConfigType: """Validate sensor config.""" if CONF_ELEVATION_AT_TIME in config: - return ELEVATION_AT_TIME_SCHEMA(config) + return _ELEVATION_AT_TIME_SCHEMA(config) if CONF_TIME_AT_ELEVATION in config: - return TIME_AT_ELEVATION_SCHEMA(config) + return _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.Optional(CONF_LOCATION): cv.string, + vol.Inclusive(CONF_LOCATION, "location"): cv.string, vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [_SUN2_BINARY_SENSOR_SCHEMA] ), @@ -132,111 +127,10 @@ def _unique_locations_names(configs: list[dict]) -> list[dict]: ) -def val_bs_elevation(hass: HomeAssistant | None = None) -> Callable[[dict], dict]: - """Validate elevation binary_sensor config.""" - - def validate(config: ConfigType) -> ConfigType: - """Validate the config.""" - if config[CONF_ELEVATION] == "horizon": - config[CONF_ELEVATION] = DEFAULT_ELEVATION - - if config.get(CONF_NAME): - return config - - if (elevation := config[CONF_ELEVATION]) == DEFAULT_ELEVATION: - name = translate(hass, "above_horizon") - else: - if elevation < 0: - name = translate(hass, "above_neg_elev", {"elevation": str(-elevation)}) - else: - name = translate(hass, "above_pos_elev", {"elevation": str(elevation)}) - config[CONF_NAME] = name - return config - - return validate - - -def val_elevation_at_time(hass: HomeAssistant | None = None) -> Callable[[dict], dict]: - """Validate elevation_at_time sensor config.""" - - def validate(config: ConfigType) -> ConfigType: - """Validate the config.""" - if config.get(CONF_NAME): - return config - - at_time = config[CONF_ELEVATION_AT_TIME] - if hass: - name = translate(hass, "elevation_at", {"elev_time": str(at_time)}) - else: - name = f"Elevation at {at_time}" - config[CONF_NAME] = name - return config - - return validate - - -_DIR_TO_ICON = { - SunDirection.RISING: "mdi:weather-sunset-up", - SunDirection.SETTING: "mdi:weather-sunset-down", -} - - -def val_time_at_elevation(hass: HomeAssistant | None = None) -> Callable[[dict], dict]: - """Validate time_at_elevation sensor config.""" - - def validate(config: ConfigType) -> ConfigType: - """Validate the config.""" - direction = SunDirection(config[CONF_DIRECTION]) - if not config.get(CONF_ICON): - config[CONF_ICON] = _DIR_TO_ICON[direction] - - if config.get(CONF_NAME): - return config - - elevation = cast(float, config[CONF_TIME_AT_ELEVATION]) - if hass: - name = translate( - hass, - f"{direction.name.lower()}_{'neg' if elevation < 0 else 'pos'}_elev", - {"elevation": str(abs(elevation))}, - ) - else: - dir_str = direction.name.title() - if elevation >= 0: - elev_str = str(elevation) - else: - elev_str = f"minus {-elevation}" - name = f"{dir_str} at {elev_str} °" - config[CONF_NAME] = name - return config - - return validate - - async def async_validate_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: """Validate configuration.""" await init_translations(hass) - config = _SUN2_CONFIG_SCHEMA(config) - if DOMAIN not in config: - return config - - _val_bs_elevation = val_bs_elevation(hass) - _val_elevation_at_time = val_elevation_at_time(hass) - _val_time_at_elevation = val_time_at_elevation(hass) - for loc_config in config[DOMAIN]: - if CONF_BINARY_SENSORS in loc_config: - loc_config[CONF_BINARY_SENSORS] = [ - _val_bs_elevation(cfg) for cfg in loc_config[CONF_BINARY_SENSORS] - ] - if CONF_SENSORS in loc_config: - sensor_configs = [] - for sensor_config in loc_config[CONF_SENSORS]: - if CONF_ELEVATION_AT_TIME in sensor_config: - sensor_configs.append(_val_elevation_at_time(sensor_config)) - else: - sensor_configs.append(_val_time_at_elevation(sensor_config)) - loc_config[CONF_SENSORS] = sensor_configs - return config + return _SUN2_CONFIG_SCHEMA(config) diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index fb61a90..e00f327 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -44,12 +44,7 @@ ) from homeassistant.util.uuid import random_uuid_hex -from .config import ( - val_bs_elevation, - val_elevation, - val_elevation_at_time, - val_time_at_elevation, -) +from .config import val_elevation from .const import ( CONF_DIRECTION, CONF_ELEVATION_AT_TIME, @@ -127,7 +122,7 @@ async def async_step_elevation_binary_sensor( if user_input is not None: if user_input["use_horizon"]: return await self.async_finish_sensor( - {CONF_ELEVATION: "horizon"}, val_bs_elevation, CONF_BINARY_SENSORS + {CONF_ELEVATION: "horizon"}, CONF_BINARY_SENSORS ) return await self.async_step_elevation_binary_sensor_2() @@ -142,9 +137,7 @@ async def async_step_elevation_binary_sensor_2( ) -> FlowResult: """Handle additional elevation binary sensor options.""" if user_input is not None: - return await self.async_finish_sensor( - user_input, val_bs_elevation, CONF_BINARY_SENSORS - ) + return await self.async_finish_sensor(user_input, CONF_BINARY_SENSORS) schema = { vol.Required(CONF_ELEVATION, default=0.0): NumberSelector( @@ -177,9 +170,7 @@ async def async_step_elevation_at_time_sensor_entity( ) -> 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, val_elevation_at_time, CONF_SENSORS - ) + return await self.async_finish_sensor(user_input, CONF_SENSORS) schema = { vol.Required(CONF_ELEVATION_AT_TIME): EntitySelector( @@ -198,9 +189,7 @@ async def async_step_elevation_at_time_sensor_time( ) -> FlowResult: """Handle elevation_at_time sensor options w/ time string.""" if user_input is not None: - return await self.async_finish_sensor( - user_input, val_elevation_at_time, CONF_SENSORS - ) + return await self.async_finish_sensor(user_input, CONF_SENSORS) schema = { vol.Required(CONF_ELEVATION_AT_TIME): TimeSelector(), @@ -220,9 +209,7 @@ async def async_step_time_at_elevation_sensor( user_input[CONF_DIRECTION] = vol.All(vol.Upper, cv.enum(SunDirection))( user_input[CONF_DIRECTION] ) - return await self.async_finish_sensor( - user_input, val_time_at_elevation, CONF_SENSORS - ) + return await self.async_finish_sensor(user_input, CONF_SENSORS) schema = { vol.Required(CONF_TIME_AT_ELEVATION, default=0.0): NumberSelector( @@ -247,13 +234,11 @@ async def async_step_time_at_elevation_sensor( async def async_finish_sensor( self, config: dict[str, Any], - validator: Callable[[HomeAssistant], Callable[[dict], dict]], sensor_type: str, ) -> FlowResult: """Finish elevation binary sensor.""" - sensor_option = validator(self.hass)(config) - sensor_option[CONF_UNIQUE_ID] = random_uuid_hex() - self.options.setdefault(sensor_type, []).append(sensor_option) + config[CONF_UNIQUE_ID] = random_uuid_hex() + self.options.setdefault(sensor_type, []).append(config) return await self.async_step_entities_menu() @abstractmethod diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 29675b9..56676a9 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -53,8 +53,6 @@ ELEVATION_AT_TIME_SCHEMA_BASE, LOC_PARAMS, TIME_AT_ELEVATION_SCHEMA_BASE, - val_elevation_at_time, - val_time_at_elevation, ) from .const import ( ATTR_BLUE_HOUR, @@ -91,6 +89,7 @@ hours_to_hms, nearest_second, next_midnight, + translate, ) _ENABLED_SENSORS = [ @@ -408,6 +407,11 @@ def __init__( elevation: float, ) -> 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, extra, CONF_TIME_AT_ELEVATION, icon, name) @@ -1167,30 +1171,68 @@ class SensorParams: "deconz_daylight": SensorParams(Sun2DeconzDaylightSensor, None), } -_ELEVATION_AT_TIME_SCHEMA = vol.All( - ELEVATION_AT_TIME_SCHEMA_BASE, val_elevation_at_time() -) -_TIME_AT_ELEVATION_SCHEMA = vol.All( - TIME_AT_ELEVATION_SCHEMA_BASE, val_time_at_elevation() -) -_SUN2_SENSOR_SCHEMA = vol.Any( - _ELEVATION_AT_TIME_SCHEMA, _TIME_AT_ELEVATION_SCHEMA, vol.In(_SENSOR_TYPES) -) + +def _sensor(config: str | ConfigType) -> ConfigType: + """Validate sensor config.""" + if isinstance(config, str): + return vol.In(_SENSOR_TYPES)(config) + if CONF_ELEVATION_AT_TIME in config: + return ELEVATION_AT_TIME_SCHEMA_BASE(config) + if CONF_TIME_AT_ELEVATION in config: + return 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}" + ) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [_SUN2_SENSOR_SCHEMA] - ), + vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [_sensor]), **LOC_PARAMS, } ) +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}" + 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 = [] @@ -1217,19 +1259,28 @@ def _sensors( Sun2ElevationAtTimeSensor( loc_params, extra, - config[CONF_NAME], + _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, - config[CONF_NAME], - config[CONF_ICON], - SunDirection(config[CONF_DIRECTION]), - config[CONF_TIME_AT_ELEVATION], + _time_at_elevation_name( + hass, + config.get(CONF_NAME), + direction, + elevation, + ), + config.get(CONF_ICON), + direction, + elevation, ) ) return sensors @@ -1271,7 +1322,7 @@ async def async_setup_entry( 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, [])) - + _sensors(loc_params, sun2_entity_params, _SENSOR_TYPES.keys()), + _sensors(loc_params, sun2_entity_params, config.get(CONF_SENSORS, []), hass) + + _sensors(loc_params, sun2_entity_params, _SENSOR_TYPES.keys(), hass), True, ) From 1c21156a4954ffc644b8ddaef337e7da9dde532a Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 1 Dec 2023 13:16:46 -0600 Subject: [PATCH 49/59] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8537af5..b1a7f33 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ A list of configuration options for one or more "locations". Each location is de 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. Default is Home Assistant's current location name. +`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.) @@ -54,7 +54,7 @@ Key | Optional | Description `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 configuration. +\* These must all be used together. If not used, the default is Home Assistant's location & name configuration. ### Binary Sensor Configurations From 4c590f7b3a32259512bc084c0c973965c5a85401 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 1 Dec 2023 13:17:41 -0600 Subject: [PATCH 50/59] Bump version to 3.0.0b9 --- custom_components/sun2/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index fda6276..0c27679 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/pnbruckner/ha-sun2/issues", "requirements": [], - "version": "3.0.0b7" + "version": "3.0.0b9" } From 3902be598e84cbca12d8bdb9ee575562a98e67da Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 4 Dec 2023 07:52:24 -0600 Subject: [PATCH 51/59] Miscellaneous improvements --- custom_components/sun2/config.py | 4 ++-- custom_components/sun2/config_flow.py | 21 ++++++--------------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 833b06c..d1943d6 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -68,12 +68,12 @@ vol.Coerce(float), vol.Range(min=-90, max=90), msg="invalid elevation" ) -_DIRECTIONS = [dir.lower() for dir in SunDirection.__members__] +SUN_DIRECTIONS = [dir.lower() for dir in SunDirection.__members__] TIME_AT_ELEVATION_SCHEMA_BASE = vol.Schema( { vol.Required(CONF_TIME_AT_ELEVATION): val_elevation, - vol.Optional(CONF_DIRECTION, default=_DIRECTIONS[0]): vol.In(_DIRECTIONS), + vol.Optional(CONF_DIRECTION, default=SUN_DIRECTIONS[0]): vol.In(SUN_DIRECTIONS), vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_NAME): cv.string, } diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index e00f327..7f5af66 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -2,11 +2,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Callable from contextlib import suppress from typing import Any, cast -from astral import SunDirection import voluptuous as vol from homeassistant.config_entries import ( @@ -27,7 +25,7 @@ CONF_TIME_ZONE, CONF_UNIQUE_ID, ) -from homeassistant.core import callback, HomeAssistant +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( @@ -44,7 +42,7 @@ ) from homeassistant.util.uuid import random_uuid_hex -from .config import val_elevation +from .config import SUN_DIRECTIONS, val_elevation from .const import ( CONF_DIRECTION, CONF_ELEVATION_AT_TIME, @@ -206,9 +204,6 @@ async def async_step_time_at_elevation_sensor( ) -> FlowResult: """Handle time_at_elevation sensor options.""" if user_input is not None: - user_input[CONF_DIRECTION] = vol.All(vol.Upper, cv.enum(SunDirection))( - user_input[CONF_DIRECTION] - ) return await self.async_finish_sensor(user_input, CONF_SENSORS) schema = { @@ -219,7 +214,7 @@ async def async_step_time_at_elevation_sensor( ), vol.Required(CONF_DIRECTION): SelectSelector( SelectSelectorConfig( - options=["rising", "setting"], translation_key="direction" + options=SUN_DIRECTIONS, translation_key="direction" ) ), vol.Optional(CONF_ICON): IconSelector(), @@ -277,21 +272,17 @@ def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Import config entry from configuration.""" - self._location_name = cast( - str, data.pop(CONF_LOCATION, self.hass.config.location_name) - ) + 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=self._location_name, options=data + 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") - self.options.clear() - self.options.update(data) - return await self.async_step_done() + 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.""" From 30ee8008700a217501a4a62cc51080f446e88650 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 5 Dec 2023 10:14:09 -0600 Subject: [PATCH 52/59] ruff, pylint, black & mypy --- custom_components/sun2/__init__.py | 21 +-- custom_components/sun2/binary_sensor.py | 32 ++--- custom_components/sun2/config.py | 8 +- custom_components/sun2/config_flow.py | 20 ++- custom_components/sun2/helpers.py | 22 ++-- custom_components/sun2/sensor.py | 167 +++++++++++------------- 6 files changed, 135 insertions(+), 135 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 09f7fbc..03d8e2d 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -2,18 +2,19 @@ from __future__ import annotations import asyncio -from typing import cast +from collections.abc import Coroutine +from typing import Any, cast from astral import SunDirection -from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_SENSORS, CONF_UNIQUE_ID, EVENT_CORE_CONFIG_UPDATE, - Platform, SERVICE_RELOAD, + Platform, ) from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.helpers.dispatcher import dispatcher_send @@ -24,12 +25,11 @@ 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] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Setup composite integration.""" + """Set up composite integration.""" def update_local_loc_data() -> LocData: """Update local location data from HA's config.""" @@ -43,11 +43,14 @@ def update_local_loc_data() -> LocData: ) return loc_data - async def process_config(config: ConfigType, run_immediately: bool = True) -> None: + async def process_config( + config: ConfigType | None, run_immediately: bool = True + ) -> None: """Process sun2 config.""" - configs = config.get(DOMAIN, []) + if not config or not (configs := config.get(DOMAIN)): + configs = [] unique_ids = [config[CONF_UNIQUE_ID] for config in configs] - tasks = [] + tasks: list[Coroutine[Any, Any, Any]] = [] for entry in hass.config_entries.async_entries(DOMAIN): if entry.source != SOURCE_IMPORT: @@ -82,7 +85,7 @@ async def handle_core_config_update(event: Event) -> None: loc_data = update_local_loc_data() - if not any(key in event.data for key in ["location_name", "language"]): + 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 diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 69c83e4..5710e79 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -1,16 +1,17 @@ """Sun2 Binary Sensor.""" from __future__ import annotations +from collections.abc import Iterable from datetime import datetime -from typing import Any, Iterable, cast +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 ConfigEntry from homeassistant.const import ( @@ -44,11 +45,11 @@ from .helpers import ( LocParams, Num, - sun2_dev_info, Sun2Entity, Sun2EntityParams, get_loc_params, nearest_second, + sun2_dev_info, translate, ) @@ -126,7 +127,7 @@ def __init__( self.entity_description = BinarySensorEntityDescription( key=CONF_ELEVATION, name=name ) - super().__init__(loc_params, extra) + super().__init__(loc_params, cast(Sun2EntityParams | None, extra)) self._event = "solar_elevation" if isinstance(threshold, str): @@ -230,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 @@ -298,13 +299,12 @@ 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} @@ -330,11 +330,11 @@ def _elevation_name( def _sensors( loc_params: LocParams | None, extra: Sun2EntityParams | str | None, - sensors_config: Iterable[str | dict[str, Any]], + sensors_config: Iterable[ConfigType], hass: HomeAssistant | None = None, ) -> list[Entity]: """Create list of entities to add.""" - sensors = [] + sensors: list[Entity] = [] for config in sensors_config: if isinstance(extra, Sun2EntityParams): extra.unique_id = config[CONF_UNIQUE_ID] @@ -356,7 +356,7 @@ async def async_setup_platform( ) -> None: """Set up sensors.""" LOGGER.warning( - "%s: %s under %s is deprecated. Move to %s: ...", + "%s: %s under %s is deprecated. Move to %s:", CONF_PLATFORM, DOMAIN, BINARY_SENSOR_DOMAIN, diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index d1943d6..0db3484 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 typing import cast + from astral import SunDirection import voluptuous as vol @@ -87,9 +89,9 @@ def _sensor(config: ConfigType) -> ConfigType: """Validate sensor config.""" if CONF_ELEVATION_AT_TIME in config: - return _ELEVATION_AT_TIME_SCHEMA(config) + return cast(ConfigType, _ELEVATION_AT_TIME_SCHEMA(config)) if CONF_TIME_AT_ELEVATION in config: - return _TIME_AT_ELEVATION_SCHEMA(config) + return cast(ConfigType, _TIME_AT_ELEVATION_SCHEMA(config)) raise vol.Invalid(f"expected {CONF_ELEVATION_AT_TIME} or {CONF_TIME_AT_ELEVATION}") @@ -133,4 +135,4 @@ async def async_validate_config( """Validate configuration.""" await init_translations(hass) - return _SUN2_CONFIG_SCHEMA(config) + return cast(ConfigType, _SUN2_CONFIG_SCHEMA(config)) diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index 7f5af66..f5ce58a 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -1,17 +1,17 @@ """Config flow for Sun2 integration.""" from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod from contextlib import suppress from typing import Any, cast import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_IMPORT, ConfigEntry, ConfigFlow, OptionsFlowWithConfigEntry, - SOURCE_IMPORT, ) from homeassistant.const import ( CONF_BINARY_SENSORS, @@ -26,7 +26,7 @@ CONF_UNIQUE_ID, ) from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import FlowHandler, FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( EntitySelector, @@ -54,7 +54,7 @@ _LOCATION_OPTIONS = [CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_TIME_ZONE] -class Sun2Flow(ABC): +class Sun2Flow(FlowHandler): """Sun2 flow mixin.""" _existing_entries: list[ConfigEntry] | None = None @@ -66,6 +66,11 @@ def _entries(self) -> list[ConfigEntry]: self._existing_entries = self.hass.config_entries.async_entries(DOMAIN) return self._existing_entries + @abstractmethod + @property + 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) @@ -250,7 +255,7 @@ class Sun2ConfigFlow(ConfigFlow, Sun2Flow, domain=DOMAIN): def __init__(self) -> None: """Initialize config flow.""" - self.options = {} + self._options: dict[str, Any] = {} @staticmethod @callback @@ -270,6 +275,11 @@ def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: 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)) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 86be9e9..2a00050 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -24,12 +24,11 @@ try: from homeassistant.helpers.device_registry import DeviceInfo except ImportError: - from homeassistant.helpers.entity import DeviceInfo + 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.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from .const import ( @@ -40,12 +39,10 @@ ATTR_YESTERDAY, ATTR_YESTERDAY_HMS, DOMAIN, - LOGGER, ONE_DAY, SIG_HA_LOC_UPDATED, ) - Num = Union[float, int] @@ -84,7 +81,7 @@ class Sun2Data: language: str | None = None -def get_loc_params(config: ConfigType) -> LocParams | None: +def get_loc_params(config: Mapping[str, Any]) -> LocParams | None: """Get location parameters from configuration.""" try: return LocParams( @@ -100,7 +97,7 @@ 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(hours=int(cast(Num, hours)))) except TypeError: return None @@ -207,7 +204,7 @@ def __init__( ) self._attr_device_info = sun2_entity_params.device_info else: - self._attr_unique_id = self.name + self._attr_unique_id = cast(str, self.name) self._loc_params = loc_params self.async_on_remove(self._cancel_update) @@ -265,11 +262,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, @@ -287,13 +282,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/sensor.py b/custom_components/sun2/sensor.py index 56676a9..1ab421e 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, Iterable, Optional, TypeVar, Union, cast +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 ( @@ -37,7 +36,7 @@ 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 @@ -70,10 +69,10 @@ CONF_ELEVATION_AT_TIME, CONF_TIME_AT_ELEVATION, DOMAIN, - HALF_DAY, - MAX_ERR_ELEV, ELEV_STEP, + HALF_DAY, LOGGER, + MAX_ERR_ELEV, MAX_ERR_PHASE, ONE_DAY, SUNSET_ELEV, @@ -82,13 +81,13 @@ LocData, LocParams, Num, - sun2_dev_info, Sun2Entity, Sun2EntityParams, get_loc_params, hours_to_hms, nearest_second, next_midnight, + sun2_dev_info, translate, ) @@ -138,12 +137,11 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ) - super().__init__(loc_params, extra) + 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.""" @@ -203,7 +201,7 @@ def __init__( extra = None entity_description.name = name self.entity_description = entity_description - super().__init__(loc_params, extra) + 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) @@ -302,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: @@ -312,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) @@ -524,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 @@ -576,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.""" @@ -589,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: @@ -618,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( @@ -681,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)) @@ -740,7 +735,7 @@ def _update(self, cur_dttm: datetime) -> None: self._attr_native_value = rnd_elev = round(cur_elev, 1) 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) @@ -763,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 @@ -836,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: @@ -904,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): @@ -924,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.""" @@ -946,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: @@ -984,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: @@ -1104,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 @@ -1115,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)) @@ -1175,11 +1166,11 @@ class SensorParams: def _sensor(config: str | ConfigType) -> ConfigType: """Validate sensor config.""" if isinstance(config, str): - return vol.In(_SENSOR_TYPES)(config) + return cast(ConfigType, vol.In(_SENSOR_TYPES)(config)) if CONF_ELEVATION_AT_TIME in config: - return ELEVATION_AT_TIME_SCHEMA_BASE(config) + return cast(ConfigType, ELEVATION_AT_TIME_SCHEMA_BASE(config)) if CONF_TIME_AT_ELEVATION in config: - return TIME_AT_ELEVATION_SCHEMA_BASE(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}" @@ -1294,7 +1285,7 @@ async def async_setup_platform( ) -> None: """Set up sensors.""" LOGGER.warning( - "%s: %s under %s is deprecated. Move to %s: ...", + "%s: %s under %s is deprecated. Move to %s:", CONF_PLATFORM, DOMAIN, SENSOR_DOMAIN, From 43415081340747b37d3ac8b24865cdf8d213868a Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 5 Dec 2023 20:55:54 -0600 Subject: [PATCH 53/59] Fix bug with use of abstractmethod & property decorators. --- custom_components/sun2/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index f5ce58a..cddcb43 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -66,8 +66,8 @@ def _entries(self) -> list[ConfigEntry]: self._existing_entries = self.hass.config_entries.async_entries(DOMAIN) return self._existing_entries - @abstractmethod @property + @abstractmethod def options(self) -> dict[str, Any]: """Return mutable copy of options.""" From 29808396541694ce74028cd2e683d0a0eed62f35 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 7 Dec 2023 07:16:18 -0600 Subject: [PATCH 54/59] Fix bug in hours_to_hms introduced in previous commit --- custom_components/sun2/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 2a00050..540672f 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -97,7 +97,7 @@ def get_loc_params(config: Mapping[str, Any]) -> LocParams | None: def hours_to_hms(hours: Num | None) -> str | None: """Convert hours to HH:MM:SS string.""" try: - return str(timedelta(hours=int(cast(Num, hours)))) + return str(timedelta(seconds=int(cast(Num, hours) * 3600))) except TypeError: return None From 5a4323fcc9c6d89d4d27f2242979ac1e942dd486 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 8 Dec 2023 07:45:47 -0600 Subject: [PATCH 55/59] Use only new style selectors --- custom_components/sun2/config.py | 8 +- custom_components/sun2/config_flow.py | 163 ++++++++++++-------- custom_components/sun2/translations/en.json | 30 +--- custom_components/sun2/translations/nl.json | 26 +--- 4 files changed, 117 insertions(+), 110 deletions(-) diff --git a/custom_components/sun2/config.py b/custom_components/sun2/config.py index 0db3484..01c54d6 100644 --- a/custom_components/sun2/config.py +++ b/custom_components/sun2/config.py @@ -66,15 +66,13 @@ {vol.Required(CONF_UNIQUE_ID): cv.string} ) -val_elevation = vol.All( - vol.Coerce(float), vol.Range(min=-90, max=90), msg="invalid elevation" -) - SUN_DIRECTIONS = [dir.lower() for dir in SunDirection.__members__] TIME_AT_ELEVATION_SCHEMA_BASE = vol.Schema( { - vol.Required(CONF_TIME_AT_ELEVATION): val_elevation, + 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, diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index cddcb43..48cd588 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -29,9 +29,11 @@ from homeassistant.data_entry_flow import FlowHandler, FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( + BooleanSelector, EntitySelector, EntitySelectorConfig, IconSelector, + LocationSelector, NumberSelector, NumberSelectorConfig, NumberSelectorMode, @@ -42,7 +44,7 @@ ) from homeassistant.util.uuid import random_uuid_hex -from .config import SUN_DIRECTIONS, val_elevation +from .config import SUN_DIRECTIONS from .const import ( CONF_DIRECTION, CONF_ELEVATION_AT_TIME, @@ -81,28 +83,44 @@ async def async_step_location( """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() - schema = { - vol.Required( - CONF_LATITUDE, default=self.options.get(CONF_LATITUDE, vol.UNDEFINED) - ): cv.latitude, - vol.Required( - CONF_LONGITUDE, - default=self.options.get(CONF_LONGITUDE, vol.UNDEFINED), - ): cv.longitude, - vol.Required( - CONF_ELEVATION, - default=self.options.get(CONF_ELEVATION, vol.UNDEFINED), - ): val_elevation, - vol.Required( - CONF_TIME_ZONE, - default=self.options.get(CONF_TIME_ZONE, vol.UNDEFINED), - ): cv.string, - } + 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=vol.Schema(schema), last_step=False + step_id="location", data_schema=data_schema, last_step=False ) async def async_step_entities_menu( @@ -131,7 +149,7 @@ async def async_step_elevation_binary_sensor( return self.async_show_form( step_id="elevation_binary_sensor", - data_schema=vol.Schema({vol.Required("use_horizon", default=False): bool}), + data_schema=vol.Schema({vol.Required("use_horizon"): BooleanSelector()}), last_step=False, ) @@ -142,17 +160,22 @@ async def async_step_elevation_binary_sensor_2( if user_input is not None: return await self.async_finish_sensor(user_input, CONF_BINARY_SENSORS) - schema = { - vol.Required(CONF_ELEVATION, default=0.0): NumberSelector( - NumberSelectorConfig( - min=-90, max=90, step="any", mode=NumberSelectorMode.BOX - ) - ), - vol.Optional(CONF_NAME): TextSelector(), - } + 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=vol.Schema(schema), + data_schema=data_schema, last_step=False, ) @@ -175,15 +198,17 @@ async def async_step_elevation_at_time_sensor_entity( if user_input is not None: return await self.async_finish_sensor(user_input, CONF_SENSORS) - schema = { - vol.Required(CONF_ELEVATION_AT_TIME): EntitySelector( - EntitySelectorConfig(domain="input_datetime") - ), - vol.Optional(CONF_NAME): TextSelector(), - } + 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=vol.Schema(schema), + data_schema=data_schema, last_step=False, ) @@ -194,13 +219,15 @@ async def async_step_elevation_at_time_sensor_time( if user_input is not None: return await self.async_finish_sensor(user_input, CONF_SENSORS) - schema = { - vol.Required(CONF_ELEVATION_AT_TIME): TimeSelector(), - vol.Optional(CONF_NAME): TextSelector(), - } + 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=vol.Schema(schema), + data_schema=data_schema, last_step=False, ) @@ -211,23 +238,28 @@ async def async_step_time_at_elevation_sensor( if user_input is not None: return await self.async_finish_sensor(user_input, CONF_SENSORS) - schema = { - vol.Required(CONF_TIME_AT_ELEVATION, default=0.0): 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 = 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=vol.Schema(schema), + data_schema=data_schema, last_step=False, ) @@ -251,7 +283,7 @@ class Sun2ConfigFlow(ConfigFlow, Sun2Flow, domain=DOMAIN): VERSION = 1 - _location_name: str | vol.UNDEFINED = vol.UNDEFINED + _location_name: str | None = None def __init__(self) -> None: """Initialize config flow.""" @@ -313,9 +345,10 @@ async def async_step_use_home( return await self.async_step_entities_menu() return await self.async_step_location_name() - schema = {vol.Required("use_home", default=True): bool} return self.async_show_form( - step_id="use_home", data_schema=vol.Schema(schema), last_step=False + step_id="use_home", + data_schema=vol.Schema({vol.Required("use_home"): BooleanSelector()}), + last_step=False, ) async def async_step_location_name( @@ -325,15 +358,19 @@ async def async_step_location_name( errors = {} if user_input is not None: - self._location_name = user_input[CONF_NAME] + 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" - schema = {vol.Required(CONF_NAME, default=self._location_name): TextSelector()} + 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=vol.Schema(schema), + data_schema=data_schema, errors=errors, last_step=False, ) @@ -341,7 +378,7 @@ async def async_step_location_name( async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: """Finish the flow.""" return self.async_create_entry( - title=self._location_name, data={}, options=self.options + title=cast(str, self._location_name), data={}, options=self.options ) diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index b0488fd..32e7812 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -6,7 +6,7 @@ "title": "Elevation at Time Type", "menu_options": { "elevation_at_time_sensor_entity": "input_datetime entity", - "elevation_at_time_sensor_time": "Time string" + "elevation_at_time_sensor_time": "Time" } }, "elevation_at_time_sensor_entity": { @@ -26,7 +26,7 @@ "elevation_binary_sensor": { "title": "Elevation Binary Sensor Options", "data": { - "use_horizon": "Use horizon as elevation?" + "use_horizon": "Use horizon as elevation" } }, "elevation_binary_sensor_2": { @@ -49,8 +49,7 @@ "title": "Location Options", "data": { "elevation": "Elevation", - "latitude": "Latitude", - "longitude": "Longitude", + "location": "Location", "time_zone": "Time zone" }, "data_description": { @@ -63,12 +62,6 @@ "name": "Name" } }, - "remove_entity": { - "title": "Remove {name}?", - "data": { - "remove": "Remove" - } - }, "time_at_elevation_sensor": { "title": "Time at Elevation Sensor Options", "data": { @@ -80,7 +73,7 @@ }, "use_home": { "data": { - "use_home": "Use Home Assistant name and location?" + "use_home": "Use Home Assistant name and location" } } }, @@ -94,7 +87,7 @@ "title": "Elevation at Time Type", "menu_options": { "elevation_at_time_sensor_entity": "input_datetime entity", - "elevation_at_time_sensor_time": "Time string" + "elevation_at_time_sensor_time": "Time" } }, "elevation_at_time_sensor_entity": { @@ -114,7 +107,7 @@ "elevation_binary_sensor": { "title": "Elevation Binary Sensor Options", "data": { - "use_horizon": "Use horizon as elevation?" + "use_horizon": "Use horizon as elevation" } }, "elevation_binary_sensor_2": { @@ -137,20 +130,13 @@ "title": "Location Options", "data": { "elevation": "Elevation", - "latitude": "Latitude", - "longitude": "Longitude", + "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_entity": { - "title": "Remove {name}?", - "data": { - "remove": "Remove" - } - }, "time_at_elevation_sensor": { "title": "Time at Elevation Sensor Options", "data": { @@ -162,7 +148,7 @@ }, "use_home": { "data": { - "use_home": "Use Home Assistant name and location?" + "use_home": "Use Home Assistant name and location" } } } diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json index 0ee0570..64f4172 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -26,7 +26,7 @@ "elevation_binary_sensor": { "title": "Opties voor binaire elevatiesensoren", "data": { - "use_horizon": "Horizon als elevatie gebruiken?" + "use_horizon": "Gebruik horizon als elevatie" } }, "elevation_binary_sensor_2": { @@ -49,8 +49,7 @@ "title": "Locatie Opties", "data": { "elevation": "Hoogtehoek", - "latitude": "Breedtegraad", - "longitude": "Lengtegraad", + "location": "Plaats", "time_zone": "Tijdzone" }, "data_description": { @@ -63,12 +62,6 @@ "name": "Naam" } }, - "remove_entity": { - "title": "{name} verwijderen?", - "data": { - "remove": "ReVerwijderenmove" - } - }, "time_at_elevation_sensor": { "title": "Opties voor tijdsensor op hoogte", "data": { @@ -80,7 +73,7 @@ }, "use_home": { "data": { - "use_home": "De naam en locatie van de Home Assistant gebruiken?" + "use_home": "De naam en locatie van de Home Assistant gebruiken" } } }, @@ -114,7 +107,7 @@ "elevation_binary_sensor": { "title": "Opties voor binaire elevatiesensoren", "data": { - "use_horizon": "Horizon als elevatie gebruiken?" + "use_horizon": "Gebruik horizon als elevatie" } }, "elevation_binary_sensor_2": { @@ -137,20 +130,13 @@ "title": "Locatie Opties", "data": { "elevation": "Hoogtehoek", - "latitude": "Breedtegraad", - "longitude": "Lengtegraad", + "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_entity": { - "title": "{name} verwijderen?", - "data": { - "remove": "ReVerwijderenmove" - } - }, "time_at_elevation_sensor": { "title": "Opties voor tijdsensor op hoogte", "data": { @@ -162,7 +148,7 @@ }, "use_home": { "data": { - "use_home": "De naam en locatie van de Home Assistant gebruiken?" + "use_home": "De naam en locatie van de Home Assistant gebruiken" } } } From ff14d49e404f8c6189d86fc2415afc006d3ce596 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 8 Dec 2023 10:28:09 -0600 Subject: [PATCH 56/59] Simplify unique IDs for UI added additional sensors --- custom_components/sun2/__init__.py | 11 +++++++++++ custom_components/sun2/binary_sensor.py | 7 +++++-- custom_components/sun2/config_flow.py | 4 +--- custom_components/sun2/helpers.py | 6 +----- custom_components/sun2/sensor.py | 9 ++++++--- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 03d8e2d..0cc60e7 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Coroutine +import re from typing import Any, cast from astral import SunDirection @@ -17,6 +18,7 @@ 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 @@ -26,6 +28,7 @@ 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})") async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -129,6 +132,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 diff --git a/custom_components/sun2/binary_sensor.py b/custom_components/sun2/binary_sensor.py index 5710e79..7e3f014 100644 --- a/custom_components/sun2/binary_sensor.py +++ b/custom_components/sun2/binary_sensor.py @@ -13,7 +13,7 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_ABOVE, CONF_BINARY_SENSORS, @@ -337,7 +337,10 @@ def _sensors( sensors: list[Entity] = [] for config in sensors_config: if isinstance(extra, Sun2EntityParams): - extra.unique_id = config[CONF_UNIQUE_ID] + 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: diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index 48cd588..7d57203 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -116,9 +116,7 @@ async def async_step_location( 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 - ) + 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 ) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 540672f..5bda08c 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -197,11 +197,7 @@ def __init__( if sun2_entity_params: self._attr_has_entity_name = True self._attr_translation_key = self.entity_description.key - entry = sun2_entity_params.entry - unique_id = sun2_entity_params.unique_id - self._attr_unique_id = ( - f"{entry.entry_id}-{unique_id or 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) diff --git a/custom_components/sun2/sensor.py b/custom_components/sun2/sensor.py index 1ab421e..ba31a45 100644 --- a/custom_components/sun2/sensor.py +++ b/custom_components/sun2/sensor.py @@ -21,7 +21,7 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ICON, CONF_ENTITY_NAMESPACE, @@ -1230,7 +1230,7 @@ def _sensors( for config in sensors_config: if isinstance(config, str): if isinstance(extra, Sun2EntityParams): - extra.unique_id = None + extra.unique_id = f"{extra.entry.entry_id}-{config}" sensors.append( _SENSOR_TYPES[config].cls( loc_params, extra, config, _SENSOR_TYPES[config].icon @@ -1238,7 +1238,10 @@ def _sensors( ) else: if isinstance(extra, Sun2EntityParams): - extra.unique_id = config[CONF_UNIQUE_ID] + 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. From a30b1be4777512cce796c76084f0c103886bfb72 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 9 Dec 2023 06:38:10 -0600 Subject: [PATCH 57/59] Add option to remove additional entities --- custom_components/sun2/__init__.py | 14 ++++ custom_components/sun2/config_flow.py | 76 ++++++++++++++++++++- custom_components/sun2/translations/en.json | 37 ++++++++-- custom_components/sun2/translations/nl.json | 37 ++++++++-- 4 files changed, 148 insertions(+), 16 deletions(-) diff --git a/custom_components/sun2/__init__.py b/custom_components/sun2/__init__.py index 0cc60e7..f1b4535 100644 --- a/custom_components/sun2/__init__.py +++ b/custom_components/sun2/__init__.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONF_BINARY_SENSORS, CONF_LATITUDE, CONF_SENSORS, CONF_UNIQUE_ID, @@ -29,6 +30,7 @@ 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: @@ -116,6 +118,18 @@ async def handle_core_config_update(event: Event) -> None: 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) diff --git a/custom_components/sun2/config_flow.py b/custom_components/sun2/config_flow.py index 7d57203..dec5c02 100644 --- a/custom_components/sun2/config_flow.py +++ b/custom_components/sun2/config_flow.py @@ -7,6 +7,8 @@ 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, @@ -27,6 +29,7 @@ ) 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, @@ -60,6 +63,7 @@ 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]: @@ -68,6 +72,27 @@ def _entries(self) -> list[ConfigEntry]: 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]: @@ -126,13 +151,25 @@ async def async_step_entities_menu( ) -> 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="entities_menu", menu_options=menu_options) + 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 @@ -269,7 +306,42 @@ async def async_finish_sensor( """Finish elevation binary sensor.""" config[CONF_UNIQUE_ID] = random_uuid_hex() self.options.setdefault(sensor_type, []).append(config) - return await self.async_step_entities_menu() + 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: diff --git a/custom_components/sun2/translations/en.json b/custom_components/sun2/translations/en.json index 32e7812..fb83d35 100644 --- a/custom_components/sun2/translations/en.json +++ b/custom_components/sun2/translations/en.json @@ -2,6 +2,16 @@ "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": { @@ -39,10 +49,8 @@ "entities_menu": { "title": "Additional Entities", "menu_options": { - "done": "Done", - "elevation_at_time_sensor_menu": "Add elevation at time sensor", - "elevation_binary_sensor": "Add elevation binary sensor", - "time_at_elevation_sensor": "Add time at elevation sensor" + "add_entities_menu": "Add entities", + "done": "Done" } }, "location": { @@ -83,6 +91,16 @@ }, "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": { @@ -120,10 +138,9 @@ "entities_menu": { "title": "Additional Entities", "menu_options": { + "add_entities_menu": "Add entities", "done": "Done", - "elevation_at_time_sensor_menu": "Add elevation at time sensor", - "elevation_binary_sensor": "Add elevation binary sensor", - "time_at_elevation_sensor": "Add time at elevation sensor" + "remove_entities": "Remove entities" } }, "location": { @@ -137,6 +154,12 @@ "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": { diff --git a/custom_components/sun2/translations/nl.json b/custom_components/sun2/translations/nl.json index 64f4172..313f9ac 100644 --- a/custom_components/sun2/translations/nl.json +++ b/custom_components/sun2/translations/nl.json @@ -2,6 +2,16 @@ "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": { @@ -39,10 +49,8 @@ "entities_menu": { "title": "Aanvullende entiteiten", "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" + "add_entities_menu": "Entiteiten toevoegen", + "done": "Klaar" } }, "location": { @@ -83,6 +91,16 @@ }, "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": { @@ -120,10 +138,9 @@ "entities_menu": { "title": "Aanvullende entiteiten", "menu_options": { + "add_entities_menu": "Entiteiten toevoegen", "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" + "remove_entities": "Entiteiten verwijderen" } }, "location": { @@ -137,6 +154,12 @@ "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": { From 11ab5c2bc4cca02f6b4c2389e02abf27f8665ff3 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 9 Dec 2023 09:19:00 -0600 Subject: [PATCH 58/59] Bump version to 3.0.0b10 --- custom_components/sun2/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/manifest.json b/custom_components/sun2/manifest.json index 0c27679..47417c6 100644 --- a/custom_components/sun2/manifest.json +++ b/custom_components/sun2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/pnbruckner/ha-sun2/issues", "requirements": [], - "version": "3.0.0b9" + "version": "3.0.0b10" } From b0cd85bbc211283f9dc97678366b0288f2eebfa9 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 11 Dec 2023 15:08:27 -0600 Subject: [PATCH 59/59] Fix _unrecorded_attributes --- custom_components/sun2/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sun2/helpers.py b/custom_components/sun2/helpers.py index 5bda08c..c8e7dd4 100644 --- a/custom_components/sun2/helpers.py +++ b/custom_components/sun2/helpers.py @@ -167,7 +167,7 @@ class Sun2EntityParams: class Sun2Entity(Entity): """Sun2 Entity.""" - _unreported_attributes = frozenset( + _unrecorded_attributes = frozenset( { ATTR_NEXT_CHANGE, ATTR_TODAY_HMS,