From 7027cccea9eb7170ea0f08e1def3f979f2e59932 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 25 Jun 2026 16:03:18 -0400 Subject: feat: add external-command geolocation, opt-out, and example adapters These build on the current-location picker with the rest of the geolocation work. wttrin-geolocation-command runs a command that prints {lat,lng} (and optionally an address) and queries wttr.in by those coordinates. IP geolocation only finds the network's exit point, which is wrong on a VPN or hotspot. A command that scans nearby WiFi resolves to street level. It runs asynchronously, falls back to the IP provider when unset or failing, and assumes nothing about the OS, so it's inert until set. When the command returns an address, wttrin shows it on a "Location:" line, so the resolved place is readable even though the fetch is by raw coordinates. wttrin-use-current-location is a labeled command that sets the favorite to auto-detect, so the bare t value never has to be typed into init by hand. wttrin-geolocation-enabled (default t) turns every geolocation surface off for anyone who wants that: the picker entry, the auto-detect favorite, and the command. examples/geolocation/ ships two reference adapters for the command: google-geolocate.py (Google API, key via the environment or ~/.authinfo.gpg) and apple-wps.py (Apple's keyless WiFi positioning, which uses an undocumented endpoint, so read its caveat). Both are Python 3 standard library and scan via nmcli, with notes on adapting the scan to other systems. --- examples/geolocation/google-geolocate.py | 190 +++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100755 examples/geolocation/google-geolocate.py (limited to 'examples/geolocation/google-geolocate.py') 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 login password . + """ + 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"(?