Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion manual/05-the-top-bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — | — |
Expand Down
50 changes: 50 additions & 0 deletions shell/plugins/panels/weather/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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,
Expand Down
115 changes: 103 additions & 12 deletions shell/plugins/panels/weather/Panel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

Expand All @@ -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
Expand Down Expand Up @@ -148,37 +152,49 @@ 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
// exhausted round (e.g. waking with the network still down) doesn't
// 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"
+ "&current=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code,is_day"
+ "&forecast_days=4"
Expand All @@ -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))
+ "&current=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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
}
}
}
}
}
Expand Down
41 changes: 41 additions & 0 deletions test/shell.d/weather-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down