diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-25 16:03:18 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-25 16:03:18 -0400 |
| commit | 7027cccea9eb7170ea0f08e1def3f979f2e59932 (patch) | |
| tree | 51752f8cac0d92b4442048d913272e923211b619 /wttrin-geolocation.el | |
| parent | 33621b5a6e5407da190767b89756e287698ef234 (diff) | |
| download | emacs-wttrin-7027cccea9eb7170ea0f08e1def3f979f2e59932.tar.gz emacs-wttrin-7027cccea9eb7170ea0f08e1def3f979f2e59932.zip | |
feat: add external-command geolocation, opt-out, and example adapters
These build on the current-location picker with the rest of the geolocation work.
wttrin-geolocation-command runs a command that prints {lat,lng} (and optionally an address) and queries wttr.in by those coordinates. IP geolocation only finds the network's exit point, which is wrong on a VPN or hotspot. A command that scans nearby WiFi resolves to street level. It runs asynchronously, falls back to the IP provider when unset or failing, and assumes nothing about the OS, so it's inert until set. When the command returns an address, wttrin shows it on a "Location:" line, so the resolved place is readable even though the fetch is by raw coordinates.
wttrin-use-current-location is a labeled command that sets the favorite to auto-detect, so the bare t value never has to be typed into init by hand.
wttrin-geolocation-enabled (default t) turns every geolocation surface off for anyone who wants that: the picker entry, the auto-detect favorite, and the command.
examples/geolocation/ ships two reference adapters for the command: google-geolocate.py (Google API, key via the environment or ~/.authinfo.gpg) and apple-wps.py (Apple's keyless WiFi positioning, which uses an undocumented endpoint, so read its caveat). Both are Python 3 standard library and scan via nmcli, with notes on adapting the scan to other systems.
Diffstat (limited to 'wttrin-geolocation.el')
| -rw-r--r-- | wttrin-geolocation.el | 106 |
1 files changed, 96 insertions, 10 deletions
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 |
