aboutsummaryrefslogtreecommitdiff
path: root/examples/geolocation
diff options
context:
space:
mode:
Diffstat (limited to 'examples/geolocation')
-rw-r--r--examples/geolocation/README.org164
-rwxr-xr-xexamples/geolocation/apple-wps.py334
-rwxr-xr-xexamples/geolocation/google-geolocate.py190
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()