Home Assistant Unofficial Reference 2024.12.1
location.py
Go to the documentation of this file.
1 """Module with location helpers.
2 
3 detect_location_info and elevation are mocked by default during tests.
4 """
5 
6 from __future__ import annotations
7 
8 from functools import lru_cache
9 import math
10 from typing import Any, NamedTuple
11 
12 import aiohttp
13 
14 from homeassistant.const import __version__ as HA_VERSION
15 
16 WHOAMI_URL = "https://services.home-assistant.io/whoami/v1"
17 WHOAMI_URL_DEV = "https://services-dev.home-assistant.workers.dev/whoami/v1"
18 
19 # Constants from https://github.com/maurycyp/vincenty
20 # Earth ellipsoid according to WGS 84
21 # Axis a of the ellipsoid (Radius of the earth in meters)
22 AXIS_A = 6378137
23 # Flattening f = (a-b) / a
24 FLATTENING = 1 / 298.257223563
25 # Axis b of the ellipsoid in meters.
26 AXIS_B = 6356752.314245
27 
28 MILES_PER_KILOMETER = 0.621371
29 MAX_ITERATIONS = 200
30 CONVERGENCE_THRESHOLD = 1e-12
31 
32 
33 class LocationInfo(NamedTuple):
34  """Tuple with location information."""
35 
36  ip: str
37  country_code: str
38  currency: str
39  region_code: str
40  region_name: str
41  city: str
42  zip_code: str
43  time_zone: str
44  latitude: float
45  longitude: float
46  use_metric: bool
47 
48 
50  session: aiohttp.ClientSession,
51 ) -> LocationInfo | None:
52  """Detect location information."""
53  if (data := await _get_whoami(session)) is None:
54  return None
55 
56  data["use_metric"] = data["country_code"] not in ("US", "MM", "LR")
57 
58  return LocationInfo(**data)
59 
60 
61 @lru_cache
63  lat1: float | None, lon1: float | None, lat2: float, lon2: float
64 ) -> float | None:
65  """Calculate the distance in meters between two points.
66 
67  Async friendly.
68  """
69  if lat1 is None or lon1 is None:
70  return None
71  result = vincenty((lat1, lon1), (lat2, lon2))
72  if result is None:
73  return None
74  return result * 1000
75 
76 
77 # Author: https://github.com/maurycyp
78 # Source: https://github.com/maurycyp/vincenty
79 # License: https://github.com/maurycyp/vincenty/blob/master/LICENSE
81  point1: tuple[float, float], point2: tuple[float, float], miles: bool = False
82 ) -> float | None:
83  """Vincenty formula (inverse method) to calculate the distance.
84 
85  Result in kilometers or miles between two points on the surface of a
86  spheroid.
87 
88  Async friendly.
89  """
90  # short-circuit coincident points
91  if point1[0] == point2[0] and point1[1] == point2[1]:
92  return 0.0
93 
94  U1 = math.atan((1 - FLATTENING) * math.tan(math.radians(point1[0])))
95  U2 = math.atan((1 - FLATTENING) * math.tan(math.radians(point2[0])))
96  L = math.radians(point2[1] - point1[1])
97  Lambda = L
98 
99  sinU1 = math.sin(U1)
100  cosU1 = math.cos(U1)
101  sinU2 = math.sin(U2)
102  cosU2 = math.cos(U2)
103 
104  for _ in range(MAX_ITERATIONS):
105  sinLambda = math.sin(Lambda)
106  cosLambda = math.cos(Lambda)
107  sinSigma = math.sqrt(
108  (cosU2 * sinLambda) ** 2 + (cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) ** 2
109  )
110  if sinSigma == 0.0:
111  return 0.0 # coincident points
112  cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda
113  sigma = math.atan2(sinSigma, cosSigma)
114  sinAlpha = cosU1 * cosU2 * sinLambda / sinSigma
115  cosSqAlpha = 1 - sinAlpha**2
116  try:
117  cos2SigmaM = cosSigma - 2 * sinU1 * sinU2 / cosSqAlpha
118  except ZeroDivisionError:
119  cos2SigmaM = 0
120  C = FLATTENING / 16 * cosSqAlpha * (4 + FLATTENING * (4 - 3 * cosSqAlpha))
121  LambdaPrev = Lambda
122  Lambda = L + (1 - C) * FLATTENING * sinAlpha * (
123  sigma
124  + C * sinSigma * (cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM**2))
125  )
126  if abs(Lambda - LambdaPrev) < CONVERGENCE_THRESHOLD:
127  break # successful convergence
128  else:
129  return None # failure to converge
130 
131  uSq = cosSqAlpha * (AXIS_A**2 - AXIS_B**2) / (AXIS_B**2)
132  A = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)))
133  B = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)))
134  # fmt: off
135  deltaSigma = (
136  B
137  * sinSigma
138  * (
139  cos2SigmaM
140  + B
141  / 4
142  * (
143  cosSigma * (-1 + 2 * cos2SigmaM**2)
144  - B
145  / 6
146  * cos2SigmaM
147  * (-3 + 4 * sinSigma ** 2)
148  * (-3 + 4 * cos2SigmaM ** 2)
149  )
150  )
151  )
152  # fmt: on
153  s = AXIS_B * A * (sigma - deltaSigma)
154 
155  s /= 1000 # Conversion of meters to kilometers
156  if miles:
157  s *= MILES_PER_KILOMETER # kilometers to miles
158 
159  return round(s, 6)
160 
161 
162 async def _get_whoami(session: aiohttp.ClientSession) -> dict[str, Any] | None:
163  """Query whoami.home-assistant.io for location data."""
164  try:
165  resp = await session.get(
166  WHOAMI_URL_DEV if HA_VERSION.endswith("0.dev0") else WHOAMI_URL,
167  timeout=aiohttp.ClientTimeout(total=30),
168  )
169  except (aiohttp.ClientError, TimeoutError):
170  return None
171 
172  try:
173  raw_info = await resp.json()
174  except (aiohttp.ClientError, ValueError):
175  return None
176 
177  return {
178  "ip": raw_info.get("ip"),
179  "country_code": raw_info.get("country"),
180  "currency": raw_info.get("currency"),
181  "region_code": raw_info.get("region_code"),
182  "region_name": raw_info.get("region"),
183  "city": raw_info.get("city"),
184  "zip_code": raw_info.get("postal_code"),
185  "time_zone": raw_info.get("timezone"),
186  "latitude": float(raw_info.get("latitude")),
187  "longitude": float(raw_info.get("longitude")),
188  }
float|None vincenty(tuple[float, float] point1, tuple[float, float] point2, bool miles=False)
Definition: location.py:82
float|None distance(float|None lat1, float|None lon1, float lat2, float lon2)
Definition: location.py:64
dict[str, Any]|None _get_whoami(aiohttp.ClientSession session)
Definition: location.py:162
LocationInfo|None async_detect_location_info(aiohttp.ClientSession session)
Definition: location.py:51