├── .github
├── FUNDING.yml
└── workflows
│ ├── hassfest-validation.yml
│ └── hacs-validation.yml
├── logo
├── icon.png
└── icon@2x.png
├── hacs.json
├── .gitignore
├── custom_components
└── geolocator
│ ├── api
│ ├── base.py
│ ├── osm.py
│ ├── bigdatacloud.py
│ ├── opencage.py
│ ├── google.py
│ └── geonames.py
│ ├── manifest.json
│ ├── const.py
│ ├── services.yaml
│ ├── translations
│ └── en.json
│ ├── sensor.py
│ ├── config_flow.py
│ └── __init__.py
├── LICENSE
└── README.md
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | buy_me_a_coffee: smartyvan
--------------------------------------------------------------------------------
/logo/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SmartyVan/hass-geolocator/HEAD/logo/icon.png
--------------------------------------------------------------------------------
/logo/icon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SmartyVan/hass-geolocator/HEAD/logo/icon@2x.png
--------------------------------------------------------------------------------
/hacs.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "GeoLocator",
3 | "content_in_root": false,
4 | "homeassistant": "2024.4.0",
5 | "render_readme": true
6 | }
7 |
--------------------------------------------------------------------------------
/.github/workflows/hassfest-validation.yml:
--------------------------------------------------------------------------------
1 | name: Validate with hassfest
2 |
3 | on:
4 | push:
5 | pull_request:
6 | schedule:
7 | - cron: '0 0 * * *'
8 |
9 | jobs:
10 | validate:
11 | runs-on: "ubuntu-latest"
12 | steps:
13 | - uses: "actions/checkout@v4"
14 | - uses: "home-assistant/actions/hassfest@master"
--------------------------------------------------------------------------------
/.github/workflows/hacs-validation.yml:
--------------------------------------------------------------------------------
1 | name: HACS Validation
2 |
3 | on:
4 | push:
5 | pull_request:
6 | workflow_dispatch:
7 |
8 | jobs:
9 | validate-hacs:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v4
14 |
15 | - name: Run HACS validation
16 | uses: hacs/action@22.5.0
17 | with:
18 | category: "integration"
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Byte-compiled / optimized / DLL files
3 | __pycache__/
4 | *.py[cod]
5 | *$py.class
6 |
7 | # Home Assistant files
8 | home-assistant_v2.db
9 | *.db
10 | *.log
11 | *.yaml~
12 | .storage/
13 | deps/
14 |
15 | # OS-generated files
16 | .DS_Store
17 | Thumbs.db
18 |
19 | # VSCode settings
20 | .vscode/
21 |
22 | # Build artifacts
23 | *.tar.gz
24 | *.zip
25 | *.egg-info/
26 |
27 | # Python virtual environment
28 | .venv/
29 | env/
30 |
31 | # API dev
32 | /API Dev/
--------------------------------------------------------------------------------
/custom_components/geolocator/api/base.py:
--------------------------------------------------------------------------------
1 | class GeoLocatorAPI:
2 | """Abstract base class for geolocation APIs."""
3 |
4 | async def reverse_geocode(self, latitude: float, longitude: float, language: str = "en") -> dict:
5 | """Return a dict of address components."""
6 | raise NotImplementedError
7 |
8 | async def get_timezone(self, latitude: float, longitude: float, language: str = "en") -> str:
9 | """Return an IANA time zone string."""
10 | raise NotImplementedError
11 |
--------------------------------------------------------------------------------
/custom_components/geolocator/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "domain": "geolocator",
3 | "name": "GeoLocator",
4 | "codeowners": ["@SmartyVan"],
5 | "config_flow": true,
6 | "dependencies": [],
7 | "documentation": "https://github.com/SmartyVan/hass-geolocator",
8 | "iot_class": "cloud_polling",
9 | "issue_tracker": "https://github.com/SmartyVan/hass-geolocator/issues",
10 | "requirements": ["timezonefinder==4.5.0", "openlocationcode", "Babel>=2.9.0"],
11 | "version": "0.3.1-beta"
12 | }
13 |
--------------------------------------------------------------------------------
/custom_components/geolocator/const.py:
--------------------------------------------------------------------------------
1 | # custom_components/geolocator/const.py
2 |
3 | DOMAIN = "geolocator"
4 |
5 | CONF_API_PROVIDER = "api_provider"
6 | CONF_API_KEY = "api_key"
7 |
8 | SERVICE_UPDATE_LOCATION = "update_location"
9 | SERVICE_SET_TIMEZONE = "set_home_timezone"
10 |
11 |
12 | ATTR_LATITUDE = "latitude"
13 | ATTR_LONGITUDE = "longitude"
14 | ATTR_TIMESTAMP = "timestamp"
15 |
16 | TIMEZONE_SENSOR = "current_timezone"
17 | LOCATION_SENSOR = "current_location"
18 |
19 | API_PROVIDER_META = {
20 | "google": {"name": "Google Maps", "needs_key": True},
21 | "opencage": {"name": "OpenCage", "needs_key": True},
22 | "geonames": {"name": "GeoNames", "needs_key": True},
23 | "bigdatacloud": {"name": "BigDataCloud", "needs_key": False},
24 | "offline": {"name": "Offline", "needs_key": False},
25 | }
--------------------------------------------------------------------------------
/custom_components/geolocator/services.yaml:
--------------------------------------------------------------------------------
1 | update_location:
2 | name: Update Location
3 | description: >
4 | Use the current GPS coordinates of zone.home to fetch reverse geocode location information
5 | and timezone using the selected API provider.
6 | Automatically updates all GeoLocator sensors and the Home Assistant system timezone if it has changed.
7 | fields: {}
8 |
9 | set_home_timezone:
10 | name: Set Home Timezone
11 | description: >
12 | Set the timezone of the Home Assistant system directly using an IANA time zone identifier
13 | (e.g., America/Los_Angeles, Europe/Paris).
14 | fields:
15 | timezone:
16 | name: Timezone
17 | description: IANA time zone identifier to set (e.g., America/New_York)
18 | example: "America/New_York"
19 | required: true
20 | selector:
21 | text:
22 | type: text
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | MIT License
3 |
4 | Copyright (c) 2025 SmartyVan
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/custom_components/geolocator/api/osm.py:
--------------------------------------------------------------------------------
1 | import aiohttp
2 | from .base import GeoLocatorAPI
3 |
4 | NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse"
5 |
6 | class OSMAPI(GeoLocatorAPI):
7 | """GeoLocator API using OpenStreetMap's Nominatim service."""
8 |
9 | def __init__(self, user_agent: str = "geo_locator_home_assistant"):
10 | self.user_agent = user_agent
11 |
12 | async def reverse_geocode(self, latitude, longitude):
13 | headers = {
14 | "User-Agent": self.user_agent
15 | }
16 | params = {
17 | "lat": latitude,
18 | "lon": longitude,
19 | "format": "jsonv2",
20 | "addressdetails": 1,
21 | }
22 |
23 | async with aiohttp.ClientSession(headers=headers) as session:
24 | async with session.get(NOMINATIM_URL, params=params) as resp:
25 | return await resp.json()
26 |
27 | async def get_timezone(self, latitude, longitude):
28 | # OSM does not provide timezone info
29 | return None
30 |
31 | def format_full_address(self, data):
32 | return data.get("display_name", "")
33 |
34 | def extract_neighborhood(self, data):
35 | return data.get("address", {}).get("neighbourhood")
36 |
37 | def extract_city(self, data):
38 | return data.get("address", {}).get("city") or \
39 | data.get("address", {}).get("town") or \
40 | data.get("address", {}).get("village")
41 |
42 | def extract_state_long(self, data):
43 | return data.get("address", {}).get("state")
44 |
45 | def extract_country(self, data):
46 | return data.get("address", {}).get("country")
47 |
--------------------------------------------------------------------------------
/custom_components/geolocator/api/bigdatacloud.py:
--------------------------------------------------------------------------------
1 | import aiohttp
2 | import logging
3 | from .base import GeoLocatorAPI
4 |
5 | _LOGGER = logging.getLogger(__name__)
6 |
7 | BIGDATACLOUD_URL = "https://api.bigdatacloud.net/data/reverse-geocode-client"
8 |
9 |
10 | class BigDataCloudAPI(GeoLocatorAPI):
11 | """GeoLocator API using BigDataCloud (no key required)."""
12 |
13 | def __init__(self):
14 | pass # Removed _last_timezone_info; no longer needed
15 |
16 | async def reverse_geocode(self, latitude, longitude, language="en"):
17 | params = {
18 | "latitude": latitude,
19 | "longitude": longitude,
20 | "localityLanguage": "en"
21 | }
22 | async with aiohttp.ClientSession() as session:
23 | async with session.get(BIGDATACLOUD_URL, params=params) as resp:
24 | data = await resp.json()
25 | _LOGGER.debug("BigDataCloud response: %s", data)
26 | return data
27 |
28 | async def get_timezone(self, latitude, longitude, language="en"):
29 | data = await self.reverse_geocode(latitude, longitude)
30 | informative = data.get("localityInfo", {}).get("informative", [])
31 | for item in informative:
32 | if item.get("description", "").lower() == "time zone":
33 | return item.get("name")
34 | return None
35 |
36 | def format_full_address(self, data):
37 | # Handle missing parts safely
38 | locality = data.get("locality", "")
39 | state = data.get("principalSubdivision", "")
40 | country = data.get("countryName", "")
41 | parts = [p for p in [locality, state, country] if p]
42 | return ", ".join(parts)
43 |
44 | def extract_neighborhood(self, data):
45 | return None # Not available from BigDataCloud
46 |
47 | def extract_city(self, data):
48 | return data.get("locality")
49 |
50 | def extract_state_long(self, data):
51 | return data.get("principalSubdivision")
52 |
53 | def extract_country(self, data):
54 | return data.get("countryName")
55 |
--------------------------------------------------------------------------------
/custom_components/geolocator/api/opencage.py:
--------------------------------------------------------------------------------
1 | import aiohttp
2 | import logging
3 | from .base import GeoLocatorAPI
4 |
5 | _LOGGER = logging.getLogger(__name__)
6 | GEOCODE_URL = "https://api.opencagedata.com/geocode/v1/json"
7 |
8 | class OpenCageAPI(GeoLocatorAPI):
9 | def __init__(self, api_key, language="en"):
10 | self.api_key = api_key
11 | self.language = language
12 |
13 | async def reverse_geocode(self, lat, lon, language="en"):
14 | async with aiohttp.ClientSession() as session:
15 | params = {
16 | "q": f"{lat},{lon}",
17 | "key": self.api_key,
18 | "language": language,
19 | }
20 | async with session.get(GEOCODE_URL, params=params) as resp:
21 | data = await resp.json()
22 | _LOGGER.debug("OpenCage reverse geocode response: %s", data)
23 | return data
24 |
25 | async def get_timezone(self, lat, lon, language="en"):
26 | data = await self.reverse_geocode(lat, lon, language)
27 | try:
28 | return data["results"][0]["annotations"]["timezone"]["name"]
29 | except (IndexError, KeyError):
30 | _LOGGER.warning("OpenCage: Failed to extract timezone from response")
31 | return None
32 |
33 | def format_full_address(self, data):
34 | try:
35 | return data["results"][0]["formatted"]
36 | except (IndexError, KeyError):
37 | return ""
38 |
39 | def extract_city(self, data):
40 | try:
41 | return data["results"][0]["components"].get("city") or \
42 | data["results"][0]["components"].get("town") or \
43 | data["results"][0]["components"].get("village") or \
44 | data["results"][0]["components"].get("county")
45 | except (IndexError, KeyError):
46 | return None
47 |
48 | def extract_state_long(self, data):
49 | try:
50 | return data["results"][0]["components"].get("state")
51 | except (IndexError, KeyError):
52 | return None
53 |
54 | def extract_country(self, data):
55 | try:
56 | return data["results"][0]["components"].get("country")
57 | except (IndexError, KeyError):
58 | return None
59 |
--------------------------------------------------------------------------------
/custom_components/geolocator/api/google.py:
--------------------------------------------------------------------------------
1 | import aiohttp
2 | from .base import GeoLocatorAPI
3 |
4 | GEOCODE_URL = "https://maps.googleapis.com/maps/api/geocode/json"
5 | TIMEZONE_URL = "https://maps.googleapis.com/maps/api/timezone/json"
6 |
7 | class GoogleMapsAPI(GeoLocatorAPI):
8 | def __init__(self, api_key, language="en"):
9 | self.api_key = api_key
10 |
11 | async def reverse_geocode(self, lat, lon, language="en"):
12 | async with aiohttp.ClientSession() as session:
13 | params = {
14 | "latlng": f"{lat},{lon}",
15 | "key": self.api_key,
16 | "language": language,
17 | }
18 | async with session.get(GEOCODE_URL, params=params) as resp:
19 | return await resp.json()
20 |
21 | async def get_timezone(self, lat, lon, language="en"):
22 | import time
23 | import logging
24 | _LOGGER = logging.getLogger(__name__)
25 |
26 | timestamp = int(time.time())
27 | async with aiohttp.ClientSession() as session:
28 | params = {
29 | "location": f"{lat},{lon}",
30 | "timestamp": timestamp,
31 | "key": self.api_key,
32 | "language": language,
33 | }
34 | async with session.get(TIMEZONE_URL, params=params) as resp:
35 | data = await resp.json()
36 | _LOGGER.debug("Google Timezone API response: %s", data)
37 | return data.get("timeZoneId")
38 |
39 | def _get_component(self, data, type_name):
40 | for result in data.get("results", []):
41 | for comp in result.get("address_components", []):
42 | if type_name in comp.get("types", []):
43 | return comp.get("long_name")
44 | return None
45 |
46 | def format_full_address(self, data):
47 | if not data.get("results"):
48 | return ""
49 | return data["results"][0].get("formatted_address", "")
50 |
51 | def extract_neighborhood(self, data):
52 | return self._get_component(data, "neighborhood")
53 |
54 | def extract_city(self, data):
55 | return self._get_component(data, "locality")
56 |
57 | def extract_state_long(self, data):
58 | return self._get_component(data, "administrative_area_level_1")
59 |
60 | def extract_country(self, data):
61 | return self._get_component(data, "country")
62 |
--------------------------------------------------------------------------------
/custom_components/geolocator/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "GeoLocator",
3 |
4 | "config": {
5 | "step": {
6 | "user": {
7 | "title": "Setup GeoLocator",
8 | "description": "Choose a geolocation API and provide credentials if required.",
9 | "data": {
10 | "api_provider": "API Provider",
11 | "api_key": "API Key or Username"
12 | }
13 | },
14 | "credentials": {
15 | "title": "API Credentials",
16 | "description": "Enter the API key or username required for the selected provider.",
17 | "data": {
18 | "api_key": "API Key or Username"
19 | }
20 | },
21 | "api_key": {
22 | "title": "API Credentials",
23 | "description": "Enter the API key or username required for the selected provider.",
24 | "data": {
25 | "api_key": "API Key or Username"
26 | }
27 | }
28 | },
29 | "error": {
30 | "cannot_connect": "Failed to connect to the selected API.",
31 | "invalid_auth": "Invalid API key or username.",
32 | "unknown": "An unknown error occurred."
33 | },
34 | "abort": {
35 | "already_configured": "GeoLocator is already configured."
36 | }
37 | },
38 |
39 | "options": {
40 | "step": {
41 | "init": {
42 | "title": "GeoLocator Options",
43 | "description": "Update the geolocation provider and API credentials.",
44 | "data": {
45 | "api_provider": "API Provider",
46 | "api_key": "API Key or Username"
47 | }
48 | },
49 | "options_credentials": {
50 | "title": "API Credentials",
51 | "description": "Enter the API key or username required for the selected provider.",
52 | "data": {
53 | "api_key": "API Key or Username"
54 | }
55 | }
56 | }
57 | },
58 |
59 | "services": {
60 | "update_location": {
61 | "name": "Update Location",
62 | "description": "Fetch reverse geocode data and update location/timezone sensors."
63 | },
64 | "set_home_timezone": {
65 | "name": "Set Home Timezone",
66 | "description": "Update the system time zone using an IANA identifier (e.g., America/New_York)."
67 | }
68 | },
69 |
70 | "entity": {
71 | "sensor": {
72 | "geolocator_current_address": {
73 | "name": "Current Address"
74 | },
75 | "geolocator_neighborhood": {
76 | "name": "Neighborhood"
77 | },
78 | "geolocator_city": {
79 | "name": "City"
80 | },
81 | "geolocator_state": {
82 | "name": "State"
83 | },
84 | "geolocator_country": {
85 | "name": "Country"
86 | },
87 | "geolocator_timezone_id": {
88 | "name": "Timezone ID"
89 | },
90 | "geolocator_timezone_name": {
91 | "name": "Timezone Name"
92 | },
93 | "geolocator_timezone_abbreviation": {
94 | "name": "Timezone Abbreviation"
95 | }
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/custom_components/geolocator/api/geonames.py:
--------------------------------------------------------------------------------
1 | import aiohttp
2 | from .base import GeoLocatorAPI
3 |
4 | GEONAMES_REVERSE_URL = "http://api.geonames.org/findNearestAddressJSON"
5 | GEONAMES_PLACE_URL = "http://api.geonames.org/findNearbyPlaceNameJSON"
6 | GEONAMES_TIMEZONE_URL = "http://api.geonames.org/timezoneJSON"
7 |
8 | class GeoNamesAPI(GeoLocatorAPI):
9 | def __init__(self, username: str):
10 | self.username = username
11 |
12 | async def reverse_geocode(self, lat, lon, language="en"):
13 | async with aiohttp.ClientSession() as session:
14 | reverse_resp = await session.get(GEONAMES_REVERSE_URL, params={"lat": lat, "lng": lon, "username": self.username})
15 | place_resp = await session.get(GEONAMES_PLACE_URL, params={"lat": lat, "lng": lon, "username": self.username, "cities": "cities500"})
16 | reverse_data = await reverse_resp.json()
17 | place_data = await place_resp.json()
18 | return {"reverse": reverse_data, "place": place_data}
19 |
20 | async def get_timezone(self, lat, lon, language="en"):
21 | params = {
22 | "lat": lat,
23 | "lng": lon,
24 | "username": self.username,
25 | }
26 | async with aiohttp.ClientSession() as session:
27 | async with session.get(GEONAMES_TIMEZONE_URL, params=params) as resp:
28 | data = await resp.json()
29 | return data.get("timezoneId")
30 |
31 | def _get_top_result(self, data):
32 | if "geonames" in data:
33 | return data.get("geonames", [{}])[0]
34 | elif "address" in data:
35 | return data["address"]
36 | return {}
37 |
38 | def format_full_address(self, data):
39 | reverse_top = self._get_top_result(data.get("reverse", {}))
40 | place_top = self._get_top_result(data.get("place", {}))
41 |
42 | # Combine street number + street (no comma)
43 | street_number = reverse_top.get("streetNumber")
44 | street = reverse_top.get("street")
45 | street_line = f"{street_number} {street}".strip() if street or street_number else None
46 |
47 | # City / locality with fallback
48 | placename = reverse_top.get("placename")
49 | if not placename:
50 | placename = place_top.get("name") # Fallback to populated place name
51 |
52 | # Combine state + postal code (no comma)
53 | admin = reverse_top.get("adminCode1")
54 | postal = reverse_top.get("postalcode")
55 | region_line = f"{admin} {postal}".strip() if admin or postal else None
56 |
57 | country = place_top.get("countryName")
58 |
59 | # Final join with correct commas
60 | return ", ".join(filter(None, [street_line, placename, region_line, country]))
61 |
62 |
63 | def extract_city(self, data):
64 | reverse_top = self._get_top_result(data.get("reverse", {}))
65 | city = reverse_top.get("placename")
66 |
67 | if not city:
68 | place_top = self._get_top_result(data.get("place", {}))
69 | city = place_top.get("name") # Fallback to nearby populated place name
70 |
71 | return city
72 |
73 | def extract_state_long(self, data):
74 | reverse_top = self._get_top_result(data["reverse"])
75 | return reverse_top.get("adminName1")
76 |
77 | def extract_country(self, data):
78 | place_top = self._get_top_result(data["place"])
79 | return place_top.get("countryName")
80 |
--------------------------------------------------------------------------------
/custom_components/geolocator/sensor.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from homeassistant.components.sensor import SensorEntity
4 | from homeassistant.config_entries import ConfigEntry
5 | from homeassistant.core import HomeAssistant
6 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
7 | from zoneinfo import ZoneInfo
8 | from datetime import datetime
9 |
10 | from .const import DOMAIN
11 |
12 | SENSOR_KEYS = {
13 | "current_address": "Current Address",
14 | "city": "City",
15 | "state": "State",
16 | "country": "Country",
17 | "timezone_id": "Timezone ID",
18 | "timezone_full": "Timezone",
19 | "timezone_abbreviation": "Timezone Abbreviation",
20 | "timezone_source": "Data Source",
21 | "plus_code": "Plus Code",
22 | }
23 |
24 | SENSOR_ICONS = {
25 | "current_address": "mdi:map-marker",
26 | "city": "mdi:city",
27 | "state": "mdi:flag-variant",
28 | "country": "mdi:earth",
29 | "timezone_id": "mdi:calendar-clock",
30 | "timezone_full": "mdi:map-clock-outline",
31 | "timezone_abbreviation": "mdi:map-clock",
32 | "timezone_source": "mdi:cloud-download",
33 | "plus_code": "mdi:crosshairs-gps",
34 | }
35 |
36 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
37 | api_data = hass.data[DOMAIN][entry.entry_id]
38 | sensors = []
39 |
40 | provider = entry.options.get("api_provider") or entry.data.get("api_provider", "google")
41 |
42 | for key, name in SENSOR_KEYS.items():
43 | if provider == "offline" and key not in ("timezone_id", "timezone_abbreviation", "timezone_source"):
44 | continue
45 | if key == "timezone_source":
46 | sensors.append(TimezoneSourceSensor(hass=hass, entry=entry))
47 | else:
48 | sensors.append(GeoLocatorSensor(hass=hass, entry=entry, key=key, name=name, api_data=api_data))
49 |
50 | async_add_entities(sensors)
51 |
52 |
53 | class GeoLocatorSensor(SensorEntity):
54 | def __init__(self, hass, entry, key, name, api_data):
55 | self._entry = entry
56 | self._key = key
57 | self._name = name
58 | self._api_data = api_data
59 | self._attr_name = f"GeoLocator: {name}"
60 | self._attr_unique_id = f"{entry.entry_id}_{key}"
61 | self._attr_icon = SENSOR_ICONS.get(key, "mdi:map-marker-question")
62 |
63 |
64 | # Register self for updates
65 | hass.data[DOMAIN][entry.entry_id]["entities"].append(self)
66 |
67 | @property
68 | def state(self):
69 | if self._key in ("timezone_id", "timezone_abbreviation"):
70 | tz_id = self._api_data.get("last_timezone")
71 | if not tz_id:
72 | return None
73 | try:
74 | now = datetime.now(ZoneInfo(tz_id))
75 | if self._key == "timezone_id":
76 | return tz_id
77 | elif self._key == "timezone_abbreviation":
78 | return now.tzname()
79 | except Exception:
80 | return None
81 | elif self._key == "timezone_full":
82 | return self._api_data.get("timezone_full")
83 | elif self._key == "plus_code":
84 | return self._api_data.get("last_plus_code")
85 | else:
86 | last = self._api_data.get("last_address")
87 | if not last:
88 | return None
89 | return last.get(self._key)
90 |
91 |
92 | class TimezoneSourceSensor(SensorEntity):
93 | def __init__(self, hass, entry):
94 | self._hass = hass
95 | self._entry = entry
96 | self._attr_name = "GeoLocator: Data Source"
97 | self._attr_unique_id = f"{entry.entry_id}_data_source"
98 | self._attr_icon = SENSOR_ICONS.get("timezone_source", "mdi:cloud-question")
99 |
100 |
101 | hass.data[DOMAIN][entry.entry_id]["entities"].append(self)
102 |
103 | @property
104 | def state(self):
105 | return self._hass.data[DOMAIN][self._entry.entry_id].get("last_timezone_source")
106 |
107 |
108 |
--------------------------------------------------------------------------------
/custom_components/geolocator/config_flow.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import voluptuous as vol
3 |
4 | from homeassistant import config_entries
5 | from homeassistant.core import callback
6 | from homeassistant.data_entry_flow import FlowResult
7 |
8 | from .const import DOMAIN, CONF_API_KEY, CONF_API_PROVIDER, API_PROVIDER_META
9 |
10 | _LOGGER = logging.getLogger(__name__)
11 |
12 |
13 | class GeoLocatorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
14 | VERSION = 1
15 |
16 | def __init__(self):
17 | self._errors = {}
18 | self._selected_provider = None
19 |
20 | async def async_step_user(self, user_input=None):
21 | self._errors = {}
22 |
23 | if user_input is not None:
24 | self._selected_provider = user_input[CONF_API_PROVIDER]
25 | return await self.async_step_credentials()
26 |
27 | provider_options = {
28 | k: f"{v['name']} (no key required)" if not v['needs_key'] else v['name']
29 | for k, v in API_PROVIDER_META.items()
30 | }
31 |
32 | return self.async_show_form(
33 | step_id="user",
34 | data_schema=vol.Schema({
35 | vol.Required(CONF_API_PROVIDER): vol.In(provider_options)
36 | }),
37 | errors=self._errors,
38 | )
39 |
40 | async def async_step_credentials(self, user_input=None):
41 | self._errors = {}
42 |
43 | provider = self._selected_provider
44 | provider_meta = API_PROVIDER_META.get(provider, {})
45 |
46 | if user_input is not None:
47 | return self.async_create_entry(
48 | title="GeoLocator",
49 | data={
50 | CONF_API_PROVIDER: provider,
51 | CONF_API_KEY: user_input.get(CONF_API_KEY, "")
52 | }
53 | )
54 |
55 | if provider_meta.get("needs_key"):
56 | return self.async_show_form(
57 | step_id="credentials",
58 | data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
59 | errors=self._errors,
60 | description_placeholders={}
61 | )
62 | else:
63 | # Skip credentials step if not needed
64 | return self.async_create_entry(
65 | title="GeoLocator",
66 | data={CONF_API_PROVIDER: provider, CONF_API_KEY: ""}
67 | )
68 |
69 | @staticmethod
70 | @callback
71 | def async_get_options_flow(config_entry):
72 | return GeoLocatorOptionsFlowHandler(config_entry)
73 |
74 |
75 | class GeoLocatorOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
76 | def __init__(self, config_entry):
77 | super().__init__(config_entry)
78 | self._errors = {}
79 | self._selected_provider = None
80 |
81 | async def async_step_init(self, user_input=None):
82 | self._errors = {}
83 |
84 | if user_input is not None:
85 | self._selected_provider = user_input[CONF_API_PROVIDER]
86 | return await self.async_step_options_credentials()
87 |
88 | provider_options = {
89 | k: f"{v['name']} (no key required)" if not v['needs_key'] else v['name']
90 | for k, v in API_PROVIDER_META.items()
91 | }
92 |
93 | current_provider = self.config_entry.options.get(
94 | CONF_API_PROVIDER,
95 | self.config_entry.data.get(CONF_API_PROVIDER)
96 | )
97 |
98 | return self.async_show_form(
99 | step_id="init",
100 | data_schema=vol.Schema({
101 | vol.Required(CONF_API_PROVIDER, default=current_provider): vol.In(provider_options)
102 | }),
103 | errors=self._errors,
104 | description_placeholders={}
105 | )
106 |
107 | async def async_step_options_credentials(self, user_input=None):
108 | self._errors = {}
109 |
110 | provider = self._selected_provider
111 | provider_meta = API_PROVIDER_META.get(provider, {})
112 |
113 | current_key = self.config_entry.options.get(
114 | CONF_API_KEY,
115 | self.config_entry.data.get(CONF_API_KEY, "")
116 | )
117 |
118 | if not provider_meta.get("needs_key"):
119 | return self.async_create_entry(
120 | title="",
121 | data={CONF_API_PROVIDER: provider, CONF_API_KEY: ""}
122 | )
123 |
124 | if user_input is not None:
125 | return self.async_create_entry(
126 | title="",
127 | data={CONF_API_PROVIDER: provider, CONF_API_KEY: user_input.get(CONF_API_KEY, "")}
128 | )
129 |
130 | return self.async_show_form(
131 | step_id="options_credentials",
132 | data_schema=vol.Schema({vol.Required(CONF_API_KEY, default=current_key): str}),
133 | errors=self._errors,
134 | description_placeholders={}
135 | )
136 |
--------------------------------------------------------------------------------
/custom_components/geolocator/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import voluptuous as vol
3 | from homeassistant.config_entries import ConfigEntry
4 | from homeassistant.core import HomeAssistant, ServiceCall
5 | from homeassistant.helpers.typing import ConfigType
6 | from homeassistant.helpers import config_validation as cv
7 | from homeassistant.helpers.service import async_register_admin_service
8 |
9 | from .const import DOMAIN, SERVICE_SET_TIMEZONE, API_PROVIDER_META
10 | from .api.google import GoogleMapsAPI
11 | from .api.opencage import OpenCageAPI
12 | from .api.geonames import GeoNamesAPI
13 | from .api.bigdatacloud import BigDataCloudAPI
14 |
15 | from timezonefinder import TimezoneFinder
16 |
17 | from openlocationcode import openlocationcode as olc
18 |
19 | from babel.dates import get_timezone_name
20 | from zoneinfo import ZoneInfo
21 | from datetime import datetime
22 |
23 | CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
24 |
25 | _LOGGER = logging.getLogger(__name__)
26 |
27 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
28 | async def async_set_home_timezone(call: ServiceCall):
29 | await hass.config.async_update(time_zone=call.data["timezone"])
30 |
31 | async_register_admin_service(
32 | hass,
33 | DOMAIN,
34 | SERVICE_SET_TIMEZONE,
35 | async_set_home_timezone,
36 | vol.Schema({"timezone": cv.time_zone}),
37 | )
38 | return True
39 |
40 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
41 | config = entry.options if entry.options else entry.data
42 | provider = config.get("api_provider", "google")
43 | api_key = config.get("api_key", "")
44 |
45 | original_provider = entry.data.get("api_provider", "google")
46 | if entry.options and provider != original_provider:
47 | _LOGGER.info("GeoLocator: API provider changed from '%s' to '%s' via options", original_provider, provider)
48 | else:
49 | _LOGGER.info("GeoLocator: Using API provider: %s", provider)
50 |
51 | if provider == "google":
52 | api = GoogleMapsAPI(api_key)
53 | elif provider == "opencage":
54 | api = OpenCageAPI(api_key)
55 | elif provider == "geonames":
56 | api = GeoNamesAPI(api_key)
57 | elif provider == "bigdatacloud":
58 | api = BigDataCloudAPI()
59 | elif provider == "offline":
60 | api = None
61 | else:
62 | raise ValueError(f"Unsupported API provider: {provider}")
63 |
64 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
65 | "api": api,
66 | "entry": entry,
67 | "last_address": None,
68 | "last_timezone": None,
69 | "last_timezone_source": None,
70 | "entities": [],
71 | }
72 |
73 | async def async_update_location_service(call: ServiceCall | None = None):
74 | lat = hass.config.latitude
75 | lon = hass.config.longitude
76 | _LOGGER.debug("GeoLocator: Fetching location for lat=%s, lon=%s", lat, lon)
77 |
78 | try:
79 | address_data = {}
80 | timezone_id = None
81 | source = None
82 | plus_code = olc.encode(lat, lon)
83 |
84 | if api is not None:
85 | try:
86 | user_language = hass.config.language or "en"
87 | geocode_raw = await api.reverse_geocode(lat, lon, user_language)
88 | timezone_id = await api.get_timezone(lat, lon, user_language)
89 |
90 | address_data = {
91 | "current_address": api.format_full_address(geocode_raw),
92 | "city": api.extract_city(geocode_raw),
93 | "state": api.extract_state_long(geocode_raw),
94 | "country": api.extract_country(geocode_raw),
95 | "plus_code": plus_code,
96 | }
97 | source = API_PROVIDER_META[provider]["name"]
98 |
99 | except Exception as e:
100 | _LOGGER.warning("GeoLocator: Failed to update location: %s", e)
101 |
102 | if not timezone_id:
103 | try:
104 | def _find_timezone():
105 | tf = TimezoneFinder(in_memory=True)
106 | try:
107 | return tf.timezone_at(lat=lat, lng=lon)
108 | except Exception as e:
109 | _LOGGER.warning("GeoLocator: Exception while finding timezone: %s", e)
110 | return None
111 |
112 | tz = await hass.async_add_executor_job(_find_timezone)
113 | if tz:
114 | timezone_id = tz
115 | source = "Local Fallback"
116 | else:
117 | source = "Error"
118 | except Exception as e:
119 | _LOGGER.warning("GeoLocator: Failed to find local timezone: %s", e)
120 |
121 | # Add full timezone name using Babel and zoneinfo
122 | try:
123 | if timezone_id:
124 | tz = ZoneInfo(timezone_id)
125 | dt = datetime.now(tz)
126 | user_locale = hass.config.language or "en-US"
127 | is_dst = dt.dst() is not None and dt.dst().total_seconds() != 0
128 | zone_variant = 'daylight' if is_dst else 'standard'
129 |
130 | def _get_full_timezone_name():
131 | from babel.dates import get_timezone, get_timezone_name
132 | from babel.core import Locale, UnknownLocaleError
133 |
134 | tzinfo = get_timezone(timezone_id)
135 |
136 | try:
137 | loc = Locale.parse(user_locale, sep='-')
138 | except UnknownLocaleError:
139 | loc = Locale.parse("en-US", sep='-')
140 |
141 | return get_timezone_name(dt, locale=loc)
142 |
143 | full_name = await hass.async_add_executor_job(_get_full_timezone_name)
144 | else:
145 | full_name = None
146 | except Exception as e:
147 | _LOGGER.warning("GeoLocator: Failed to get full timezone name: %s", e)
148 | full_name = None
149 |
150 | hass.data[DOMAIN][entry.entry_id]["last_address"] = address_data
151 | hass.data[DOMAIN][entry.entry_id]["last_timezone"] = timezone_id
152 | hass.data[DOMAIN][entry.entry_id]["last_timezone_source"] = source
153 | hass.data[DOMAIN][entry.entry_id]["last_plus_code"] = plus_code
154 | hass.data[DOMAIN][entry.entry_id]["timezone_full"] = full_name
155 |
156 | # Call the timezone-setting service with the computed timezone_id
157 | if timezone_id:
158 | try:
159 | await hass.services.async_call(
160 | DOMAIN,
161 | SERVICE_SET_TIMEZONE,
162 | {"timezone": timezone_id},
163 | blocking=True
164 | )
165 | except Exception as e:
166 | _LOGGER.error("GeoLocator: Failed to call set_home_timezone: %s", e)
167 |
168 | for entity in hass.data[DOMAIN][entry.entry_id]["entities"]:
169 | entity.async_schedule_update_ha_state(True)
170 |
171 | except Exception as e:
172 | _LOGGER.exception("GeoLocator: Unexpected error during location update: %s", e)
173 |
174 |
175 | hass.data[DOMAIN][entry.entry_id]["update_func"] = async_update_location_service
176 | hass.services.async_register(DOMAIN, "update_location", async_update_location_service)
177 |
178 | await hass.config_entries.async_forward_entry_setups(entry, ["sensor"])
179 |
180 | await async_update_location_service()
181 | entry.async_on_unload(entry.add_update_listener(async_reload_entry))
182 | return True
183 |
184 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
185 | unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor"])
186 | if unload_ok:
187 | hass.data[DOMAIN].pop(entry.entry_id, None)
188 | return unload_ok
189 |
190 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
191 | await async_unload_entry(hass, entry)
192 | await async_setup_entry(hass, entry)
193 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | # GeoLocator by [SmartyVan](https://www.youtube.com/@SmartyVan)
5 | [](https://github.com/hacs/integration)
6 | [](https://github.com/smartyvan/hass-geolocator/releases)
7 |
8 | [](https://discord.gg/3rqeqES3zP)
9 | [](https://www.youtube.com/@SmartyVan)
10 | [](https://www.buymeacoffee.com/smartyvan)
11 |
12 |
13 |
14 |
15 |
16 | ### 📺 Watch the [YouTube video](https://www.youtube.com/watch?v=Kg4TQhNOonE) about this project:
17 | [
](https://www.youtube.com/watch?v=Kg4TQhNOonE)
18 | ### WHERE are we
19 | **GeoLocator** is a Home Assistant custom integration that retrieves current reverse geocoded location sensor data based on `zone.home` GPS coordinate attributes using one of several provided reverse geocode API options.
20 |
21 | ### WHEN are we
22 | This integration also solves a major problem for mobile Home Assistant servers: it creates a service to update the Home Assistant system timezone programatically. An accurate system timezone is crucial for Automation Timing, Sun events, Template rendering (`now()`, `today_at()`, `as_timestamp()`), and Dashboard time display.
23 |
24 | ### OFFLINE happens
25 | Designed specifically for moving vehicles (vans, RVs, boats) that *MAY* not always have an internet connection, GeoLocator falls back to a local python library ([timezonefinder](https://pypi.org/project/timezonefinder/)) when no network connection is available. This method is less accurate, but works offline.
26 |
27 | *Optionally*: GeoLocator can be used in `Offline` mode to force the use of the local timezonefinder library at all times to set system timezone — in this mode, no reverse geocode data will be retrived.
28 |
29 | ---
30 |
31 | ## 🔧 Installation
32 |
33 | ### HACS Installation (Custom Repository)
34 |
35 | This integration is not yet available in the HACS default store, however you can still install it via [HACS](https://www.hacs.xyz/docs/use/) as a custom repository:
36 |
37 | [](https://my.home-assistant.io/redirect/hacs_repository/?owner=SmartyVan&repository=hass-geolocator&category=integration)
38 |
39 |
40 | 1. Click "Open HACS Repository" button above and install GeoLocator
41 | 2. Restart Home Assistant
42 | 3. Navigate to Settings > Devices & Services
43 | 4. Click **Add Integration** at the bottom
44 | 5. Search for **GeoLocator**
45 |
46 | ### Or, Manual Installation
47 |
48 | 1. Download the source code of the [latest release](https://github.com/SmartyVan/hass-geolocator/releases).
49 | 2. Unzip the source code download.
50 | 3. Copy **geolocator** from the **custom_components** directory you just downloaded to your Home Assistant **custom_components** directory:
51 | ```
52 | config/custom_components/geolocator/
53 | ```
54 |
55 | 4. Restart Home Assistant.
56 | ---
57 |
58 | ## 📍 Usage
59 |
60 | Basic usage requires two steps:
61 | ### Step 1.
62 | Set the GPS coordinate attributes of `zone.home` to your current location using the Home Assistant Core Integration: `homeassistant.set_location` in an automation, script, or developer tools:
63 | ```yaml
64 | action: homeassistant.set_location
65 | data:
66 | latitude: 34.0549
67 | longitude: -118.2426
68 | ```
69 | The source of the coordinates can be from any sensor or input you have available. This may be a router, Home Assistant native iOS / Android app, Cerbo GX with USB GPS dongle, etc.
70 |
71 | *Step 1 does not rely on this custom component, but is a necessary step to ensure your `zone.home` has current GPS coordinates.*
72 |
73 | ### Step 2.
74 | Call custom service: `geolocator.update_location`:
75 | ```yaml
76 | action: geolocator.update_location
77 | data: {}
78 | ```
79 |
80 | This will fetch the Reverse Geocode data and populate sensors (if using an API) and then set the Home Assistant system Timezone if it has changed.
81 |
82 | **Example Automation:**\
83 | This is a very basic automation. Consider using conditions to restrict location information udpates only when vehicle is (or has been) moving.
84 | ```yaml
85 | alias: "GeoLocator: Update Location"
86 | description: "Fetch Reverse Geocode and Timezone ID from API when zone.home is updated."
87 | triggers:
88 | - trigger: state
89 | entity_id:
90 | - zone.home
91 | conditions: []
92 | actions:
93 | - action: geolocator.update_location
94 | metadata: {}
95 | data: {}
96 | mode: single
97 | ```
98 |
99 | ### 🚨 Important:
100 | By design, this component does **NOT** automatically poll.\
101 | You decide how often you want to update the GPS coordinate attributes of `zone.home`.\
102 | You also decide how often to call `geolocator.update_location`.\
103 | This flexibility allows for maximum control over polling rates, and updates.
104 |
105 | ---
106 |
107 | ## ⚙️ Sensors Created
108 |
109 | | Entity | Description | Generated by |
110 | |:-------|:------------|:------|
111 | | `sensor.geolocator_current_address`* | Formatted location address | API |
112 | | `sensor.geolocator_city`* | City name | API |
113 | | `sensor.geolocator_state`* | State name | API |
114 | | `sensor.geolocator_country`* | Country name | API |
115 | | `sensor.geolocator_timezone_id` | Timezone ID (`America/Chicago`) | API / Local Fallback |
116 | | `sensor.geolocator_timezone` | Timezone (`Central Daylight Time`) | Local |
117 | | `sensor.geolocator_timezone_abbreviation` | Timezone Abbreviation (`CDT`) | Local |
118 | | `sensor.geolocator_data_source` | API provider used for current data (*or Offline Fallback*) | Local |
119 | | `sensor.geolocator_plus_code` | Full [Plus Code](https://maps.google.com/pluscodes/) for current location | Local |
120 |
121 | \* *these sensors are only created/updated when using an API - they will also be unavailable when GeoLocator falls back to the local Python library*
122 |
123 | ---
124 |
125 | ## 🌐 Supported Reverse Geocoding APIs
126 |
127 | These are the currently supported APIs. Feel free to submit pull requests for other services.
128 |
129 | | Results | API Service | Credentials | Notes | Current Address | Localized |
130 | |:-------:|-------------|-------------|-------|-----------------|:------------:|
131 | |🟢| **Google Maps** | `API Key` | Enable Reverse Geocode & Timezone APIs. Add billing to your project. Create an [API key](https://developers.google.com/maps). | Full street address | ✔︎ |
132 | |🟢| **OpenCage** | `API Key` | [Sign up](https://opencagedata.com) for a free account and retrieve an API key. \**free accounts can make 2,500 requests/day (1 request/second)* | Full street address | ✔︎ |
133 | |🟡| **GeoNames** | `Username` | Requires free [user account](https://www.geonames.org/login). After activation, visit [Manage Account](https://www.geonames.org/manageaccount) and enable free web servcies (link at bottom of page). | Full street address (US only) |
134 | |🟠| **BigDataCloud** | None | Free - no API key required. | City, State, Country Only |
135 | |🟠| **Offline** | None | **No Reverse Geocode!** Some enclaves or borders are less accurate than the API solutions but works 100% locally using the timezonefinder library. | None |
136 |
137 | *Only one service is used at a time (with fallback to the local python library). API/user key configuration is available via the UI.*
138 |
139 | ---
140 |
141 | ## 🔧 Services Provided
142 |
143 | | Service | Description |
144 | |:--------|:------------|
145 | | `geolocator.update_location` | Fetch the latest location and timezone from your chosen API, update sensors, and automatically update Home Assistant's timezone. |
146 | | `geolocator.set_home_timezone` | Used internally by the component to set Home Assistant system timezone using a provided IANA Timezone ID (e.g. `America/New_York`). Can be useful on its own if you acquire your Timezone ID elsewhere and simply need to set system timezone. |
147 |
148 | ---
149 |
150 | ## 📋 Notes
151 |
152 | - This integration **only updates location and timezone data when manually triggered using the `geolocator.update_location` service** — no automatic background polling.
153 | - API costs are your responsibility, but most services have generous quotas on their free tiers.
154 | - Intended for users who move frequently across regions and want dashboard and system timezone awareness.
155 |
156 | ---
157 |
158 | ## 🧑💻 Author
159 |
160 | Created by [@SmartyVan](https://github.com/SmartyVan).
161 | Smarty Van on [YouTube](https://www.youtube.com/@SmartyVan).\
162 | Licensed under MIT License.
163 |
164 | ---
165 |
166 | ## 💬 Contributions & Issues
167 |
168 | Feel free to open issues, suggest improvements, or contribute pull requests on GitHub!
169 |
--------------------------------------------------------------------------------