Add get_state_coordinates helper to location helpers (#176110)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
epenet
2026-08-24 13:57:55 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d0c93aa431
commit 5951fc4507
5 changed files with 85 additions and 59 deletions
+17 -20
View File
@@ -8,13 +8,7 @@ import logging
from pynws import NwsNoDataError, SimpleNWS, call_with_retry
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
EntityStateAttribute,
Platform,
)
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import debounce, entity_registry as er
@@ -24,7 +18,7 @@ from homeassistant.helpers.event import (
async_track_entity_registry_updated_event,
async_track_state_change_event,
)
from homeassistant.helpers.location import has_location
from homeassistant.helpers.location import Coordinates, get_state_coordinates
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
@@ -95,14 +89,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool:
)
location_entity_id = entity_entry.entity_id
state = hass.states.get(location_entity_id)
if state is None or not has_location(state):
if state is None or (location := get_state_coordinates(state)) is None:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="entity_unavailable",
translation_placeholders={"entity_id": location_entity_id},
)
latitude = state.attributes[EntityStateAttribute.LATITUDE]
longitude = state.attributes[EntityStateAttribute.LONGITUDE]
latitude = location.latitude
longitude = location.longitude
station = None
else:
latitude = entry.data[CONF_LATITUDE]
@@ -156,7 +150,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool:
entry,
nws_data,
location_entity_id=location_entity_id,
initial_position=(latitude, longitude) if location_entity_id else None,
initial_position=Coordinates(latitude, longitude)
if location_entity_id
else None,
)
# Don't use retries in setup
@@ -214,20 +210,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool:
) -> None:
"""Request coordinator refresh when the location entity moves."""
new_state = event.data["new_state"]
if new_state is None or not has_location(new_state):
return
new_lat = new_state.attributes[EntityStateAttribute.LATITUDE]
new_lon = new_state.attributes[EntityStateAttribute.LONGITUDE]
if (
new_lat == entry.runtime_data.latitude
and new_lon == entry.runtime_data.longitude
new_state is None
or (location := get_state_coordinates(new_state)) is None
):
return
if (
location.latitude == entry.runtime_data.latitude
and location.longitude == entry.runtime_data.longitude
):
return
dist = location_util.distance(
entry.runtime_data.latitude,
entry.runtime_data.longitude,
new_lat,
new_lon,
location.latitude,
location.longitude,
)
if dist is not None and dist <= LOCATION_CHANGE_THRESHOLD:
return
+5 -14
View File
@@ -8,17 +8,12 @@ from pynws import SimpleNWS
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
EntityStateAttribute,
)
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.location import has_location
from homeassistant.helpers.location import get_state_coordinates
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from . import base_unique_id
@@ -118,7 +113,7 @@ class NWSConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "entity_disabled"
else:
state = self.hass.states.get(location_entity)
if state is None or not has_location(state):
if state is None or (location := get_state_coordinates(state)) is None:
errors["base"] = "entity_no_coordinates"
else:
data = {
@@ -133,12 +128,8 @@ class NWSConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass,
{
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_LATITUDE: state.attributes[
EntityStateAttribute.LATITUDE
],
CONF_LONGITUDE: state.attributes[
EntityStateAttribute.LONGITUDE
],
CONF_LATITUDE: location.latitude,
CONF_LONGITUDE: location.longitude,
},
)
return self.async_create_entry(title=location_entity, data=data)
+20 -16
View File
@@ -8,11 +8,11 @@ import aiohttp
from aiohttp import ClientResponseError
from pynws import NwsError, NwsNoDataError, SimpleNWS, call_with_retry
from homeassistant.const import CONF_API_KEY, EntityStateAttribute
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers import debounce
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.location import has_location
from homeassistant.helpers.location import Coordinates, get_state_coordinates
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
@@ -49,7 +49,7 @@ class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]):
nws: SimpleNWS,
*,
location_entity_id: str | None = None,
initial_position: tuple[float, float] | None = None,
initial_position: Coordinates | None = None,
) -> None:
"""Initialize."""
self.nws = nws
@@ -84,26 +84,30 @@ class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]):
return
self._location_state_warned = False
if not has_location(state):
if (location := get_state_coordinates(state)) is None:
_LOGGER.debug(
"Location entity %s has no location attributes; skipping location update",
self._location_entity_id,
)
return
new_lat = state.attributes[EntityStateAttribute.LATITUDE]
new_lon = state.attributes[EntityStateAttribute.LONGITUDE]
if self._previous_position is not None:
prev_lat, prev_lon = self._previous_position
if new_lat == prev_lat and new_lon == prev_lon:
if (previous := self._previous_position) is not None:
if location == previous:
return
dist = location_util.distance(prev_lat, prev_lon, new_lat, new_lon)
dist = location_util.distance(
previous.latitude,
previous.longitude,
location.latitude,
location.longitude,
)
if dist is not None and dist <= LOCATION_CHANGE_THRESHOLD:
return
client_session = async_get_clientsession(self.hass)
api_key = self.config_entry.data[CONF_API_KEY]
station = self.config_entry.data.get(CONF_STATION)
try:
new_nws = SimpleNWS(new_lat, new_lon, api_key, client_session)
new_nws = SimpleNWS(
location.latitude, location.longitude, api_key, client_session
)
await new_nws.set_station(station)
except aiohttp.ClientError, NwsError:
_LOGGER.exception(
@@ -114,16 +118,16 @@ class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]):
_LOGGER.info(
"NWS API updated: station %s at (%.4f, %.4f)",
new_nws.station,
new_lat,
new_lon,
location.latitude,
location.longitude,
)
self.nws = new_nws
self.name = f"NWS observation station {new_nws.station}"
runtime_data = self.config_entry.runtime_data
runtime_data.api = new_nws
runtime_data.latitude = new_lat
runtime_data.longitude = new_lon
self._previous_position = (new_lat, new_lon)
runtime_data.latitude = location.latitude
runtime_data.longitude = location.longitude
self._previous_position = location
self.initialized = False
self.last_api_success_time = None
runtime_data.coordinator_forecast.name = (
+27 -9
View File
@@ -2,6 +2,7 @@
from collections.abc import Iterable
import logging
from typing import NamedTuple
from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant, State
@@ -10,20 +11,37 @@ from homeassistant.util import location as location_util
_LOGGER = logging.getLogger(__name__)
class Coordinates(NamedTuple):
"""A latitude/longitude coordinate pair."""
latitude: float
longitude: float
def get_state_coordinates(state: State) -> Coordinates | None:
"""Return the state's location, or None.
Returns None if the state does not contain a valid location.
Async friendly.
"""
if isinstance(
latitude := state.attributes.get(EntityStateAttribute.LATITUDE),
(float, int),
) and isinstance(
longitude := state.attributes.get(EntityStateAttribute.LONGITUDE),
(float, int),
):
return Coordinates(latitude, longitude)
return None
def has_location(state: State) -> bool:
"""Test if state contains a valid location.
Async friendly.
"""
return (
isinstance(state, State)
and isinstance(
state.attributes.get(EntityStateAttribute.LATITUDE), (float, int)
)
and isinstance(
state.attributes.get(EntityStateAttribute.LONGITUDE), (float, int)
)
)
return isinstance(state, State) and get_state_coordinates(state) is not None
def closest(latitude: float, longitude: float, states: Iterable[State]) -> State | None:
+16
View File
@@ -33,6 +33,22 @@ def test_has_location_with_states_with_int_location() -> None:
assert location.has_location(state)
def test_get_state_coordinates_with_invalid_state() -> None:
"""Test that an invalid location returns None."""
state = State(
"hello.world", "invalid", {ATTR_LATITUDE: "no number", ATTR_LONGITUDE: 123.12}
)
assert location.get_state_coordinates(state) is None
def test_get_state_coordinates_with_valid_location() -> None:
"""Test that a valid location returns the coordinates."""
state = State("hello.world", "valid", {ATTR_LATITUDE: 12.34, ATTR_LONGITUDE: 56.78})
assert location.get_state_coordinates(state) == location.Coordinates(
latitude=12.34, longitude=56.78
)
def test_closest_with_no_states_with_location() -> None:
"""Set up the tests."""
state = State("light.test", "on")