Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add optional location based region to dwd_weather_warnings #96027

Merged
merged 13 commits into from
Apr 22, 2024
11 changes: 2 additions & 9 deletions homeassistant/components/dwd_weather_warnings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,16 @@

from __future__ import annotations

from dwdwfsapi import DwdWeatherWarningsAPI

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

from .const import CONF_REGION_IDENTIFIER, DOMAIN, PLATFORMS
from .const import DOMAIN, PLATFORMS
from .coordinator import DwdWeatherWarningsCoordinator


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
region_identifier: str = entry.data[CONF_REGION_IDENTIFIER]

# Initialize the API and coordinator.
api = await hass.async_add_executor_job(DwdWeatherWarningsAPI, region_identifier)
coordinator = DwdWeatherWarningsCoordinator(hass, api)

coordinator = DwdWeatherWarningsCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
Expand Down
75 changes: 62 additions & 13 deletions homeassistant/components/dwd_weather_warnings/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig

from .const import CONF_REGION_IDENTIFIER, DOMAIN
from .const import CONF_REGION_DEVICE_TRACKER, CONF_REGION_IDENTIFIER, DOMAIN
from .exceptions import EntityNotFoundError
from .util import get_position_data

EXCLUSIVE_OPTIONS = (CONF_REGION_IDENTIFIER, CONF_REGION_DEVICE_TRACKER)


class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN):
Expand All @@ -25,27 +31,70 @@ async def async_step_user(
errors: dict = {}

if user_input is not None:
region_identifier = user_input[CONF_REGION_IDENTIFIER]
# Check, if either CONF_REGION_IDENTIFIER or CONF_GPS_TRACKER has been set.
if all(k not in user_input for k in EXCLUSIVE_OPTIONS):
errors["base"] = "no_identifier"
elif all(k in user_input for k in EXCLUSIVE_OPTIONS):
errors["base"] = "ambiguous_identifier"
elif CONF_REGION_IDENTIFIER in user_input:
# Validate region identifier using the API
identifier = user_input[CONF_REGION_IDENTIFIER]

if not await self.hass.async_add_executor_job(
DwdWeatherWarningsAPI, identifier
):
errors["base"] = "invalid_identifier"

if not errors:
# Set the unique ID for this config entry.
await self.async_set_unique_id(identifier)
self._abort_if_unique_id_configured()

return self.async_create_entry(title=identifier, data=user_input)
else: # CONF_REGION_DEVICE_TRACKER
device_tracker = user_input[CONF_REGION_DEVICE_TRACKER]
registry = er.async_get(self.hass)
entity_entry = registry.async_get(device_tracker)

if entity_entry is None:
errors["base"] = "entity_not_found"
else:
try:
position = get_position_data(self.hass, entity_entry.id)
except EntityNotFoundError:
errors["base"] = "entity_not_found"
except AttributeError:
errors["base"] = "attribute_not_found"
else:
# Validate position using the API
if not await self.hass.async_add_executor_job(
DwdWeatherWarningsAPI, position
):
errors["base"] = "invalid_identifier"

# Validate region identifier using the API
if not await self.hass.async_add_executor_job(
DwdWeatherWarningsAPI, region_identifier
):
errors["base"] = "invalid_identifier"
# Position is valid here, because the API call was successful.
if not errors and position is not None and entity_entry is not None:
# Set the unique ID for this config entry.
await self.async_set_unique_id(entity_entry.id)
self._abort_if_unique_id_configured()

if not errors:
# Set the unique ID for this config entry.
await self.async_set_unique_id(region_identifier)
self._abort_if_unique_id_configured()
# Replace entity ID with registry ID for more stability.
user_input[CONF_REGION_DEVICE_TRACKER] = entity_entry.id

return self.async_create_entry(title=region_identifier, data=user_input)
return self.async_create_entry(
title=device_tracker.removeprefix("device_tracker."),
data=user_input,
)

return self.async_show_form(
step_id="user",
errors=errors,
data_schema=vol.Schema(
{
vol.Required(CONF_REGION_IDENTIFIER): cv.string,
vol.Optional(CONF_REGION_IDENTIFIER): cv.string,
vol.Optional(CONF_REGION_DEVICE_TRACKER): EntitySelector(
EntitySelectorConfig(domain="device_tracker")
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
),
}
),
)
1 change: 1 addition & 0 deletions homeassistant/components/dwd_weather_warnings/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

CONF_REGION_NAME: Final = "region_name"
CONF_REGION_IDENTIFIER: Final = "region_identifier"
CONF_REGION_DEVICE_TRACKER: Final = "region_device_tracker"

ATTR_REGION_NAME: Final = "region_name"
ATTR_REGION_ID: Final = "region_id"
Expand Down
48 changes: 43 additions & 5 deletions homeassistant/components/dwd_weather_warnings/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,61 @@

from dwdwfsapi import DwdWeatherWarningsAPI

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
from .const import (
CONF_REGION_DEVICE_TRACKER,
CONF_REGION_IDENTIFIER,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
LOGGER,
)
from .exceptions import EntityNotFoundError
from .util import get_position_data


class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]):
"""Custom coordinator for the dwd_weather_warnings integration."""

def __init__(self, hass: HomeAssistant, api: DwdWeatherWarningsAPI) -> None:
config_entry: ConfigEntry

def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize the dwd_weather_warnings coordinator."""
super().__init__(
hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL
)

self.api = api
self._api = DwdWeatherWarningsAPI(None)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the meaning passing None here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This initializes the API with an invalid state. I chose this instead of None to avoid having to deal with a potential None object in the sensor.py where the data is primarily used

Copy link
Contributor

@emontnemery emontnemery Mar 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully follow. The normal pattern for coordinator based integrations is that entities only access the coordinator after a successful refresh.

An option could be to not initialize self._api in __init__, instead just add a type annotation for it on class level:

    _api: DwdWeatherWarningsAPI

If there's some bug causing sensors to access the coordinator before the first successful data update, an attribute error will be raised.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the initialization of self._api in df914c2 as suggested

self._device_tracker = None

async def async_config_entry_first_refresh(self) -> None:
"""Perform first refresh."""
if region_identifier := self.config_entry.data.get(CONF_REGION_IDENTIFIER):
self._api = await self.hass.async_add_executor_job(
DwdWeatherWarningsAPI, region_identifier
)
else:
self._device_tracker = self.config_entry.data.get(
CONF_REGION_DEVICE_TRACKER
)

await super().async_config_entry_first_refresh()

async def _async_update_data(self) -> None:
"""Get the latest data from the DWD Weather Warnings API."""
await self.hass.async_add_executor_job(self.api.update)
if self._device_tracker:
try:
position = get_position_data(self.hass, self._device_tracker)
except (EntityNotFoundError, AttributeError) as err:
raise UpdateFailed(f"Error fetching position: {repr(err)}") from err

self._api = await self.hass.async_add_executor_job(
DwdWeatherWarningsAPI, position
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should avoid creating a new object if the location has not changed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean the region that the API would determine using the coordinates or the coordinates directly? I'd have to create the API object to test the former.

Copy link
Contributor

@emontnemery emontnemery Apr 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant that if the location of the device tracker has not changed, we should not create a new API object since it seems to be somewhat expensive? Or is the cost of just updating an existing API object similar to creating an API object, including the initial update?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initialization includes 5 API calls to get the required information based on what the object was initialized with, i.e. region ID, region name or GPS coordinates. So it's definitely more costly as the updates only do a singular API call because the required information is already there.

Is there some convenient method to verify if the location has changed instead of having to store the previous position?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just store the previous position. Maybe check if the position has changed more than 100m (or whatever is reasonable) to avoid recreating the API because of GPS inaccuracy. We have some helpers to help calculate the absolute distance between two points.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 848cfc5

After a bit of googling I chose 50m as the threshold. I've also added a normal API update in case the object doesn't get recreated as the data wouldn't update otherwise

else:
if self._api is None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this happen? self._api is unconditionally assigned in __init__

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might not be possible anymore. I think ruff marked this line at one point but I can't check for around 3 weeks as I'm on vacation as of today.

Will check after that of course

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. I've marked the PR as draft, please mark it ready for review when you've had time to check 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed it in df914c2 :)

raise UpdateFailed("API is not initialized")

await self.hass.async_add_executor_job(self._api.update)
6 changes: 6 additions & 0 deletions homeassistant/components/dwd_weather_warnings/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Exceptions for the dwd_weather_warnings integration."""
from homeassistant.exceptions import HomeAssistantError
emontnemery marked this conversation as resolved.
Show resolved Hide resolved


class EntityNotFoundError(HomeAssistantError):
"""When a referenced entity was not found."""
8 changes: 5 additions & 3 deletions homeassistant/components/dwd_weather_warnings/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from __future__ import annotations

from typing import Any

from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
Expand Down Expand Up @@ -93,18 +95,18 @@ def __init__(
entry_type=DeviceEntryType.SERVICE,
)

self.api = coordinator.api
self.api = coordinator._api
Copy link
Contributor

@emontnemery emontnemery Apr 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the location is changed, the coordinator creates a new api object, I don't think it's valid to store a reference here, don't we need to access the api object via the coordinator instead?

Copy link
Contributor Author

@andarotajo andarotajo Apr 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A reference of a complex object shares the memory with the original as far as I know so this should be fine. But using self.coordinator._api directly instead of a reference is probably cleaner..

Copy link
Contributor

@emontnemery emontnemery Apr 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A reference of a complex object shares the memory with the original as far as I know so this should be fine

That's absolutely not the case, as evident from this snippet:

$ python3
Python 3.11.5 (main, Oct 19 2023, 09:15:06) [GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class Coordinator:
...     api = object()
...
>>> coordinator = Coordinator()
>>> original_api = coordinator.api
>>> coordinator.api = object()
>>> original_api is coordinator.api
False

Don't the added tests cover creating new API object when location changes?

Copy link
Contributor Author

@andarotajo andarotajo Apr 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests cover creating the API object initially but not a location change that would result in the API having a different city set.

Also noticed that using self.coordinator._api doesn't sit well with Pylint. Is renaming the _api variable to api the way to go here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, name it api if it's to be used from other classes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 848cfc5


@property
def native_value(self):
def native_value(self) -> int | None:
"""Return the state of the sensor."""
if self.entity_description.key == CURRENT_WARNING_SENSOR:
return self.api.current_warning_level

return self.api.expected_warning_level

@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the sensor."""
data = {
ATTR_REGION_NAME: self.api.warncell_name,
Expand Down
13 changes: 9 additions & 4 deletions homeassistant/components/dwd_weather_warnings/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@
"config": {
"step": {
"user": {
"description": "To identify the desired region, the warncell ID / name is required.",
"description": "To identify the desired region, either the warncell ID / name or device tracker is required. The provided device tracker has to contain the attributes 'latitude' and 'longitude'.",
"data": {
"region_identifier": "Warncell ID or name"
"region_identifier": "Warncell ID or name",
"region_device_tracker": "Device tracker entity"
}
}
},
"error": {
"invalid_identifier": "The specified region identifier is invalid."
"no_identifier": "Either the region identifier or device tracker is required.",
"ambiguous_identifier": "The region identifier and device tracker can not be specified together.",
"invalid_identifier": "The specified region identifier / device tracker is invalid.",
"entity_not_found": "The specified device tracker entity was not found.",
"attribute_not_found": "The required `latitude` or `longitude` attribute was not found in the specified device tracker."
},
"abort": {
"already_configured": "Warncell ID / name is already configured.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]"
}
},
Expand Down
39 changes: 39 additions & 0 deletions homeassistant/components/dwd_weather_warnings/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Util functions for the dwd_weather_warnings integration."""

from __future__ import annotations

from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er

from .exceptions import EntityNotFoundError


def get_position_data(
hass: HomeAssistant, registry_id: str
) -> tuple[float, float] | None:
"""Extract longitude and latitude from a device tracker."""
registry = er.async_get(hass)
registry_entry = registry.async_get(registry_id)
if registry_entry is None:
raise EntityNotFoundError(f"Failed to find registry entry {registry_id}")

entity = hass.states.get(registry_entry.entity_id)
if entity is None:
raise EntityNotFoundError(f"Failed to find entity {registry_entry.entity_id}")

latitude = entity.attributes.get(ATTR_LATITUDE)
if not latitude:
raise AttributeError(
f"Failed to find attribute '{ATTR_LATITUDE}' in {registry_entry.entity_id}",
ATTR_LATITUDE,
)

longitude = entity.attributes.get(ATTR_LONGITUDE)
if not longitude:
raise AttributeError(

Check warning on line 34 in homeassistant/components/dwd_weather_warnings/util.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/dwd_weather_warnings/util.py#L34

Added line #L34 was not covered by tests
f"Failed to find attribute '{ATTR_LONGITUDE}' in {registry_entry.entity_id}",
ATTR_LONGITUDE,
)

return (latitude, longitude)
Loading
Loading