| 1 | pragma Singleton |
| 2 | import QtQuick |
| 3 | import Quickshell |
| 4 | import Quickshell.Io |
| 5 | |
| 6 | // Weather for the calendar popup: open-meteo (no API key) every 30 min, cached in ~/.cache/aurora/weather.json so a |
| 7 | // restart shows the last one at once. Place = Settings.weatherLoc: "" = by IP (ipinfo.io), "lat,lon", or a place name |
| 8 | // (open-meteo geocoding). Offline / overloaded -> retry in 2 min. QML timers stand still during suspend, so the popup |
| 9 | // calls poke() on open to catch up. |
| 10 | Singleton { |
| 11 | id: root |
| 12 | readonly property string cacheFile: Quickshell.env("HOME") + "/.cache/aurora/weather.json" |
| 13 | readonly property int every: 30 * 60 * 1000 |
| 14 | |
| 15 | property var now: null // {temp, feels, code, wind (km/h), humidity, rain (%), day} |
| 16 | property var days: [] // [{date, code, max, min, rain, rise, set}], today first; dates are local |
| 17 | property string place: "" // city, when the lookup gave one |
| 18 | property double fetched: 0 // Date.now() of the last good fetch |
| 19 | property string error: "" // why there is no weather |
| 20 | readonly property bool ok: now !== null |
| 21 | readonly property bool busy: where.running || get.running |
| 22 | |
| 23 | // coordinates and the Settings.weatherLoc they were resolved for |
| 24 | property string key: "" |
| 25 | property real lat: NaN |
| 26 | property real lon: NaN |
| 27 | |
| 28 | readonly property var names: ({ 0: "clear", 1: "mostly clear", 2: "partly cloudy", 3: "overcast", 45: "fog", 48: "fog", |
| 29 | 51: "drizzle", 53: "drizzle", 55: "drizzle", 56: "ice drizzle", 57: "ice drizzle", 61: "light rain", 63: "rain", |
| 30 | 65: "heavy rain", 66: "ice rain", 67: "ice rain", 71: "light snow", 73: "snow", 75: "heavy snow", 77: "snow grains", |
| 31 | 80: "showers", 81: "showers", 82: "storm showers", 85: "snow showers", 86: "snow showers", 95: "thunder", |
| 32 | 96: "thunder", 99: "thunder" }) |
| 33 | function name(code) { return names[code] !== undefined ? names[code] : "weather" } |
| 34 | function icon(code, day) { |
| 35 | if (code <= 1) return day === false ? Theme.iNight : Theme.iSunny |
| 36 | if (code === 2) return day === false ? Theme.iNightCloudy : Theme.iPartlyCloudy |
| 37 | if (code === 3) return Theme.iCloudy |
| 38 | if (code < 50) return Theme.iFog |
| 39 | if (code >= 95) return Theme.iThunder |
| 40 | if ((code >= 71 && code <= 77) || code === 85 || code === 86) return Theme.iSnow |
| 41 | if (code === 65 || code === 82) return Theme.iPouring |
| 42 | return Theme.iRain |
| 43 | } |
| 44 | // the entry for date d's day, null if the forecast doesn't cover it |
| 45 | function dayOf(d) { |
| 46 | for (var i = 0; i < days.length; i++) |
| 47 | if (days[i].date.toDateString() === d.toDateString()) return days[i] |
| 48 | return null |
| 49 | } |
| 50 | // up to n days after d |
| 51 | function after(d, n) { |
| 52 | var out = [], end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1) |
| 53 | for (var i = 0; i < days.length && out.length < n; i++) |
| 54 | if (days[i].date >= end) out.push(days[i]) |
| 55 | return out |
| 56 | } |
| 57 | |
| 58 | function refresh() { |
| 59 | if (busy) { later.restart(); return } |
| 60 | var want = (Settings.weatherLoc || "").trim() |
| 61 | if (want !== key || isNaN(lat)) resolve(want) |
| 62 | else pull() |
| 63 | } |
| 64 | function poke() { |
| 65 | if (!busy && Date.now() - fetched > every) refresh() |
| 66 | } |
| 67 | |
| 68 | function resolve(want) { |
| 69 | var m = want.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/) |
| 70 | if (m) { |
| 71 | key = want; lat = +m[1]; lon = +m[2]; place = "" |
| 72 | pull() |
| 73 | return |
| 74 | } |
| 75 | where.want = want |
| 76 | where.command = want === "" |
| 77 | ? ["curl", "-sf", "--max-time", "10", "https://ipinfo.io/json"] |
| 78 | : ["curl", "-sfG", "--max-time", "10", "--data-urlencode", "name=" + want, "-d", "count=1", |
| 79 | "https://geocoding-api.open-meteo.com/v1/search"] |
| 80 | where.running = true |
| 81 | } |
| 82 | Process { |
| 83 | id: where |
| 84 | property string want: "" |
| 85 | stdout: StdioCollector { |
| 86 | onStreamFinished: { |
| 87 | var j |
| 88 | try { j = JSON.parse(text) } catch (e) { return } |
| 89 | var w = where.want |
| 90 | if (w === "") { |
| 91 | var p = String(j.loc || "").split(",") |
| 92 | if (p.length !== 2 || isNaN(+p[0]) || isNaN(+p[1])) return |
| 93 | root.lat = +p[0]; root.lon = +p[1]; root.place = j.city || "" |
| 94 | } else { |
| 95 | if (!j.results || !j.results.length) { root.error = "no place called " + w; return } |
| 96 | var r = j.results[0] |
| 97 | root.lat = r.latitude; root.lon = r.longitude; root.place = r.name || w |
| 98 | } |
| 99 | root.key = w |
| 100 | root.pull() |
| 101 | } |
| 102 | } |
| 103 | onExited: code => { if (code !== 0) { root.error = "offline"; retry.restart() } } |
| 104 | } |
| 105 | |
| 106 | function pull() { |
| 107 | get.command = ["curl", "-sf", "--max-time", "15", "https://api.open-meteo.com/v1/forecast?latitude=" + lat + "&longitude=" + lon |
| 108 | + "¤t=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m,precipitation_probability,is_day" |
| 109 | + "&daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max" |
| 110 | + "&timezone=auto&forecast_days=6"] |
| 111 | get.running = true |
| 112 | } |
| 113 | Process { |
| 114 | id: get |
| 115 | stdout: StdioCollector { |
| 116 | onStreamFinished: { |
| 117 | var j |
| 118 | try { j = JSON.parse(text) } catch (e) { return } |
| 119 | if (!root.parse(j)) return |
| 120 | root.fetched = Date.now() |
| 121 | root.error = "" |
| 122 | cache.setText(JSON.stringify({ key: root.key, lat: root.lat, lon: root.lon, place: root.place, |
| 123 | fetched: root.fetched, raw: j })) |
| 124 | } |
| 125 | } |
| 126 | onExited: code => { if (code !== 0) { root.error = "offline"; retry.restart() } } |
| 127 | } |
| 128 | |
| 129 | // open-meteo's times have no offset ("2026-10-29T06:32", in the place's zone): JS reads them as local time |
| 130 | function parse(j) { |
| 131 | var c = j.current, d = j.daily |
| 132 | if (!c || !d || !d.time) return false |
| 133 | now = { temp: c.temperature_2m, feels: c.apparent_temperature, code: c.weather_code, wind: c.wind_speed_10m, |
| 134 | humidity: c.relative_humidity_2m, rain: c.precipitation_probability, day: c.is_day === 1 } |
| 135 | var out = [] |
| 136 | for (var i = 0; i < d.time.length; i++) |
| 137 | out.push({ date: new Date(d.time[i] + "T00:00"), code: d.weather_code[i], max: d.temperature_2m_max[i], |
| 138 | min: d.temperature_2m_min[i], rain: d.precipitation_probability_max[i], |
| 139 | rise: new Date(d.sunrise[i]), set: new Date(d.sunset[i]) }) |
| 140 | days = out |
| 141 | return true |
| 142 | } |
| 143 | |
| 144 | property bool started: false |
| 145 | FileView { |
| 146 | id: cache |
| 147 | path: root.cacheFile |
| 148 | printErrors: false |
| 149 | onLoaded: { |
| 150 | if (root.started) return |
| 151 | root.started = true |
| 152 | try { |
| 153 | var c = JSON.parse(text()) |
| 154 | if (root.parse(c.raw)) { |
| 155 | root.key = c.key; root.lat = c.lat; root.lon = c.lon; root.place = c.place || ""; root.fetched = c.fetched |
| 156 | } |
| 157 | } catch (e) {} |
| 158 | if (root.key !== (Settings.weatherLoc || "").trim() || Date.now() - root.fetched > root.every) root.refresh() |
| 159 | } |
| 160 | onLoadFailed: { if (!root.started) { root.started = true; root.refresh() } } |
| 161 | } |
| 162 | |
| 163 | Connections { |
| 164 | target: Settings |
| 165 | function onWeatherLocChanged() { if (root.started) root.refresh() } |
| 166 | } |
| 167 | Timer { id: retry; interval: 120000; onTriggered: root.refresh() } |
| 168 | Timer { id: later; interval: 3000; onTriggered: root.refresh() } |
| 169 | Timer { interval: root.every; running: true; repeat: true; onTriggered: root.refresh() } |
| 170 | } |