diff options
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() |
