From f1d5ba82472386055e460e846899bb121837476b Mon Sep 17 00:00:00 2001 From: loraque Date: Fri, 11 Sep 2026 18:33:39 -0500 Subject: [PATCH] Show Open-Meteo air quality in the weather panel Add an AIR cell next to humid when the air-quality API returns US or European AQI. Reuse the same stored or wttr coordinates as the daily forecast fetch. The cell hides when there is nothing to show. --- manual/05-the-top-bar.md | 2 +- shell/plugins/panels/weather/Model.js | 50 +++++++++++ shell/plugins/panels/weather/Panel.qml | 115 ++++++++++++++++++++++--- test/shell.d/weather-test.sh | 41 +++++++++ 4 files changed, 195 insertions(+), 13 deletions(-) diff --git a/manual/05-the-top-bar.md b/manual/05-the-top-bar.md index 09a028986f4..97a6b24d6b5 100644 --- a/manual/05-the-top-bar.md +++ b/manual/05-the-top-bar.md @@ -19,7 +19,7 @@ Nearly every widget does something on left, right, and middle click, and several | Menu | Omarchy menu | New terminal | — | | Workspaces | Focus that workspace | — | — | | Clock | Calendar popup | Cycle the label format | Middle: timezone picker | -| Weather | Forecast popup | Full weather as a notification | Middle: refresh | +| Weather | Forecast popup (with air quality when available) | Full weather as a notification | Middle: refresh | | Audio | Audio panel | Mute | Middle: panel · scroll: volume | | Microphone | Mute the mic | — | Middle: audio panel · scroll: input volume | | Network | Network panel | — | — | diff --git a/shell/plugins/panels/weather/Model.js b/shell/plugins/panels/weather/Model.js index 6562f83d0ac..5e32efd350d 100644 --- a/shell/plugins/panels/weather/Model.js +++ b/shell/plugins/panels/weather/Model.js @@ -192,6 +192,53 @@ function weatherResponseCompletesSave(hasConfiguredCoordinates, source) { return hasConfiguredCoordinates ? source === "open-meteo" : source === "wttr" } +// Shared lat/lon for Open-Meteo forecast and air-quality fetches: stored +// coordinates win, otherwise the wttr nearest_area (auto-detect). +function openMeteoCoordinates(configuredLatitude, configuredLongitude, area) { + var lat = parseFloat(String(configuredLatitude)) + var lon = parseFloat(String(configuredLongitude)) + if (!isNaN(lat) && !isNaN(lon)) return { lat: lat, lon: lon } + if (!area) return null + lat = parseFloat(String(area.latitude || "")) + lon = parseFloat(String(area.longitude || "")) + if (isNaN(lat) || isNaN(lon)) return null + return { lat: lat, lon: lon } +} + +function aqiBand(value, scale) { + var n = parseFloat(value) + if (isNaN(n)) return null + n = Math.round(n) + if (scale === "us") { + if (n <= 50) return { label: "Good", level: 0, value: n } + if (n <= 100) return { label: "Moderate", level: 1, value: n } + if (n <= 150) return { label: "Sensitive", level: 2, value: n } + if (n <= 200) return { label: "Unhealthy", level: 3, value: n } + if (n <= 300) return { label: "Very unhealthy", level: 4, value: n } + return { label: "Hazardous", level: 5, value: n } + } + if (n <= 20) return { label: "Good", level: 0, value: n } + if (n <= 40) return { label: "Fair", level: 1, value: n } + if (n <= 60) return { label: "Moderate", level: 2, value: n } + if (n <= 80) return { label: "Poor", level: 3, value: n } + if (n <= 100) return { label: "Very poor", level: 4, value: n } + return { label: "Extreme", level: 5, value: n } +} + +function parseAirQuality(raw) { + try { + var data = JSON.parse(String(raw || "{}")) + var current = data.current + if (!current) return null + var us = aqiBand(current.us_aqi, "us") + var eu = aqiBand(current.european_aqi, "eu") + if (!us && !eu) return null + return { us: us, eu: eu } + } catch (e) { + return null + } +} + function wttrNextForecastDays(report, todayString) { var days = report && report.weather ? report.weather : [] var result = [] @@ -285,6 +332,9 @@ if (typeof module !== "undefined") { currentIcon: currentIcon, provisionalCurrentIcon: provisionalCurrentIcon, weatherResponseCompletesSave: weatherResponseCompletesSave, + openMeteoCoordinates: openMeteoCoordinates, + aqiBand: aqiBand, + parseAirQuality: parseAirQuality, wttrNextForecastDays: wttrNextForecastDays, buildForecastDays: buildForecastDays, bareTempForDay: bareTempForDay, diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index d9d43b684b7..d23cdee6d0e 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -72,6 +72,7 @@ Panel { // Parsed wttr.in j1 response. Kept on failure so stale data stays visible. property var report: null property var dailyForecastReport: null + property var airQualityReport: null property string wttrLocation: "" // Configured location, read from the weather.json state file (owned by @@ -89,8 +90,10 @@ Panel { if (savingLocation) savingLocationQueryStarted = true forecastRetries = 0 dailyForecastRetries = 0 + airQualityRetries = 0 forecastProc.running = false dailyForecastProc.running = false + airQualityProc.running = false Qt.callLater(refresh) } @@ -115,6 +118,7 @@ Panel { property int forecastRetries: 0 property int dailyForecastRetries: 0 + property int airQualityRetries: 0 // Click-to-edit state for the location label. property bool editingLocation: false @@ -148,6 +152,19 @@ Panel { readonly property string reportFeels: current ? formatTemp(useImperial ? current.FeelsLikeF : current.FeelsLikeC) : "" readonly property string reportWind: current ? (useImperial ? (current.windspeedMiles + " mph") : (current.windspeedKmph + " km/h")) : "" readonly property string reportHumidity: current ? (current.humidity + "%") : "" + readonly property var airAqi: { + var report = airQualityReport + if (!report) return null + return useImperial ? (report.us || report.eu) : (report.eu || report.us) + } + readonly property string reportAqi: airAqi ? (airAqi.label + " " + airAqi.value) : "—" + readonly property bool airAqiAlarming: !!(airAqi && airAqi.level >= 3) + + function forecastArea(sourceReport) { + return sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] + ? sourceReport.nearest_area[0] + : root.areaInfo + } function refresh() { // Each full refresh cycle gets a fresh retry budget, so an earlier @@ -155,30 +172,29 @@ Panel { // starve retries for the rest of the session. forecastRetries = 0 dailyForecastRetries = 0 + airQualityRetries = 0 if (!forecastProc.running) forecastProc.running = true if (root.locationQuery === "" && !locationProc.running) locationProc.running = true // With stored coordinates this fetches open-meteo right away — no need // to wait for the slow wttr response. Without them it's a no-op until // wttr reports the detected area. refreshDailyForecast(null) + refreshAirQuality(null) } function refreshDailyForecast(sourceReport) { if (dailyForecastProc.running) return - var lat = parseFloat(String(root.configuredLocationState.latitude)) - var lon = parseFloat(String(root.configuredLocationState.longitude)) - if (isNaN(lat) || isNaN(lon)) { - var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo - if (!area) return - lat = parseFloat(String(area.latitude || "")) - lon = parseFloat(String(area.longitude || "")) - } - if (isNaN(lat) || isNaN(lon)) return + var coords = Model.openMeteoCoordinates( + root.configuredLocationState.latitude, + root.configuredLocationState.longitude, + root.forecastArea(sourceReport) + ) + if (!coords) return var url = "https://api.open-meteo.com/v1/forecast" - + "?latitude=" + encodeURIComponent(String(lat)) - + "&longitude=" + encodeURIComponent(String(lon)) + + "?latitude=" + encodeURIComponent(String(coords.lat)) + + "&longitude=" + encodeURIComponent(String(coords.lon)) + "&daily=weather_code,temperature_2m_max,temperature_2m_min" + "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code,is_day" + "&forecast_days=4" @@ -187,6 +203,25 @@ Panel { dailyForecastProc.running = true } + function refreshAirQuality(sourceReport) { + if (airQualityProc.running) return + + var coords = Model.openMeteoCoordinates( + root.configuredLocationState.latitude, + root.configuredLocationState.longitude, + root.forecastArea(sourceReport) + ) + if (!coords) return + + var url = "https://air-quality-api.open-meteo.com/v1/air-quality" + + "?latitude=" + encodeURIComponent(String(coords.lat)) + + "&longitude=" + encodeURIComponent(String(coords.lon)) + + "¤t=us_aqi,european_aqi" + + "&timezone=auto" + airQualityProc.command = ["curl", "-fsS", "--max-time", "5", url] + airQualityProc.running = true + } + // ---- Location editing. Clicking the location label swaps it for a search // field; picking a geocoded suggestion persists name + coordinates to // the module's shell.json entry. An empty commit returns to auto. @@ -351,8 +386,10 @@ Panel { root.finishSavingLocation() // Stored coordinates already drove the fast open-meteo fetch from // refresh(); only auto-detect needs the area wttr reported. - if (isNaN(parseFloat(String(root.configuredLocationState.latitude)))) + if (isNaN(parseFloat(String(root.configuredLocationState.latitude)))) { root.refreshDailyForecast(parsed) + root.refreshAirQuality(parsed) + } } catch (e) { // Keep last-good report visible, but try again shortly. root.scheduleForecastRetry() @@ -390,6 +427,39 @@ Panel { onTriggered: root.refreshDailyForecast(null) } + function scheduleAirQualityRetry() { + if (airQualityRetries >= 3) return + airQualityRetries++ + airQualityRetryTimer.restart() + } + + Timer { + id: airQualityRetryTimer + interval: 2500 + onTriggered: root.refreshAirQuality(null) + } + + Process { + id: airQualityProc + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var raw = String(text || "").trim() + if (!raw) { + root.scheduleAirQualityRetry() + return + } + var parsed = Model.parseAirQuality(raw) + if (!parsed) { + root.scheduleAirQualityRetry() + return + } + root.airQualityReport = parsed + root.airQualityRetries = 0 + } + } + } + Process { id: dailyForecastProc stdout: StdioCollector { @@ -446,8 +516,10 @@ Panel { root.savingLocationQueryStarted = true root.forecastRetries = 0 root.dailyForecastRetries = 0 + root.airQualityRetries = 0 forecastProc.running = false dailyForecastProc.running = false + airQualityProc.running = false Qt.callLater(root.refresh) } } @@ -733,6 +805,25 @@ Panel { font.pixelSize: Style.font.title } } + + Column { + visible: !!root.airQualityReport + spacing: Style.space(5) + Text { + text: "AIR" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + Text { + textFormat: Text.PlainText + text: root.reportAqi + color: root.airAqiAlarming ? Color.urgent : root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.title + } + } } } } diff --git a/test/shell.d/weather-test.sh b/test/shell.d/weather-test.sh index 85aca8ec33c..1d4368f1a82 100644 --- a/test/shell.d/weather-test.sh +++ b/test/shell.d/weather-test.sh @@ -145,6 +145,47 @@ assert( panelSource.split('root.controller.show()\n locationFile.reload()\n root.refresh()').length === 3, 'weather reloads external location changes whenever either open path runs' ) +assertDeepEqual( + weather.openMeteoCoordinates(34.02577, -118.7804, { latitude: 1, longitude: 2 }), + { lat: 34.02577, lon: -118.7804 }, + 'weather prefers stored coordinates for Open-Meteo' +) +assertDeepEqual( + weather.openMeteoCoordinates(null, null, { latitude: '40.7', longitude: '-74.0' }), + { lat: 40.7, lon: -74.0 }, + 'weather falls back to wttr area coordinates for Open-Meteo' +) +assertEqual(weather.openMeteoCoordinates(null, null, null), null, 'weather waits for coordinates before Open-Meteo') +assertEqual(weather.openMeteoCoordinates('nope', -118.7, null), null, 'weather ignores a partial stored coordinate pair') + +assertDeepEqual(weather.aqiBand(42, 'us'), { label: 'Good', level: 0, value: 42 }, 'weather maps US AQI good') +assertDeepEqual(weather.aqiBand(101, 'us'), { label: 'Sensitive', level: 2, value: 101 }, 'weather maps US AQI sensitive') +assertDeepEqual(weather.aqiBand(201, 'us'), { label: 'Very unhealthy', level: 4, value: 201 }, 'weather maps US AQI very unhealthy') +assertDeepEqual(weather.aqiBand(12, 'eu'), { label: 'Good', level: 0, value: 12 }, 'weather maps European AQI good') +assertDeepEqual(weather.aqiBand(85, 'eu'), { label: 'Very poor', level: 4, value: 85 }, 'weather maps European AQI very poor') +assertEqual(weather.aqiBand('nope', 'us'), null, 'weather ignores invalid AQI') + +assertDeepEqual( + weather.parseAirQuality(JSON.stringify({ current: { us_aqi: 55, european_aqi: 33 } })), + { us: { label: 'Moderate', level: 1, value: 55 }, eu: { label: 'Fair', level: 1, value: 33 } }, + 'weather parses Open-Meteo air quality' +) +assertEqual(weather.parseAirQuality('{}'), null, 'weather returns no air quality without current data') +assertEqual(weather.parseAirQuality('{'), null, 'weather handles invalid air quality JSON') + +assert( + panelSource.includes('https://air-quality-api.open-meteo.com/v1/air-quality'), + 'weather fetches air quality from Open-Meteo' +) +assert( + panelSource.includes('text: "AIR"'), + 'weather shows an AIR cell in the current-conditions row' +) +assert( + panelSource.includes('root.refreshAirQuality(parsed)'), + 'weather fetches air quality from wttr auto-detect coordinates' +) + assert(!weather.weatherResponseCompletesSave(true, 'wttr'), 'weather keeps the spinner through a non-authoritative pinned-location response') assert(weather.weatherResponseCompletesSave(true, 'open-meteo'), 'weather completes a pinned-location save with Open-Meteo data') assert(weather.weatherResponseCompletesSave(false, 'wttr'), 'weather completes a name-only location save with wttr data')