diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-25 16:03:18 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-25 16:03:18 -0400 |
| commit | 7027cccea9eb7170ea0f08e1def3f979f2e59932 (patch) | |
| tree | 51752f8cac0d92b4442048d913272e923211b619 /examples | |
| parent | 33621b5a6e5407da190767b89756e287698ef234 (diff) | |
| download | emacs-wttrin-7027cccea9eb7170ea0f08e1def3f979f2e59932.tar.gz emacs-wttrin-7027cccea9eb7170ea0f08e1def3f979f2e59932.zip | |
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.
Diffstat (limited to 'examples')
| -rw-r--r-- | examples/geolocation/README.org | 164 | ||||
| -rwxr-xr-x | examples/geolocation/apple-wps.py | 334 | ||||
| -rwxr-xr-x | examples/geolocation/google-geolocate.py | 190 |
3 files changed, 688 insertions, 0 deletions
diff --git a/examples/geolocation/README.org b/examples/geolocation/README.org new file mode 100644 index 0000000..2a5462e --- /dev/null +++ b/examples/geolocation/README.org @@ -0,0 +1,164 @@ +#+TITLE: wttrin geolocation command adapters + +These are example programs for ~wttrin-geolocation-command~. wttrin can run an +external command to find your location with far better accuracy than IP +geolocation (which only finds your network's exit point — wrong on a VPN or a +cellular hotspot). The command scans nearby WiFi access points, looks them up, +and prints coordinates; wttrin then queries wttr.in by those coordinates. + +These are *examples*, not part of the installed package. Copy one, adapt it to +your system, and point the variable at it: + +#+begin_src emacs-lisp + (setq wttrin-geolocation-command "/path/to/google-geolocate.py") +#+end_src + +* The contract + +A command prints one JSON object to standard output and exits zero: + +#+begin_src json + {"lat": 41.3222, "lng": -71.8113, "accuracy_m": 25.0, "address": "Westerly, Rhode Island, USA"} +#+end_src + +- ~lat~ and ~lng~ (numbers) are required. wttrin fetches weather for them. +- ~address~ (or ~label~) is optional. When present, wttrin shows it on a + "Location:" line in the weather buffer, so the resolved place is readable even + though the weather was fetched by raw coordinates. +- Any other keys are ignored. + +On any failure the command should exit non-zero (and may print a message to +standard error). wttrin then falls back to the IP provider named by +~wttrin-geolocation-provider~, so geolocation still works. + +* The adapters + +** google-geolocate.py +Uses the Google Geolocation API. Documented, supported, accurate, broad +coverage. Needs an API key (see below). Recommended starting point. + +** apple-wps.py +Uses Apple's WiFi positioning service. Keyless and accurate, but it queries an +*undocumented* Apple endpoint via a reverse-engineered protocol, which may be +contrary to Apple's terms of service. Read the caveat at the top of the file and +decide for yourself. Provided as an example only. + +* Requirements + +- Python 3 (standard library only — no packages to install). +- A WiFi scanner. Both examples use ~nmcli~ (NetworkManager), which is common on + Linux. On a system without NetworkManager, replace the ~scan_wifi~ function + with your platform's scanner; that is the only platform-specific part. See + "Adapting the WiFi scan" below for systemd-networkd, iwd, and macOS. +- Network access. Both also do a best-effort reverse-geocode via OpenStreetMap + Nominatim to fill in ~address~; if that call fails the coordinates are still + returned, just without an address. + +* Adapting the WiFi scan + +~scan_wifi~ needs, for each nearby access point, its BSSID (MAC address) and a +signal strength. nmcli gives a 0-100 quality (the examples convert it to dBm); +the sources below give dBm directly. Swap in whichever your system has and adjust +the parsing to match the sample line. + +** systemd-networkd (via wpa_supplicant) +networkd handles addressing, not WiFi scanning — the scan comes from +wpa_supplicant. No root needed when wpa_supplicant is already running. + +#+begin_src sh + wpa_cli -i wlan0 scan + wpa_cli -i wlan0 scan_results +#+end_src + +Output columns are =BSSID frequency signal(dBm) flags SSID= (tab-separated): + +#+begin_example + 00:11:22:33:44:55 2437 -65 [WPA2-PSK-CCMP][ESS] MyNetwork +#+end_example + +Take field 1 (BSSID) and field 3 (signal in dBm). + +** iwd +iwd has no scriptable way to list per-AP BSSIDs (=iwctl station <iface> +get-networks= shows SSIDs and aggregated signal, not BSSIDs). On an iwd system, +use =iw= below — it works regardless of the network manager. + +** Any Linux (iw, the low-level tool) +=iw= talks to the kernel directly (nl80211), so it works under NetworkManager, +iwd, wpa_supplicant, or networkd. Triggering a scan needs root. + +#+begin_src sh + sudo iw dev wlan0 scan | grep -E "^BSS|signal:" +#+end_src + +#+begin_example + BSS 00:11:22:33:44:55(on wlan0) + signal: -65.00 dBm +#+end_example + +Pair each =BSS <bssid>= line with the =signal: <dBm>= line that follows it. + +** macOS +macOS restricts WiFi scanning. The old =airport -s= was removed in macOS 14, and +=system_profiler SPAirPortDataType= redacts BSSIDs unless the calling process has +Location Services permission. The reliable path today is a small CoreWLAN helper +(Swift/PyObjC) that, with Location Services granted, reads =CWInterface +scanForNetworksWithName= and returns each network's =bssid= and =rssiValue= +(dBm). Treat macOS as needing that helper rather than a one-liner. + +* Setting up a Google API key + +1. Create a project in the Google Cloud console (https://console.cloud.google.com/). +2. Enable the "Geolocation API" for that project. +3. Create an API key (APIs & Services -> Credentials -> Create credentials -> API key). + Restrict it to the Geolocation API. +4. Note Google's pricing: the Geolocation API is a paid API with a monthly free + allowance. Review current terms before relying on it. +5. Make the key available to the command. The script checks the environment + first, then ~~/.authinfo.gpg~; use whichever you prefer. + +** Option A: an environment variable + +#+begin_src sh + export GOOGLE_GEOLOCATION_API_KEY="your-key-here" +#+end_src + +Put it somewhere your Emacs inherits it (a login shell profile). The script reads +~GOOGLE_GEOLOCATION_API_KEY~ and never stores the key. + +** Option B: ~/.authinfo.gpg (encrypted, recommended) + +Keep the key in the same encrypted store Emacs's auth-source uses. The script +reads it whenever the environment variable is unset. + +1. Open ~~/.authinfo.gpg~ in Emacs. It decrypts transparently for editing. (If + the file doesn't exist, create it; Emacs encrypts it on save once your GnuPG + key is set up.) +2. Add one netrc-style line: + +#+begin_src authinfo + machine googleapis.com login geolocation password YOUR-KEY-HERE +#+end_src + +3. Save the buffer. Emacs re-encrypts the file. +4. Confirm gpg can read it without prompting (gpg-agent caches the passphrase): + +#+begin_src sh + gpg --quiet --decrypt ~/.authinfo.gpg | grep googleapis.com +#+end_src + +The ~login~ field (~geolocation~) is just a label. The script matches on +~machine googleapis.com~ and uses the ~password~ value; the key is never printed +or written in plaintext. + +* Testing an adapter by hand + +Run it in a terminal and check the JSON: + +#+begin_src sh + ./google-geolocate.py + # {"lat": 41.3222, "lng": -71.8113, "accuracy_m": 25.0, "address": "..."} +#+end_src + +If it prints coordinates near you, point ~wttrin-geolocation-command~ at it and +pick "Current location (detect)" in ~M-x wttrin~. diff --git a/examples/geolocation/apple-wps.py b/examples/geolocation/apple-wps.py new file mode 100755 index 0000000..c09b660 --- /dev/null +++ b/examples/geolocation/apple-wps.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""apple-wps -- find your location from nearby WiFi via Apple's positioning service. + + CAVEAT, READ FIRST + ------------------ + This queries gs-loc.apple.com, an UNDOCUMENTED Apple endpoint, using a + reverse-engineered protobuf protocol. It is not a public or supported API, + and using it may be contrary to Apple's terms of service. It is provided as + an example only -- decide for yourself whether to use it. It needs no API + key and no account. + +An example adapter for wttrin's `wttrin-geolocation-command'. It scans nearby +WiFi access points, asks Apple for their coordinates, averages the located ones +weighted by signal strength to estimate your position, and prints JSON on stdout: + + {"lat": 41.3222, "lng": -71.8113, "address": "Westerly, Rhode Island, USA"} + +wttrin reads `lat' and `lng' to fetch weather, and shows `address' on the +buffer's "Location:" line. + +Note the difference from Google's API: Google solves your position from a scan +and returns one point. Apple returns the location of *each access point*, so +this script does the averaging itself. + +Requirements: + - Python 3 (standard library only; the protobuf is hand-encoded below). + - nmcli (NetworkManager) for the WiFi scan. Replace `scan_wifi' for a system + without NetworkManager; it is the only platform-specific part. + +Wire it into Emacs: + + (setq wttrin-geolocation-command "/path/to/apple-wps.py") + +On any failure this exits non-zero, so wttrin falls back to its IP provider. +""" + +import json +import re +import struct +import subprocess +import sys +import urllib.error +import urllib.request +from typing import NoReturn + +WLOC_URL = "https://gs-loc.apple.com/clls/wloc" +NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lng}&format=jsonv2" +# Apple's service inspects the User-Agent; a locationd-like string is expected. +APPLE_USER_AGENT = "locationd/1753.17 CFNetwork/889.9 Darwin/17.2.0" +NOMINATIM_USER_AGENT = "wttrin-apple-wps-example/1.0" + +MAX_ACCESS_POINTS = 12 # cap the request size; the strongest APs suffice +APPLE_UNKNOWN = -18000000000 # value Apple returns for an access point it can't place + + +def fail(message) -> NoReturn: + """Print MESSAGE to stderr and exit non-zero (wttrin falls back to IP).""" + print(f"apple-wps: {message}", file=sys.stderr) + sys.exit(1) + + +# -------------------------------------------------------------------------- +# Minimal protobuf -- only the few wire-format pieces this request and response +# need, so there is no dependency on a protobuf library. +# -------------------------------------------------------------------------- + +def encode_varint(value): + """Encode a non-negative integer as a protobuf base-128 varint.""" + out = bytearray() + while True: + seven_bits = value & 0x7F + value >>= 7 + out.append(seven_bits | (0x80 if value else 0)) + if not value: + return bytes(out) + + +def read_varint(buf, offset): + """Decode a varint from BUF starting at OFFSET; return (value, next_offset).""" + result = shift = 0 + while True: + byte = buf[offset] + offset += 1 + result |= (byte & 0x7F) << shift + if not byte & 0x80: + return result, offset + shift += 7 + + +def tag(field_number, wire_type): + """Encode a protobuf field tag (field number + wire type).""" + return encode_varint((field_number << 3) | wire_type) + + +def length_delimited(field_number, data): + """Encode a length-delimited field (wire type 2): tag, length, then DATA.""" + return tag(field_number, 2) + encode_varint(len(data)) + data + + +def as_signed_64(value): + """Interpret an unsigned 64-bit varint as a two's-complement signed integer.""" + return value - (1 << 64) if value >= (1 << 63) else value + + +# -------------------------------------------------------------------------- +# Request and response +# -------------------------------------------------------------------------- + +def build_request(bssids): + """Build the binary Apple WLOC request for a list of BSSID strings. + + The protobuf is an AppleWLoc message: field 2 is a repeated WifiDevice, and + each WifiDevice has field 1 = the BSSID string. Fields 3 and 4 are small + integers the real client sends (an unknown flag and a single-result flag, + both left 0 here so Apple returns every access point it can). The protobuf + is wrapped in a fixed frame: a start marker, three length-prefixed strings + (locale, the locationd identifier, a client version), and a 2-byte + big-endian payload length. + """ + wifi_devices = b"".join( + length_delimited(2, length_delimited(1, bssid.encode())) + for bssid in bssids) + payload = (wifi_devices + + tag(3, 0) + encode_varint(0) + + tag(4, 0) + encode_varint(0)) + frame = (b"\x00\x01\x00\x05en_US" + b"\x00\x13com.apple.locationd" + b"\x00\x0a8.1.12B411" + b"\x00\x00\x00\x01\x00\x00") + return frame + struct.pack(">H", len(payload)) + payload + + +def query_apple(request_bytes): + """POST REQUEST_BYTES to Apple's WLOC endpoint and return the raw response.""" + request = urllib.request.Request( + WLOC_URL, data=request_bytes, + headers={"User-Agent": APPLE_USER_AGENT, + "Content-Type": "application/x-www-form-urlencoded"}) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return response.read() + except urllib.error.HTTPError as error: + fail(f"Apple WLOC error {error.code}") + except urllib.error.URLError as error: + fail(f"network error: {error.reason}") + + +def parse_response(raw): + """Parse Apple's WLOC response into {normalized_bssid: (lat, lng)}. + + The body begins with a 10-byte prefix before the AppleWLoc protobuf. We walk + its fields, decoding each WifiDevice (field 2) into a BSSID and a location. + """ + buf = raw[10:] + located = {} + offset, end = 0, len(buf) + while offset < end: + key, offset = read_varint(buf, offset) + field_number, wire_type = key >> 3, key & 0x7 + if wire_type == 2: + length, offset = read_varint(buf, offset) + chunk = buf[offset:offset + length] + offset += length + if field_number == 2: + bssid, coords = parse_wifi_device(chunk) + if bssid and coords: + located[normalize_bssid(bssid)] = coords + elif wire_type == 0: + _, offset = read_varint(buf, offset) # skip a varint we don't use + else: + break # unexpected wire type; stop + return located + + +def parse_wifi_device(buf): + """Decode a WifiDevice message: field 1 is the BSSID, field 2 the location.""" + bssid = None + coords = None + offset, end = 0, len(buf) + while offset < end: + key, offset = read_varint(buf, offset) + field_number, wire_type = key >> 3, key & 0x7 + if wire_type == 2: + length, offset = read_varint(buf, offset) + chunk = buf[offset:offset + length] + offset += length + if field_number == 1: + bssid = chunk.decode(errors="replace") + elif field_number == 2: + coords = parse_location(chunk) + elif wire_type == 0: + _, offset = read_varint(buf, offset) + else: + break + return bssid, coords + + +def parse_location(buf): + """Decode a Location message: field 1 latitude, field 2 longitude. + + Both are int64 scaled by 1e8. Apple returns APPLE_UNKNOWN for an access + point it cannot place; those are dropped. Returns (lat, lng) or None. + """ + latitude = longitude = None + offset, end = 0, len(buf) + while offset < end: + key, offset = read_varint(buf, offset) + field_number, wire_type = key >> 3, key & 0x7 + if wire_type == 0: + value, offset = read_varint(buf, offset) + value = as_signed_64(value) + if field_number == 1: + latitude = value + elif field_number == 2: + longitude = value + elif wire_type == 2: + length, offset = read_varint(buf, offset) + offset += length + else: + break + if latitude is None or longitude is None: + return None + if latitude == APPLE_UNKNOWN or longitude == APPLE_UNKNOWN: + return None + return latitude * 1e-8, longitude * 1e-8 + + +# -------------------------------------------------------------------------- +# WiFi scan and position estimate +# -------------------------------------------------------------------------- + +def normalize_bssid(bssid): + """Canonicalize a BSSID to lowercase, zero-padded octets, for reliable matching.""" + return ":".join(octet.rjust(2, "0").lower() for octet in bssid.split(":")) + + +def scan_wifi(): + """Return a list of (normalized_bssid, signal_quality) for visible access points. + + Uses nmcli; SIGNAL is a 0-100 quality percentage used here only to weight the + position average (stronger means nearer). Replace this function to support a + system without NetworkManager. + """ + 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(): + # nmcli -t separates fields with colons and backslash-escapes the colons + # inside the BSSID; split on the first unescaped colon, then unescape. + parts = [p.replace("\\:", ":") + for p in re.split(r"(?<!\\):", line, maxsplit=1)] + if len(parts) != 2: + continue + signal, bssid = parts + bssid = normalize_bssid(bssid) + if not re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", bssid): + continue + try: + quality = int(signal) + except ValueError: + continue + access_points.append((bssid, quality)) + return access_points + + +def signal_weighted_centroid(access_points, located): + """Average the coordinates of access points we see and Apple located. + + Each is weighted by its signal quality, so nearer access points pull the + estimate toward them. Returns (lat, lng) or None when none matched. + """ + total_weight = latitude_sum = longitude_sum = 0.0 + for bssid, weight in access_points: + coords = located.get(bssid) + if coords: + latitude_sum += coords[0] * weight + longitude_sum += coords[1] * weight + total_weight += weight + if total_weight == 0: + return None + return latitude_sum / total_weight, longitude_sum / total_weight + + +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": NOMINATIM_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(): + access_points = scan_wifi() + if len(access_points) < 2: + fail(f"only {len(access_points)} access point(s) visible; need at least 2") + + # Query the strongest access points; Apple also returns nearby ones it knows. + access_points.sort(key=lambda ap: ap[1], reverse=True) + query_bssids = [bssid for bssid, _ in access_points[:MAX_ACCESS_POINTS]] + + located = parse_response(query_apple(build_request(query_bssids))) + position = signal_weighted_centroid(access_points, located) + if position is None: + # If this happens with many APs visible, Apple may want the BSSIDs in a + # different form (some clients strip leading zeros from each octet rather + # than padding them); adjust normalize_bssid and retry. + fail("Apple located none of the visible access points") + + lat, lng = position + result = {"lat": round(lat, 7), "lng": round(lng, 7)} + address = reverse_geocode(lat, lng) + if address: + result["address"] = address + print(json.dumps(result)) + + +if __name__ == "__main__": + main() 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() |
