aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--README.org20
-rw-r--r--examples/geolocation/README.org164
-rwxr-xr-xexamples/geolocation/apple-wps.py334
-rwxr-xr-xexamples/geolocation/google-geolocate.py190
-rw-r--r--tests/test-wttrin--format-location-line.el35
-rw-r--r--tests/test-wttrin-geolocation-command.el161
-rw-r--r--tests/test-wttrin-geolocation-sentinel.el2
-rw-r--r--tests/test-wttrin-use-current-location.el73
-rw-r--r--wttrin-geolocation.el106
-rw-r--r--wttrin.el55
11 files changed, 1121 insertions, 21 deletions
diff --git a/.gitignore b/.gitignore
index 7c40cec..60f6dd2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,8 @@
*.elc
*-autoloads.el
*~
+__pycache__/
+*.pyc
# --- elisp ruleset ---
.claude/
diff --git a/README.org b/README.org
index c8c550f..81dcf89 100644
--- a/README.org
+++ b/README.org
@@ -273,13 +273,13 @@ If you don't want to type your city by hand, wttrin can detect it for you.
**Make the detected city your default:** in that weather buffer, press =d=. The detected city becomes =wttrin-favorite-location= (what the mode-line tracks). With =savehist-mode= on, the favorite persists across sessions automatically, since wttrin registers it with savehist. No =customize-save-variable= step is needed.
-**Always use my current location:**
+**Always use my current location:** run =M-x wttrin-use-current-location=, or set the variable directly:
#+begin_src emacs-lisp
(setq wttrin-favorite-location t)
#+end_src
-When set to =t=, wttrin runs the geolocation lookup once on first use (when the mode-line first fetches, when the buffer cache first refreshes, etc.) and caches the result for the rest of the session. The lookup happens in the background, so Emacs startup isn't blocked. The first display tick shows a placeholder until the lookup returns; everything proceeds normally after that.
+When set to =t=, wttrin runs the geolocation lookup once on first use (when the mode-line first fetches, when the buffer cache first refreshes, etc.) and caches the result for the rest of the session. The lookup happens in the background, so Emacs startup isn't blocked. The first display tick shows a placeholder until the lookup returns; everything proceeds normally after that. =M-x wttrin-use-current-location= is the labeled, confirmed way to choose this without typing the bare =t=.
The default lookup provider is =ipapi.co=. Two alternatives ship with the package, both free and key-less:
@@ -291,6 +291,22 @@ The default lookup provider is =ipapi.co=. Two alternatives ship with the packag
*Note:* IP-based geolocation can be wrong when you are behind a VPN or using a mobile hotspot. If you prefer, set =wttrin-favorite-location= directly to any city string that wttr.in understands.
+**Higher accuracy via an external command:** IP geolocation only finds your network's exit point, which on a VPN or cellular hotspot can be the wrong city or state. For a more accurate fix, point =wttrin-geolocation-command= at a command that returns your coordinates as JSON:
+
+#+begin_src emacs-lisp
+ (setq wttrin-geolocation-command "your-location-script --json")
+#+end_src
+
+The command runs asynchronously and must print a JSON object with numeric =lat= and =lng= keys. It may also include an =address= (or =label=) string; when present, wttrin shows it on a "Location:" line in the weather buffer so the resolved place is readable even though the weather is fetched by raw coordinates. Any other keys are ignored. For example:
+
+#+begin_src json
+ {"lat": 41.3222, "lng": -71.8113, "address": "Westerly, Rhode Island, USA"}
+#+end_src
+
+wttrin queries wttr.in by the coordinates and lets it echo the place name in its own header. A command that scans nearby WiFi access points and looks them up (far more accurate than IP) is the typical source. The package ships no command and assumes nothing about your system, so this is inert until you set it. If the command is unset, exits non-zero, or prints no usable coordinates, wttrin falls back to the IP provider above.
+
+Two ready-to-adapt example commands live in [[file:examples/geolocation/][examples/geolocation/]]: =google-geolocate.py= (Google Geolocation API, needs a key) and =apple-wps.py= (Apple's keyless WiFi positioning, which uses an undocumented endpoint — read its caveat). Both are Python 3 standard library, scan WiFi via =nmcli=, and print the JSON described above. See that directory's README for setup.
+
The older =M-x wttrin-set-location-from-geolocation= command still works but is deprecated in favor of the picker entry above.
**Turning geolocation off:** geolocation is on by default. To opt out — no "Current location" entry in the picker, no detection requests — set:
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()
diff --git a/tests/test-wttrin--format-location-line.el b/tests/test-wttrin--format-location-line.el
new file mode 100644
index 0000000..28a9af8
--- /dev/null
+++ b/tests/test-wttrin--format-location-line.el
@@ -0,0 +1,35 @@
+;;; test-wttrin--format-location-line.el --- Tests for the buffer Location line -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for `wttrin--format-location-line', the "Location: ADDRESS" line
+;; shown in the weather buffer when a geolocation command supplies an address.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+
+(ert-deftest test-wttrin--format-location-line-normal-builds-line ()
+ "Normal: a non-empty address becomes a \"Location: ...\" line."
+ (let ((line (wttrin--format-location-line "Westerly, Rhode Island, USA")))
+ (should (stringp line))
+ (should (string-prefix-p "Location: Westerly, Rhode Island, USA" line))))
+
+(ert-deftest test-wttrin--format-location-line-normal-uses-face ()
+ "Normal: the line carries the staleness-header face."
+ (let ((line (wttrin--format-location-line "Westerly, RI")))
+ (should (eq 'wttrin-staleness-header
+ (get-text-property 0 'face line)))))
+
+(ert-deftest test-wttrin--format-location-line-boundary-nil-returns-nil ()
+ "Boundary: a nil address yields no line."
+ (should (null (wttrin--format-location-line nil))))
+
+(ert-deftest test-wttrin--format-location-line-boundary-empty-returns-nil ()
+ "Boundary: an empty address yields no line."
+ (should (null (wttrin--format-location-line ""))))
+
+(provide 'test-wttrin--format-location-line)
+;;; test-wttrin--format-location-line.el ends here
diff --git a/tests/test-wttrin-geolocation-command.el b/tests/test-wttrin-geolocation-command.el
new file mode 100644
index 0000000..40ea278
--- /dev/null
+++ b/tests/test-wttrin-geolocation-command.el
@@ -0,0 +1,161 @@
+;;; test-wttrin-geolocation-command.el --- Tests for the external-command geolocation provider -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the generic external-command geolocation provider:
+;; `wttrin-geolocation--parse-coordinates' (pure JSON -> "LAT,LNG"), the
+;; routing/fallback in `wttrin-geolocation-detect' when
+;; `wttrin-geolocation-command' is set, and an end-to-end run of
+;; `wttrin-geolocation--detect-via-command' against a real shell command.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+(require 'wttrin-geolocation)
+
+;;; wttrin-geolocation--parse-coordinates
+
+(ert-deftest test-wttrin-geolocation-command-normal-parse-coordinates ()
+ "Normal: numeric lat/lng become a \"LAT,LNG\" string."
+ (should (equal "41.32,-71.81"
+ (wttrin-geolocation--parse-coordinates
+ "{\"lat\": 41.32, \"lng\": -71.81}"))))
+
+(ert-deftest test-wttrin-geolocation-command-boundary-parse-ignores-extra-keys ()
+ "Boundary: extra keys (accuracy, address) are ignored."
+ (should (equal "1.5,2.5"
+ (wttrin-geolocation--parse-coordinates
+ "{\"lat\":1.5,\"lng\":2.5,\"accuracy_m\":11.5,\"address\":\"x\"}"))))
+
+(ert-deftest test-wttrin-geolocation-command-error-parse-malformed-json ()
+ "Error: malformed JSON returns nil."
+ (should (null (wttrin-geolocation--parse-coordinates "not json")))
+ (should (null (wttrin-geolocation--parse-coordinates "")))
+ (should (null (wttrin-geolocation--parse-coordinates nil))))
+
+(ert-deftest test-wttrin-geolocation-command-error-parse-missing-or-nonnumeric ()
+ "Error: missing or non-numeric coordinates return nil."
+ (should (null (wttrin-geolocation--parse-coordinates "{\"lat\":1.0}")))
+ (should (null (wttrin-geolocation--parse-coordinates
+ "{\"lat\":\"x\",\"lng\":\"y\"}"))))
+
+;;; wttrin-geolocation--parse-address
+
+(ert-deftest test-wttrin-geolocation-command-normal-parse-address ()
+ "Normal: the address key is returned verbatim."
+ (should (equal "Westerly, Rhode Island, USA"
+ (wttrin-geolocation--parse-address
+ "{\"lat\":1.0,\"lng\":2.0,\"address\":\"Westerly, Rhode Island, USA\"}"))))
+
+(ert-deftest test-wttrin-geolocation-command-boundary-parse-address-label-synonym ()
+ "Boundary: a `label' key is accepted when `address' is absent."
+ (should (equal "Westerly, RI"
+ (wttrin-geolocation--parse-address
+ "{\"lat\":1.0,\"lng\":2.0,\"label\":\"Westerly, RI\"}"))))
+
+(ert-deftest test-wttrin-geolocation-command-error-parse-address-absent ()
+ "Error: no address or label, malformed JSON, or empty string returns nil."
+ (should (null (wttrin-geolocation--parse-address "{\"lat\":1.0,\"lng\":2.0}")))
+ (should (null (wttrin-geolocation--parse-address "not json")))
+ (should (null (wttrin-geolocation--parse-address
+ "{\"lat\":1.0,\"lng\":2.0,\"address\":\"\"}"))))
+
+;;; wttrin-geolocation-detect — routing and fallback
+
+(ert-deftest test-wttrin-geolocation-command-normal-detect-uses-command ()
+ "Normal: with a command set that succeeds, its coordinates are used."
+ (let ((got 'none)
+ (ip-called nil)
+ (wttrin-geolocation-command "ignored-in-mock"))
+ (cl-letf (((symbol-function 'wttrin-geolocation--detect-via-command)
+ (lambda (cb) (funcall cb "1.0,2.0")))
+ ((symbol-function 'wttrin-geolocation--detect-via-ip)
+ (lambda (cb) (setq ip-called t) (funcall cb "City, ST"))))
+ (wttrin-geolocation-detect (lambda (r &optional _a) (setq got r))))
+ (should (equal "1.0,2.0" got))
+ (should-not ip-called)))
+
+(ert-deftest test-wttrin-geolocation-command-boundary-detect-falls-back-to-ip ()
+ "Boundary: with a command that fails (nil), detection falls back to IP."
+ (let ((got 'none)
+ (wttrin-geolocation-command "ignored-in-mock"))
+ (cl-letf (((symbol-function 'wttrin-geolocation--detect-via-command)
+ (lambda (cb) (funcall cb nil)))
+ ((symbol-function 'wttrin-geolocation--detect-via-ip)
+ (lambda (cb) (funcall cb "City, ST"))))
+ (wttrin-geolocation-detect (lambda (r &optional _a) (setq got r))))
+ (should (equal "City, ST" got))))
+
+(ert-deftest test-wttrin-geolocation-command-boundary-detect-no-command-uses-ip ()
+ "Boundary: with no command set, the IP path runs directly."
+ (let ((got 'none)
+ (cmd-called nil)
+ (wttrin-geolocation-command nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation--detect-via-command)
+ (lambda (cb) (setq cmd-called t) (funcall cb "9.0,9.0")))
+ ((symbol-function 'wttrin-geolocation--detect-via-ip)
+ (lambda (cb) (funcall cb "City, ST"))))
+ (wttrin-geolocation-detect (lambda (r &optional _a) (setq got r))))
+ (should (equal "City, ST" got))
+ (should-not cmd-called)))
+
+;;; wttrin-geolocation--detect-via-command — real process
+
+(defun test-wttrin-geolocation-command--run-sync (command)
+ "Run `wttrin-geolocation--detect-via-command' with COMMAND, wait, return coords.
+The address (second callback argument) is ignored here; a separate test covers it."
+ (let ((result 'pending)
+ (wttrin-geolocation-command command))
+ (wttrin-geolocation--detect-via-command
+ (lambda (r &optional _a) (setq result r)))
+ (with-timeout (5 (error "detect-via-command timed out"))
+ (while (eq result 'pending)
+ (accept-process-output nil 0.05)))
+ result))
+
+(ert-deftest test-wttrin-geolocation-command-integration-real-command-success ()
+ "Integration: a command printing lat/lng JSON resolves to \"LAT,LNG\".
+Components: wttrin-geolocation--detect-via-command (real make-process),
+the shell (real), wttrin-geolocation--parse-coordinates (real)."
+ (should (equal "1.5,2.5"
+ (test-wttrin-geolocation-command--run-sync
+ "echo '{\"lat\":1.5,\"lng\":2.5}'"))))
+
+(ert-deftest test-wttrin-geolocation-command-integration-real-command-passes-address ()
+ "Integration: a command printing lat/lng plus an address passes both to the callback.
+Components: detect-via-command (real make-process), the shell, the coord and
+address parsers (real)."
+ (let ((coords 'pending)
+ (address 'pending)
+ (wttrin-geolocation-command
+ "echo '{\"lat\":1.5,\"lng\":2.5,\"address\":\"Westerly, RI\"}'"))
+ (wttrin-geolocation--detect-via-command
+ (lambda (c &optional a) (setq coords c address a)))
+ (with-timeout (5 (error "detect-via-command timed out"))
+ (while (eq coords 'pending)
+ (accept-process-output nil 0.05)))
+ (should (equal "1.5,2.5" coords))
+ (should (equal "Westerly, RI" address))))
+
+(ert-deftest test-wttrin-geolocation-command-integration-real-command-nonzero-exit ()
+ "Integration: a command exiting non-zero yields nil (caller falls back to IP)."
+ (should (null (test-wttrin-geolocation-command--run-sync "exit 1"))))
+
+(ert-deftest test-wttrin-geolocation-command-integration-real-command-bad-output ()
+ "Integration: a zero-exit command printing non-JSON yields nil."
+ (should (null (test-wttrin-geolocation-command--run-sync "echo not-json"))))
+
+(ert-deftest test-wttrin-geolocation-command-error-spawn-failure-yields-nil ()
+ "Error: when the process cannot spawn, the callback receives nil."
+ (let ((result 'pending)
+ (wttrin-geolocation-command "whatever"))
+ (cl-letf (((symbol-function 'make-process)
+ (lambda (&rest _) (error "spawn failed"))))
+ (wttrin-geolocation--detect-via-command (lambda (r) (setq result r))))
+ (should (null result))))
+
+(provide 'test-wttrin-geolocation-command)
+;;; test-wttrin-geolocation-command.el ends here
diff --git a/tests/test-wttrin-geolocation-sentinel.el b/tests/test-wttrin-geolocation-sentinel.el
index 0536173..61d8997 100644
--- a/tests/test-wttrin-geolocation-sentinel.el
+++ b/tests/test-wttrin-geolocation-sentinel.el
@@ -115,7 +115,7 @@ through `wttrin--query-selection' (smoke test of the entry wrapper)."
(cl-letf (((symbol-function 'wttrin-geolocation-detect)
(lambda (callback) (funcall callback "Austin, TX")))
((symbol-function 'wttrin-query)
- (lambda (loc) (setq captured loc)))
+ (lambda (loc &optional _address) (setq captured loc)))
((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin--query-selection wttrin--geolocation-sentinel))
(should (equal "Austin, TX" captured)))
diff --git a/tests/test-wttrin-use-current-location.el b/tests/test-wttrin-use-current-location.el
new file mode 100644
index 0000000..2b08448
--- /dev/null
+++ b/tests/test-wttrin-use-current-location.el
@@ -0,0 +1,73 @@
+;;; test-wttrin-use-current-location.el --- Tests for wttrin-use-current-location -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the `wttrin-use-current-location' command, the labeled way to
+;; set `wttrin-favorite-location' to t (always auto-detect) instead of typing
+;; the bare symbol. Mocks `yes-or-no-p' and `message'; touches no network.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+
+;;; Setup and Teardown
+
+(defvar test-wttrin-use-current-location--saved nil
+ "Snapshot of `wttrin-favorite-location' restored in teardown.")
+
+(defun test-wttrin-use-current-location-setup ()
+ "Snapshot `wttrin-favorite-location' and clear it."
+ (setq test-wttrin-use-current-location--saved wttrin-favorite-location)
+ (setq wttrin-favorite-location nil))
+
+(defun test-wttrin-use-current-location-teardown ()
+ "Restore `wttrin-favorite-location'."
+ (setq wttrin-favorite-location test-wttrin-use-current-location--saved))
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin-use-current-location-normal-confirm-sets-t ()
+ "Normal: confirming sets the favorite to t (auto-detect)."
+ (test-wttrin-use-current-location-setup)
+ (unwind-protect
+ (let ((wttrin-geolocation-enabled t))
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-use-current-location))
+ (should (eq t wttrin-favorite-location)))
+ (test-wttrin-use-current-location-teardown)))
+
+(ert-deftest test-wttrin-use-current-location-normal-decline-leaves-unchanged ()
+ "Normal: declining leaves the favorite untouched."
+ (test-wttrin-use-current-location-setup)
+ (setq wttrin-favorite-location "Berkeley, CA")
+ (unwind-protect
+ (let ((wttrin-geolocation-enabled t))
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) nil))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-use-current-location))
+ (should (equal "Berkeley, CA" wttrin-favorite-location)))
+ (test-wttrin-use-current-location-teardown)))
+
+;;; Boundary / Error Cases
+
+(ert-deftest test-wttrin-use-current-location-boundary-disabled-no-prompt-no-set ()
+ "Boundary: with geolocation disabled, no prompt and the favorite is unchanged."
+ (test-wttrin-use-current-location-setup)
+ (setq wttrin-favorite-location "Berkeley, CA")
+ (unwind-protect
+ (let ((prompted nil)
+ (wttrin-geolocation-enabled nil))
+ (cl-letf (((symbol-function 'yes-or-no-p)
+ (lambda (&rest _) (setq prompted t) t))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-use-current-location))
+ (should-not prompted)
+ (should (equal "Berkeley, CA" wttrin-favorite-location)))
+ (test-wttrin-use-current-location-teardown)))
+
+(provide 'test-wttrin-use-current-location)
+;;; test-wttrin-use-current-location.el ends here
diff --git a/wttrin-geolocation.el b/wttrin-geolocation.el
index a2c31aa..9f00b7f 100644
--- a/wttrin-geolocation.el
+++ b/wttrin-geolocation.el
@@ -70,6 +70,26 @@ can select them here."
(const :tag "ipwho.is" ipwhois)
(symbol :tag "Other (registered in wttrin-geolocation--providers)")))
+(defcustom wttrin-geolocation-command nil
+ "Optional shell command for higher-accuracy geolocation.
+When non-nil, `wttrin-geolocation-detect' runs this command and reads its
+standard output as JSON, expecting numeric `lat' and `lng' keys (any other
+keys are ignored). The detected location resolves to \"LAT,LNG\", which
+wttr.in accepts and echoes a place name for.
+
+This is the opt-in accuracy path. The command may do whatever the system
+supports (a WiFi scan, a GPS read) to beat IP geolocation, and it runs
+asynchronously so a multi-second lookup does not block Emacs. The package
+ships no command and assumes nothing about the OS or network stack, so it is
+inert until set.
+
+On any failure (the command is unset, exits non-zero, or prints no parseable
+lat/lng), wttrin falls back to the IP provider named by
+`wttrin-geolocation-provider'."
+ :group 'wttrin-geolocation
+ :type '(choice (const :tag "None (use IP provider)" nil)
+ (string :tag "Shell command")))
+
;;; Response Parsers
;;
;; Each parser takes a raw JSON string and returns "City, Region" or nil.
@@ -166,16 +186,34 @@ found. Intended for use inside a `url-retrieve' callback."
(buffer-substring-no-properties (point) (point-max))
'utf-8))))))
-(defun wttrin-geolocation-detect (callback)
- "Detect current location via the configured geolocation provider.
-CALLBACK is invoked asynchronously with a single argument: a
-\"City, Region\" string on success, or nil on any failure (network
-error, HTTP 4xx or 5xx, malformed response, missing fields, or
-provider-specific rate-limit signals).
-
-The provider is selected by `wttrin-geolocation-provider'. Signals
-an error synchronously if that value is not registered in
-`wttrin-geolocation--providers'."
+(defun wttrin-geolocation--parse-coordinates (json-string)
+ "Parse JSON-STRING for numeric `lat'/`lng'; return \"LAT,LNG\" or nil.
+Any keys beyond `lat' and `lng' are ignored. Returns nil on malformed
+JSON or missing/non-numeric coordinates. wttr.in accepts the \"LAT,LNG\"
+form directly and echoes a place name for it."
+ (let ((data (wttrin-geolocation--decode-json json-string)))
+ (when data
+ (let ((lat (cdr (assq 'lat data)))
+ (lng (cdr (assq 'lng data))))
+ (when (and (numberp lat) (numberp lng))
+ (format "%s,%s" lat lng))))))
+
+(defun wttrin-geolocation--parse-address (json-string)
+ "Return the `address' (or `label') string from JSON-STRING, or nil.
+A command may include a human-readable place name alongside its
+coordinates; wttrin shows it in the weather buffer. Returns nil on
+malformed JSON or a missing or empty value."
+ (let ((data (wttrin-geolocation--decode-json json-string)))
+ (when data
+ (let ((address (or (cdr (assq 'address data))
+ (cdr (assq 'label data)))))
+ (when (and (stringp address) (> (length address) 0))
+ address)))))
+
+(defun wttrin-geolocation--detect-via-ip (callback)
+ "Detect location via the IP provider; invoke CALLBACK with the result.
+CALLBACK receives a \"City, Region\" string on success or nil on failure.
+The provider is selected by `wttrin-geolocation-provider'."
(let* ((provider (wttrin-geolocation--lookup-provider
wttrin-geolocation-provider))
(url (plist-get provider :url))
@@ -192,5 +230,53 @@ an error synchronously if that value is not registered in
(ignore-errors (kill-buffer (current-buffer)))
(funcall callback result))))))
+(defun wttrin-geolocation--detect-via-command (callback)
+ "Run `wttrin-geolocation-command' asynchronously; invoke CALLBACK with result.
+CALLBACK is called with (COORDS ADDRESS): COORDS is a \"LAT,LNG\" string when
+the command exits zero and prints JSON with numeric `lat'/`lng', and ADDRESS
+is the optional human-readable place name from the JSON (nil when absent).
+On any failure (spawn error, non-zero exit, or unparseable output) CALLBACK
+is called with nil."
+ (let ((output ""))
+ (condition-case nil
+ (make-process
+ :name "wttrin-geolocation"
+ :command (list shell-file-name shell-command-switch
+ wttrin-geolocation-command)
+ :connection-type 'pipe
+ :noquery t
+ :filter (lambda (_proc chunk) (setq output (concat output chunk)))
+ :sentinel (lambda (proc _event)
+ (when (memq (process-status proc) '(exit signal))
+ (let* ((ok (eq (process-exit-status proc) 0))
+ (coords (and ok (wttrin-geolocation--parse-coordinates
+ output)))
+ (address (and coords
+ (wttrin-geolocation--parse-address
+ output))))
+ (funcall callback coords address)))))
+ (error (funcall callback nil)))))
+
+(defun wttrin-geolocation-detect (callback)
+ "Detect current location; invoke CALLBACK asynchronously with the result.
+CALLBACK is called with (LOCATION &optional ADDRESS). LOCATION is a location
+string (\"City, Region\" from an IP provider, or \"LAT,LNG\" from a command) on
+success, or nil on any failure. ADDRESS is the optional human-readable place
+name a command may supply alongside coordinates (always nil on the IP path).
+Callers that only need the location may accept a single argument.
+
+When `wttrin-geolocation-command' is non-nil, run it first; on success its
+coordinates (and address, if any) are used, and on failure detection falls back
+to the IP provider named by `wttrin-geolocation-provider'. When no command is
+set, the IP provider is used directly. Signals an error synchronously if
+`wttrin-geolocation-provider' is not registered and the IP path is reached."
+ (if wttrin-geolocation-command
+ (wttrin-geolocation--detect-via-command
+ (lambda (coords &optional address)
+ (if coords
+ (funcall callback coords address)
+ (wttrin-geolocation--detect-via-ip callback))))
+ (wttrin-geolocation--detect-via-ip callback)))
+
(provide 'wttrin-geolocation)
;;; wttrin-geolocation.el ends here
diff --git a/wttrin.el b/wttrin.el
index d628932..da2881b 100644
--- a/wttrin.el
+++ b/wttrin.el
@@ -236,7 +236,7 @@ call retries."
(setq wttrin--favorite-location-pending t)
(require 'wttrin-geolocation)
(wttrin-geolocation-detect
- (lambda (location)
+ (lambda (location &optional _address)
(setq wttrin--favorite-location-pending nil)
(when location
(setq wttrin--resolved-favorite-location location)
@@ -631,9 +631,9 @@ can fall back to typing a city in the picker."
(require 'wttrin-geolocation)
(message "Detecting location...")
(wttrin-geolocation-detect
- (lambda (location)
+ (lambda (location &optional address)
(if location
- (wttrin-query location)
+ (wttrin-query location address)
(message "Could not detect location (network or provider error)"))))))
(defun wttrin--query-selection (selection)
@@ -765,10 +765,20 @@ Looks up the cache timestamp for LOCATION and formats a line like
(propertize (format "Last updated: %s (%s)" (string-trim time-str) age-str)
'face 'wttrin-staleness-header)))))
-(defun wttrin--display-weather (location-name raw-string &optional error-msg)
+(defun wttrin--format-location-line (address)
+ "Return a propertized \"Location: ADDRESS\" line, or nil when ADDRESS is empty.
+Shown in the weather buffer when a geolocation command supplied a human-readable
+place name alongside its coordinates, so the resolved location is recognizable
+even though the weather was fetched by raw coordinates."
+ (when (and (stringp address) (> (length address) 0))
+ (propertize (concat "Location: " address) 'face 'wttrin-staleness-header)))
+
+(defun wttrin--display-weather (location-name raw-string &optional error-msg address)
"Display weather data RAW-STRING for LOCATION-NAME in weather buffer.
When ERROR-MSG is provided and data is invalid, show that instead of
-the generic error message."
+the generic error message. When ADDRESS is non-empty, show it on a
+\"Location:\" line below the weather (used by the geolocation command path,
+which fetches by coordinates but can name the place)."
(when wttrin-debug
(wttrin--save-debug-data location-name raw-string))
@@ -795,6 +805,9 @@ the generic error message."
(goto-char (point-min))
(when (re-search-forward "^Weather report: .*$" nil t)
(replace-match (concat "Weather report: " location-name)))
+ (let ((location-line (wttrin--format-location-line address)))
+ (when location-line
+ (insert "\n" location-line)))
(let ((staleness (wttrin--format-staleness-header location-name)))
(when staleness
(insert "\n" staleness)))
@@ -804,8 +817,11 @@ the generic error message."
(setq-local wttrin--current-location location-name)
(wttrin--debug-mode-line-info))))
-(defun wttrin-query (location-name)
- "Asynchronously query weather of LOCATION-NAME, display result when ready."
+(defun wttrin-query (location-name &optional address)
+ "Asynchronously query weather of LOCATION-NAME, display result when ready.
+LOCATION-NAME is what weather is fetched by (and the cache key). Optional
+ADDRESS is a human-readable place name shown on a \"Location:\" line, used when
+LOCATION-NAME is raw coordinates from a geolocation command."
(let ((buffer (get-buffer-create (format "*wttr.in*"))))
(switch-to-buffer buffer)
(setq buffer-read-only nil)
@@ -817,7 +833,7 @@ the generic error message."
(lambda (raw-string &optional error-msg)
(when (buffer-live-p buffer)
(with-current-buffer buffer
- (wttrin--display-weather location-name raw-string error-msg)))))))
+ (wttrin--display-weather location-name raw-string error-msg address)))))))
(defun wttrin--make-cache-key (location)
"Create cache key from LOCATION and current settings."
@@ -921,6 +937,29 @@ the detected city as your default."
"use the \"Current location (detect)\" entry in `wttrin', then press `d' to keep it as the default."
"0.4.0")
+;;;###autoload
+(defun wttrin-use-current-location ()
+ "Make your current location the persistent favorite (always auto-detect).
+Sets `wttrin-favorite-location' to t after confirmation, so the mode-line
+and buffer track wherever you are via geolocation rather than a fixed city.
+This is the labeled way to choose auto-detect without typing the bare symbol
+t into your init.
+
+With `savehist-mode' on, the choice persists across sessions automatically.
+Does nothing when `wttrin-geolocation-enabled' is nil."
+ (interactive)
+ (cond
+ ((not wttrin-geolocation-enabled)
+ (message "Geolocation is disabled (set wttrin-geolocation-enabled to enable it)"))
+ ((yes-or-no-p "Always use your current location (auto-detect via geolocation)? ")
+ (setq wttrin-favorite-location t)
+ (message "Favorite location set to auto-detect%s"
+ (if (bound-and-true-p savehist-mode)
+ " (persisted via savehist)."
+ ". Enable savehist-mode to persist it across sessions.")))
+ (t
+ (message "Cancelled"))))
+
(defvar-local wttrin--current-location nil
"Current location displayed in this weather buffer.")