Krolyxon
Commit b101de2

Branches

Refs →

aurora.configquickshellauroraWeather.qml

171 lines · 7.7 KB · QML

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