Skip to content

Commit

Permalink
Allow more flexible configuration of what affects sunrise & sunset ev…
Browse files Browse the repository at this point in the history
…ents (#116)
  • Loading branch information
pnbruckner authored May 9, 2024
1 parent e4d1348 commit d6a8de6
Show file tree
Hide file tree
Showing 11 changed files with 1,241 additions and 451 deletions.
53 changes: 47 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ This custom integration supports HomeAssistant versions 2023.4.0 or newer.

Reloads Sun2 from the YAML-configuration. Also adds `SUN2` to the Developers Tools -> YAML page.

### `sun2.get_location`

Responds with the current location configuration options (latitude, etc.) of the specified location.
Takes just one parameter, `location`, which is the name of the location, and is also the name of the corresponding integration entry.

### `sun2.update_location`

Updates one or more parts of the location configuration options of the specified location.
Takes one required parameter, `location`. (Same as `sun2.get_location` service.)
Can also take location parameters (`latitude`, `longitude` & `time_zone`), which if any are specified, they must all be specified.
Can also take observer elevation parameters (`obeserver_elevation`).
These parameters are the same as specified below in the YAML configuration section.

## Configuration

One or more "locations" can be added for this integration.
Expand All @@ -75,16 +88,44 @@ Key | Optional | Description
`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 observer's elevation above ground level at specified location (in meters)
`observer_elevation` | yes | What affects sunrise & sunset as defined [here](#observer-elevation)
`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, elevation & name configuration.
\* These must all be used together. If not used, the default is Home Assistant's location, time zone & name configuration.

### Observer Elevation

The easterly sun events (sunrise, dawn, etc.) and westerly sun events (sunset, dusk, etc.)
can be viewed at either the horizon, or at the top of some obstruction, such as a mountain.
(For more details, see [Effect of Elevation](https://sffjunkie.github.io/astral/#effect-of-elevation).)
This configuration option describes which apply at the specified location.

There are two basic choices. The first is that the sun events are seen at the horizon in both directions.
In this case, a simple number may be given which defines the observer's elevation above _ground_ level (not sea level) in meters.
If this option is not specified, a default of zero is used.

The second choice is specifying an obstruction in one or both directions (`sunrise_obstruction` and/or `sunset_obstruction`).
For each used, the horizontal distance to the obstruction (`distance`),
and the height of the top of the obstruction, relative to the observer (`relative_height`), must be specified.
Note that the relative height can be negative (e.g., the observer is on an even taller mountain.)
If only one is used, then the observer's elevation above ground level (`above_ground`) must also be specified (for the other direction.)

Here are some examples:

```yaml
observer_elevation: 5

observer_elevation:
sunrise_obstruction: {distance: 10000, relative_height: 2000}
sunset_obstruction: {distance: 5000, relative_height: -100}

observer_elevation:
above_ground: 0
sunrise_obstruction: {distance: 10000, relative_height: 2000}
```
> NOTE: Home Assistant describes the elevation setting as "above sea level."
> For the purpose of determining sunrise, etc., that is incorrect.
> It should be the observer's elevation above ground level at the specified location.
> For more details, see [Effect of Elevation](https://sffjunkie.github.io/astral/#effect-of-elevation)
> Note that this replaces the `elevation` option used in previous versions.

### Binary Sensor Configurations

Expand Down
209 changes: 153 additions & 56 deletions custom_components/sun2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,93 +4,150 @@
import asyncio
from collections.abc import Coroutine
import re
from typing import Any, cast
from typing import Any

from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
import voluptuous as vol

from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntry
from homeassistant.const import (
CONF_BINARY_SENSORS,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
CONF_SENSORS,
CONF_TIME_ZONE,
CONF_UNIQUE_ID,
EVENT_CORE_CONFIG_UPDATE,
SERVICE_RELOAD,
Platform,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.core import (
Event,
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.dispatcher import async_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
from .helpers import LocData, LocParams, Sun2Data
from .config import (
SUN2_LOCATION_BASE_SCHEMA,
obs_elv_from_options,
options_from_obs_elv,
)
from .config_flow import loc_from_options
from .const import CONF_OBS_ELV, DOMAIN, SIG_ASTRAL_DATA_UPDATED, SIG_HA_LOC_UPDATED
from .helpers import ConfigData, ObsElvs, get_loc_data, sun2_data

PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
_OLD_UNIQUE_ID = re.compile(r"[0-9a-f]{32}-([0-9a-f]{32})")
_UUID_UNIQUE_ID = re.compile(r"[0-9a-f]{32}")


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up composite integration."""

def update_local_loc_data() -> LocData:
"""Update local location data from HA's config."""
cast(Sun2Data, hass.data[DOMAIN]).locations[None] = loc_data = LocData(
LocParams(
hass.config.elevation,
hass.config.latitude,
hass.config.longitude,
str(hass.config.time_zone),
_UUID_UNIQUE_ID = re.compile(r"[0-9a-f]{32}")
_LOCATION = {vol.Required(CONF_LOCATION): cv.string}
_GET_LOCATION_SERVICE_SCHEMA = vol.Schema(_LOCATION)
_UPDATE_LOCATION_SERVICE_SCHEMA = SUN2_LOCATION_BASE_SCHEMA.extend(_LOCATION)


async def _process_config(
hass: HomeAssistant, config: ConfigType | None, run_immediately: bool = True
) -> None:
"""Process sun2 config."""
if not config or not (configs := config.get(DOMAIN)):
configs = []
unique_ids = [config[CONF_UNIQUE_ID] for config in configs]
tasks: list[Coroutine[Any, Any, Any]] = []

for entry in hass.config_entries.async_entries(DOMAIN):
if entry.source != SOURCE_IMPORT:
continue
if entry.unique_id not in unique_ids:
tasks.append(hass.config_entries.async_remove(entry.entry_id))

for conf in configs:
tasks.append( # noqa: PERF401
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(conf)
)
)
return loc_data

async def process_config(
config: ConfigType | None, run_immediately: bool = True
) -> None:
"""Process sun2 config."""
if not config or not (configs := config.get(DOMAIN)):
configs = []
unique_ids = [config[CONF_UNIQUE_ID] for config in configs]
tasks: list[Coroutine[Any, Any, Any]] = []
if not tasks:
return

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))
if run_immediately:
await asyncio.gather(*tasks)
else:
for task in tasks:
hass.async_create_task(task)

for conf in configs:
tasks.append(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf.copy()
)
)

if not tasks:
return
def _entry_by_title(
hass: HomeAssistant, title: str
) -> tuple[ConfigEntry, dict[str, Any]]:
"""Get config entry by title and a mutable copy of its options.
Raise ValueError if title does not exist.
"""
for entry in hass.config_entries.async_entries(
DOMAIN, include_ignore=False, include_disabled=False
):
if entry.title == title:
return entry, dict(entry.options)
raise ValueError(f"Integration entry does not exist or is not loaded: {title}")

if run_immediately:
await asyncio.gather(*tasks)
else:
for task in tasks:
hass.async_create_task(task)

async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up composite integration."""

async def reload_config(call: ServiceCall | None = None) -> None:
"""Reload configuration."""
await process_config(await async_integration_yaml_config(hass, DOMAIN))
await _process_config(hass, await async_integration_yaml_config(hass, DOMAIN))

@callback
def get_location(call: ServiceCall) -> ServiceResponse:
"""Get location parameters."""
_, options = _entry_by_title(hass, location := call.data[CONF_LOCATION])
latitude, longitude, time_zone = loc_from_options(hass, options)
return {
CONF_LOCATION: location,
CONF_LATITUDE: latitude,
CONF_LONGITUDE: longitude,
CONF_TIME_ZONE: time_zone,
CONF_OBS_ELV: obs_elv_from_options(hass, options),
}

@callback
def update_location(call: ServiceCall) -> None:
"""Update location parameters."""
loc_config = dict(call.data)
location = loc_config.pop(CONF_LOCATION)
entry, options = _entry_by_title(hass, location)
if entry.source != SOURCE_USER:
raise ValueError(f"Imported integration entries not supported: {location}")
if CONF_LATITUDE in loc_config and CONF_LATITUDE not in options:
raise ValueError("Changing location of Home configuration not supported")
if CONF_OBS_ELV in loc_config:
options_from_obs_elv(hass, loc_config)
options.update(loc_config)
hass.config_entries.async_update_entry(entry, options=options)

s2data = sun2_data(hass)

async def handle_core_config_update(event: Event) -> None:
"""Handle core config update."""
if not event.data:
return

loc_data = update_local_loc_data()
new_ha_loc_data = get_loc_data(hass.config)
if ha_loc_data_changed := new_ha_loc_data != s2data.ha_loc_data:
s2data.ha_loc_data = new_ha_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)
if ha_loc_data_changed:
async_dispatcher_send(hass, SIG_HA_LOC_UPDATED)
return

await reload_config()
Expand All @@ -106,9 +163,23 @@ async def handle_core_config_update(event: Event) -> None:
if reload and entry.state.recoverable:
await hass.config_entries.async_reload(entry.entry_id)

update_local_loc_data()
await process_config(config, run_immediately=False)
await _process_config(hass, config, run_immediately=False)

async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, reload_config)
hass.services.async_register(
DOMAIN,
"get_location",
get_location,
_GET_LOCATION_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"update_location",
update_location,
_UPDATE_LOCATION_SERVICE_SCHEMA,
supports_response=SupportsResponse.NONE,
)
hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, handle_core_config_update)

return True
Expand All @@ -128,17 +199,43 @@ async def entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
# 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)

config_data = sun2_data(hass).config_data[entry.entry_id]
if (
entry.title == config_data.title
and entry.options.get(CONF_BINARY_SENSORS, []) == config_data.binary_sensors
and entry.options.get(CONF_SENSORS, []) == config_data.sensors
):
loc_data = get_loc_data(entry.options)
obs_elvs = ObsElvs.from_entry_options(entry.options)
if loc_data != config_data.loc_data or obs_elvs != config_data.obs_elvs:
config_data.loc_data = loc_data
config_data.obs_elvs = obs_elvs
async_dispatcher_send(
hass, SIG_ASTRAL_DATA_UPDATED.format(entry.entry_id), loc_data, obs_elvs
)
return

if entry.state.recoverable:
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))
sun2_data(hass).config_data[entry.entry_id] = ConfigData(
entry.title,
entry.options.get(CONF_BINARY_SENSORS, [])[:],
entry.options.get(CONF_SENSORS, [])[:],
get_loc_data(entry.options),
ObsElvs.from_entry_options(entry.options),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(entry_updated))
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)
result = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
del sun2_data(hass).config_data[entry.entry_id]
return result
Loading

0 comments on commit d6a8de6

Please sign in to comment.