From 17d8ba0b1a0d0822e4026dd956582cf757d8055d Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 2 Aug 2026 11:39:47 +0100 Subject: [PATCH] launcher: answer weather queries from open-meteo instead of the LLM Co-Authored-By: Claude Opus 5 --- settings/quickshell.nix | 169 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 162 insertions(+), 7 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 26c9f51..17a09c3 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -269,6 +269,82 @@ in fred@10.0.0.1 qs-ask 2>/dev/null ''; + # Launcher weather. Neither of the other two answer paths can serve + # this: qs-ask runs with no tools at all, so Claude has no live data and + # correctly refuses, and Wikipedia answers "weather in kristiansand" + # with the city's founding date. open-meteo needs no key — geocode the + # place name, then read current conditions at those coordinates. + # + # An empty place (a bare "weather now") falls back to the same IP + # geolocation the calendar widget uses, so the common case needs no + # place name at all. + # + # Prints one finished sentence, or nothing when the place doesn't + # resolve or either call fails — which the QML reads as "no answer", + # exactly like the other two scripts. + wxFetchScript = pkgs.writeShellScript "wx-fetch" '' + place="$1" + jq="${pkgs.jq}/bin/jq" + + if [ -n "$place" ]; then + geo=$(${pkgs.curl}/bin/curl -sf --max-time 5 -G \ + --data-urlencode "name=$place" \ + 'https://geocoding-api.open-meteo.com/v1/search?count=1&language=en&format=json' 2>/dev/null) + [ -z "$geo" ] && exit 0 + lat=$(printf '%s' "$geo" | $jq -r '.results[0].latitude // empty' 2>/dev/null) + lon=$(printf '%s' "$geo" | $jq -r '.results[0].longitude // empty' 2>/dev/null) + # Name it back the way open-meteo spells it ("Kristiansand, Norway"), + # which doubles as confirmation that it resolved what was meant. + where=$(printf '%s' "$geo" \ + | $jq -r '[.results[0].name, .results[0].country] | map(select(. != null)) | join(", ")' 2>/dev/null) + else + loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true) + case "$loc" in + *,*) lat=''${loc%,*}; lon=''${loc#*,}; where="Here" ;; + esac + fi + [ -z "$lat" ] && exit 0 + + wx=$(${pkgs.curl}/bin/curl -sf --max-time 6 \ + "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon¤t=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto" 2>/dev/null) + [ -z "$wx" ] && exit 0 + + # One jq pass, four fields, split on whitespace — every value is a + # number, so there is nothing here for word splitting to break. + cur=$(printf '%s' "$wx" \ + | $jq -r '.current | "\(.weather_code) \(.temperature_2m) \(.apparent_temperature) \(.wind_speed_10m)"' 2>/dev/null) + case "$cur" in ""|*null*) exit 0 ;; esac + set -- $cur + code="$1"; temp="$2"; feels="$3"; wind="$4" + + # WMO weather codes, grouped to the distinctions worth a word. + case "$code" in + 0) desc="Clear" ;; + 1) desc="Mainly clear" ;; + 2) desc="Partly cloudy" ;; + 3) desc="Overcast" ;; + 45|48) desc="Fog" ;; + 51|53|55) desc="Drizzle" ;; + 56|57) desc="Freezing drizzle" ;; + 61|63|65) desc="Rain" ;; + 66|67) desc="Freezing rain" ;; + 71|73|75) desc="Snow" ;; + 77) desc="Snow grains" ;; + 80|81|82) desc="Rain showers" ;; + 85|86) desc="Snow showers" ;; + 95) desc="Thunderstorm" ;; + 96|99) desc="Thunderstorm with hail" ;; + *) desc="" ;; + esac + + # "right now" is not filler: the launcher strips day words off the + # query so the place still geocodes, so a query asking about tomorrow + # gets today's numbers and has to be able to see that it did. + [ -n "$desc" ] && desc="$desc, " + printf '%s right now: %s%s°C (feels %s°C), wind %s km/h' \ + "$where" "$desc" "$temp" "$feels" "$wind" + ''; + # cava in raw mode: one newline-terminated frame per tick, bars as # semicolon-separated 0-100 ints — trivially parsed by SplitParser. # Drives the media card's spectrum ring. Only spawned while the @@ -778,6 +854,7 @@ in readonly property string fd: "${pkgs.fd}/bin/fd" readonly property string answerFetch: "${answerFetchScript}" readonly property string askFetch: "${askFetchScript}" + readonly property string wxFetch: "${wxFetchScript}" readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open" } ''; @@ -1725,6 +1802,12 @@ in property string answerSource: "" property bool answerDropNext: false property bool askDropNext: false + property bool wxDropNext: false + // Whose answer the card is still waiting on, if any. Only + // the slow paths count: Wikipedia lands inside the debounce + // and would just flicker the spinner. + readonly property bool answerPending: askProc.running || wxProc.running + readonly property string pendingSource: wxProc.running ? "open-meteo" : "Claude" // Claude answered the current query, so the slower-arriving // Wikipedia result must not overwrite it. property bool askWon: false @@ -1769,9 +1852,34 @@ in const q = raw.trim(); if (q === "" || calcResult !== "") return false; if (q.indexOf("?") !== -1) return true; + // "weather" on its own is one short word, which the + // length rule below would throw out. + if (wantsWeather(q)) return true; return q.split(/\s+/).length >= 2 && q.length >= 8; } + function wantsWeather(q) { + return /\b(weather|forecast)\b/i.test(q); + } + + // "weather", "weather in kristiansand", "what's the weather + // like right now" — the same query with a varying amount of + // noise around an optional place name. Strip the noise and + // whatever survives is the place, or nothing at all, which + // wx-fetch reads as "wherever this machine is". + // + // The day words go too, even though only current conditions + // come back: leaving "tomorrow" in sends "oslo tomorrow" to + // the geocoder, which resolves nothing and answers with a + // blank card. Current conditions are the wrong answer to + // "tomorrow", so wx-fetch labels them "right now" rather + // than letting them pass as a forecast. + function weatherPlace(q) { + return q.toLowerCase() + .replace(/\b(what'?s|what|is|it|the|weather|forecast|temperature|temp|like|in|for|at|right|now|today|tonight|tomorrow|this|week|weekend|currently|outside)\b/g, " ") + .replace(/\s+/g, " ").trim(); + } + // The "?" is a trigger, not a search term. function answerKey(s) { return s.replace(/\?/g, " ").replace(/\s+/g, " ").trim(); @@ -1801,6 +1909,13 @@ in } } + Process { + id: wxProc + stdout: StdioCollector { + onStreamFinished: launcherPanel.takeWx(text) + } + } + function killAnswer() { if (!answerProc.running) return; answerDropNext = true; @@ -1813,10 +1928,17 @@ in askProc.running = false; } + function killWx() { + if (!wxProc.running) return; + wxDropNext = true; + wxProc.running = false; + } + function clearAnswer() { answerTimer.stop(); killAnswer(); killAsk(); + killWx(); askWon = false; answerFact = ""; answerLead = ""; @@ -1832,6 +1954,19 @@ in return; } answerQuery = q; + // Weather takes the open-meteo path alone: Claude has no + // network to look it up with, Wikipedia answers with the + // city's founding date, and the cache has no notion of + // age so it must not serve a temperature twice. + if (wantsWeather(q)) { + killAsk(); + killAnswer(); + killWx(); + askWon = false; + wxProc.command = [Commands.wxFetch, weatherPlace(q)]; + wxProc.running = true; + return; + } const hit = answerCache[q]; if (hit !== undefined) { showAnswer(hit); @@ -1874,6 +2009,23 @@ in showAnswer(res); } + function takeWx(raw) { + if (wxDropNext) { wxDropNext = false; return; } + const text = raw.trim(); + // Place didn't resolve, or open-meteo was unreachable. + if (text === "") return; + if (answerQuery !== answerKey(searchInput.text)) return; + // Deliberately not cached — a temperature is stale in + // minutes and the cache never expires. + showAnswer({ + title: "", + fact: text, + lead: "", + url: "", + source: "open-meteo" + }); + } + function cacheAnswer(res) { if (Object.keys(answerCache).length > 40) answerCache = ({}); answerCache[answerQuery] = res; @@ -2415,10 +2567,10 @@ in height: answerCol.height + 28 radius: Theme.radiusCard color: Theme.cardBg - // Also up while Claude's call is out with nothing - // on screen yet, so the spinner has somewhere to + // Also up while a call is out with nothing on + // screen yet, so the spinner has somewhere to // live during the first second. - visible: launcherPanel.answerFact !== "" || askProc.running + visible: launcherPanel.answerFact !== "" || askProc.running || wxProc.running Column { id: answerCol @@ -2481,7 +2633,7 @@ in spacing: 5 SIcon { anchors.verticalCenter: parent.verticalCenter - visible: askProc.running + visible: launcherPanel.answerPending text: "loader-circle" font.pixelSize: 11 color: Theme.base03 @@ -2489,13 +2641,16 @@ in from: 0; to: 360 duration: 900 loops: Animation.Infinite - running: askProc.running + running: launcherPanel.answerPending } } SText { anchors.verticalCenter: parent.verticalCenter - text: askProc.running - ? (launcherPanel.answerSource === "" ? "Claude" : launcherPanel.answerSource + " · Claude") + // Name what is still coming, next to + // whatever is already on screen. + text: launcherPanel.answerPending + ? (launcherPanel.answerSource === "" ? launcherPanel.pendingSource + : launcherPanel.answerSource + " · " + launcherPanel.pendingSource) : launcherPanel.answerSource font.pixelSize: 10 color: Theme.base03