diff options
Diffstat (limited to 'examples/geolocation/google-geolocate.py')
| -rwxr-xr-x | examples/geolocation/google-geolocate.py | 190 |
1 files changed, 190 insertions, 0 deletions
diff --git a/examples/geolocation/google-geolocate.py b/examples/geolocation/google-geolocate.py new file mode 100755 index 0000000..3acbbe8 --- /dev/null +++ b/examples/geolocation/google-geolocate.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""google-geolocate -- find your location from nearby WiFi via the Google Geolocation API. + +An example adapter for wttrin's `wttrin-geolocation-command'. It scans nearby +WiFi access points, sends them to Google's Geolocation API, and prints the +resulting coordinates (plus a reverse-geocoded address) as JSON on stdout: + + {"lat": 41.3222, "lng": -71.8113, "accuracy_m": 25.0, "address": "Westerly, Rhode Island, USA"} + +wttrin reads `lat' and `lng' to fetch weather, and shows `address' on the +buffer's "Location:" line. + +Why this beats IP geolocation: a real WiFi scan fed to Google's database +resolves to street level, where IP geolocation only finds your network's exit +point -- often the wrong city on a VPN or a cellular hotspot. + +Requirements: + - Python 3 (standard library only). + - nmcli (NetworkManager) for the WiFi scan. On a system without + NetworkManager, replace `scan_wifi' with your platform's scanner. + - A Google API key with the Geolocation API enabled, in the environment + variable GOOGLE_GEOLOCATION_API_KEY. See the README for setup and pricing. + +Wire it into Emacs: + + (setq wttrin-geolocation-command "/path/to/google-geolocate.py") + +On any failure this exits non-zero, so wttrin falls back to its IP provider. +""" + +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from typing import NoReturn + +GEOLOCATE_URL = "https://www.googleapis.com/geolocation/v1/geolocate?key={key}" +NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lng}&format=jsonv2" +USER_AGENT = "wttrin-google-geolocate-example/1.0" + + +AUTHINFO_MACHINE = "googleapis.com" # the "machine" entry this looks for in authinfo + + +def fail(message) -> NoReturn: + """Print MESSAGE to stderr and exit non-zero (wttrin falls back to IP).""" + print(f"google-geolocate: {message}", file=sys.stderr) + sys.exit(1) + + +def read_api_key(): + """Return the Google API key, or None if it cannot be found. + + Checks the environment variable GOOGLE_GEOLOCATION_API_KEY first, then + ~/.authinfo.gpg (the encrypted store Emacs's auth-source uses). See the + README for the authinfo line format. + """ + key = os.environ.get("GOOGLE_GEOLOCATION_API_KEY") + if key: + return key + return read_key_from_authinfo() + + +def read_key_from_authinfo(): + """Return the password for the AUTHINFO_MACHINE entry in ~/.authinfo.gpg, or None. + + Decrypts the file with gpg (gpg-agent supplies the passphrase) and reads a + netrc-style line: machine <host> login <user> password <secret>. + """ + path = os.path.expanduser("~/.authinfo.gpg") + if not os.path.exists(path): + return None + try: + decrypted = subprocess.run( + ["gpg", "--quiet", "--batch", "--decrypt", path], + capture_output=True, text=True, timeout=30, check=True).stdout + except (FileNotFoundError, subprocess.SubprocessError): + return None + for line in decrypted.splitlines(): + tokens = line.split() + if "machine" in tokens and "password" in tokens: + pairs = dict(zip(tokens[::2], tokens[1::2])) + if pairs.get("machine") == AUTHINFO_MACHINE: + return pairs.get("password") + return None + + +def scan_wifi(): + """Return a list of {"macAddress", "signalStrength"} for visible access points. + + Uses nmcli. Its SIGNAL column is a 0-100 quality percentage; NetworkManager + maps quality = 2 * (dBm + 100), so dBm = quality / 2 - 100, which is the unit + Google's API expects. Replace this function to support a non-NetworkManager + system (for example macOS via CoreWLAN); it is the only platform-specific part. + """ + try: + completed = subprocess.run( + ["nmcli", "-t", "-f", "SIGNAL,BSSID", + "device", "wifi", "list", "--rescan", "yes"], + capture_output=True, text=True, timeout=20, check=True) + except FileNotFoundError: + fail("nmcli not found; replace scan_wifi for your system") + except subprocess.SubprocessError as error: + fail(f"wifi scan failed: {error}") + + access_points = [] + for line in completed.stdout.splitlines(): + # In nmcli -t output, fields are colon-separated and literal colons + # inside a field (the BSSID) are backslash-escaped. Splitting on the + # first unescaped colon separates SIGNAL from the BSSID, then we unescape. + parts = [p.replace("\\:", ":") + for p in re.split(r"(?<!\\):", line, maxsplit=1)] + if len(parts) != 2: + continue + signal, bssid = parts + if not re.fullmatch(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}", bssid): + continue + try: + quality = int(signal) + except ValueError: + continue + access_points.append({"macAddress": bssid, + "signalStrength": quality // 2 - 100}) + return access_points + + +def google_geolocate(access_points, key): + """Ask Google for a position from ACCESS_POINTS; return (lat, lng, accuracy).""" + body = json.dumps({"considerIp": False, + "wifiAccessPoints": access_points}).encode() + request = urllib.request.Request( + GEOLOCATE_URL.format(key=key), data=body, + headers={"Content-Type": "application/json", "User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(request, timeout=20) as response: + data = json.load(response) + except urllib.error.HTTPError as error: + detail = error.read().decode(errors="replace")[:200] + fail(f"Google API error {error.code}: {detail}") + except urllib.error.URLError as error: + fail(f"network error: {error.reason}") + + location = data.get("location", {}) + return location.get("lat"), location.get("lng"), data.get("accuracy") + + +def reverse_geocode(lat, lng): + """Return a human-readable address for LAT, LNG, or None on any failure. + + Best-effort via OpenStreetMap Nominatim, which requires a descriptive + User-Agent and rate-limits heavy use. + """ + request = urllib.request.Request( + NOMINATIM_URL.format(lat=lat, lng=lng), + headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return json.load(response).get("display_name") + except (urllib.error.URLError, ValueError): + return None + + +def main(): + key = read_api_key() + if not key: + fail("no API key: set GOOGLE_GEOLOCATION_API_KEY or add it to " + "~/.authinfo.gpg (see README)") + + access_points = scan_wifi() + if len(access_points) < 2: + fail(f"only {len(access_points)} access point(s) visible; need at least 2") + + lat, lng, accuracy = google_geolocate(access_points, key) + if lat is None or lng is None: + fail("Google returned no location") + + result = {"lat": lat, "lng": lng} + if accuracy is not None: + result["accuracy_m"] = accuracy + address = reverse_geocode(lat, lng) + if address: + result["address"] = address + print(json.dumps(result)) + + +if __name__ == "__main__": + main() |
