├── .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 | GeoLocator 2 | 3 | 4 | # GeoLocator by [SmartyVan](https://www.youtube.com/@SmartyVan) 5 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/hacs/integration) 6 | [![GitHub release](https://img.shields.io/github/v/release/smartyvan/hass-geolocator)](https://github.com/smartyvan/hass-geolocator/releases) 7 | 8 | [![Join our Discord](https://img.shields.io/discord/1303421267545821245?label=Join%20Discord&logo=discord)](https://discord.gg/3rqeqES3zP) 9 | [![YouTube](https://img.shields.io/badge/YouTube-Smarty%20Van-red?logo=youtube&logoColor=white)](https://www.youtube.com/@SmartyVan) 10 | [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-donate-yellow.svg)](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 | [Smarty Van YouTube video](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 | [![Open this repository in your Home Assistant instance.](https://my.home-assistant.io/badges/hacs_repository.svg)](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 | --------------------------------------------------------------------------------