Skip to content

Commit f44514a

Browse files
authored
Merge branch 'noctalia-dev:main' into feat/ntfy-notifications
2 parents 0bca651 + 0851036 commit f44514a

165 files changed

Lines changed: 9697 additions & 1068 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

air-quality/Main.qml

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@ Item {
2626
property bool loading: false
2727
property bool hasData: false
2828
property string errorMessage: ""
29+
property string stationName: ""
2930

3031
// Current scale from settings
3132
readonly property string aqiScale: cfg.aqiScale ?? defaults.aqiScale ?? "us"
3233
readonly property bool useNoctaliaLocation: cfg.useNoctaliaLocation ?? defaults.useNoctaliaLocation ?? true
34+
readonly property string dataSource: cfg.dataSource ?? defaults.dataSource ?? "open-meteo"
35+
readonly property string aqicnToken: cfg.aqicnToken ?? defaults.aqicnToken ?? ""
3336

3437
Component.onCompleted: {
3538
Logger.i("Air Quality", "Plugin loaded, starting initial fetch...")
@@ -110,6 +113,54 @@ Item {
110113
}
111114
}
112115

116+
Process {
117+
id: aqicnFetchProcess
118+
onExited: (exitCode, exitStatus) => {
119+
if (exitCode !== 0) {
120+
Logger.w("Air Quality", "AQICN curl exited with code " + exitCode)
121+
root.loading = false
122+
}
123+
}
124+
stdout: StdioCollector {
125+
onStreamFinished: {
126+
var output = this.text.trim()
127+
if (!output) {
128+
Logger.w("Air Quality", "Empty response from AQICN API")
129+
root.loading = false
130+
return
131+
}
132+
try {
133+
var response = JSON.parse(output)
134+
if (response.status !== "ok") {
135+
root.errorMessage = pluginApi?.tr("errors.aqicnApiFailed")
136+
Logger.w("Air Quality", "AQICN API error: " + (response.data ?? "unknown"))
137+
root.loading = false
138+
return
139+
}
140+
var data = response.data
141+
root.usAqi = data.aqi ?? 0
142+
root.europeanAqi = 0
143+
root.pm25 = data.iaqi?.pm25?.v ?? 0
144+
root.pm10 = data.iaqi?.pm10?.v ?? 0
145+
root.ozone = data.iaqi?.o3?.v ?? 0
146+
root.no2 = data.iaqi?.no2?.v ?? 0
147+
root.co = data.iaqi?.co?.v ?? 0
148+
root.so2 = data.iaqi?.so2?.v ?? 0
149+
root.stationName = data.city?.name ?? ""
150+
root.hasData = true
151+
152+
var now = new Date()
153+
root.lastUpdate = Qt.formatTime(now, "HH:mm")
154+
155+
Logger.i("Air Quality", "AQICN data updated — AQI: " + root.usAqi + " Station: " + root.stationName)
156+
} catch (e) {
157+
Logger.e("Air Quality", "Failed to parse AQICN response: " + e.message)
158+
}
159+
root.loading = false
160+
}
161+
}
162+
}
163+
113164
// Get current AQI value based on selected scale
114165
function getAqi() {
115166
return aqiScale === "eu" ? europeanAqi : usAqi
@@ -157,6 +208,9 @@ Item {
157208

158209
// Get location name for display
159210
function getLocationName() {
211+
if (dataSource === "aqicn" && stationName) {
212+
return stationName
213+
}
160214
if (useNoctaliaLocation) {
161215
var name = Settings.data.location?.name ?? ""
162216
if (name) {
@@ -220,9 +274,24 @@ Item {
220274
}
221275

222276
root.loading = true
223-
var url = "https://air-quality-api.open-meteo.com/v1/air-quality?latitude=" + lat + "&longitude=" + lon + "&current=us_aqi,european_aqi,pm2_5,pm10,ozone,nitrogen_dioxide,carbon_monoxide,sulphur_dioxide&timezone=auto"
224-
Logger.d("Air Quality", "Fetching: " + url)
225-
fetchProcess.command = ["curl", "-s", url]
226-
fetchProcess.running = true
277+
root.stationName = ""
278+
279+
if (root.dataSource === "aqicn") {
280+
if (!root.aqicnToken) {
281+
root.errorMessage = pluginApi?.tr("errors.aqicnTokenMissing")
282+
Logger.w("Air Quality", "AQICN token not configured")
283+
root.loading = false
284+
return
285+
}
286+
var aqicnUrl = "https://api.waqi.info/feed/geo:" + lat + ";" + lon + "/?token=" + root.aqicnToken
287+
Logger.d("Air Quality", "Fetching AQICN data for geo:" + lat + ";" + lon)
288+
aqicnFetchProcess.command = ["curl", "-s", aqicnUrl]
289+
aqicnFetchProcess.running = true
290+
} else {
291+
var url = "https://air-quality-api.open-meteo.com/v1/air-quality?latitude=" + lat + "&longitude=" + lon + "&current=us_aqi,european_aqi,pm2_5,pm10,ozone,nitrogen_dioxide,carbon_monoxide,sulphur_dioxide&timezone=auto"
292+
Logger.d("Air Quality", "Fetching Open-Meteo: " + url)
293+
fetchProcess.command = ["curl", "-s", url]
294+
fetchProcess.running = true
295+
}
227296
}
228297
}

air-quality/README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
# Air Quality
22

3-
Displays real-time air quality data from the Open-Meteo API with EPA color coding.
3+
Displays real-time air quality data with EPA color coding.
44
Shows AQI index (US EPA or European scale) and pollutant breakdown for PM2.5, PM10, O3, NO2, CO, and SO2.
55

6+
## Data Sources
7+
8+
| Source | Type | API Key | AQI Scales |
9+
|--------|------|---------|------------|
10+
| **Open-Meteo** (default) | Forecasting (CAMS atmospheric models) | Not required | US EPA, European |
11+
| **AQICN** | Real monitoring station data | Free token required | US EPA only |
12+
13+
- **Open-Meteo** uses Copernicus CAMS atmospheric models at 11-40km resolution. No API key needed, but values are forecasted estimates.
14+
- **AQICN** provides real-time data from the nearest EPA monitoring station. Requires a free API token from [aqicn.org/data-platform/token](https://aqicn.org/data-platform/token). Shows the station name in the location pill.
15+
616
## Features
717

818
**Bar Widget**
@@ -14,7 +24,7 @@ Shows AQI index (US EPA or European scale) and pollutant breakdown for PM2.5, PM
1424

1525
**Panel**
1626
- Large AQI number with level indicator
17-
- Location pill with last update time
27+
- Location pill with last update time (shows station name when using AQICN)
1828
- Pollutant rows with colored indicators
1929
- Refresh and settings buttons
2030

@@ -25,15 +35,13 @@ Shows AQI index (US EPA or European scale) and pollutant breakdown for PM2.5, PM
2535
- Middle click to refresh
2636

2737
**Settings**
28-
- AQI scale: US AQI (EPA) or European AQI
38+
- Data source: Open-Meteo (forecasting) or AQICN (real station data)
39+
- AQICN API token field (visible when AQICN selected)
40+
- AQI scale: US AQI (EPA) or European AQI (disabled when using AQICN)
2941
- Location: use Noctalia location or custom coordinates
3042
- Refresh interval (5-120 minutes)
3143
- Bold text toggle
3244

3345
**IPC**
3446
- Refresh: `qs -c noctalia-shell ipc call plugin:air-quality refresh`
3547
- Toggle panel: `qs -c noctalia-shell ipc call plugin:air-quality toggle`
36-
37-
## Data Source
38-
39-
[Open-Meteo Air Quality API](https://open-meteo.com/)

air-quality/Settings.qml

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,59 @@ ColumnLayout {
1717
property string editCustomLongitude: cfg.customLongitude ?? defaults.customLongitude ?? ""
1818
property int editRefreshInterval: cfg.refreshInterval ?? defaults.refreshInterval ?? 30
1919
property bool editBoldText: cfg.boldText ?? defaults.boldText ?? true
20+
property string editDataSource: cfg.dataSource ?? defaults.dataSource ?? "open-meteo"
21+
property string editAqicnToken: cfg.aqicnToken ?? defaults.aqicnToken ?? ""
2022

2123
spacing: Style.marginM
2224

25+
// --- Data Source ---
26+
RowLayout {
27+
Layout.fillWidth: true
28+
spacing: Style.marginM
29+
30+
ColumnLayout {
31+
Layout.fillWidth: true
32+
spacing: Style.marginXS
33+
34+
NText {
35+
text: pluginApi?.tr("settings.dataSource")
36+
pointSize: Style.fontSizeM
37+
color: Color.mOnSurface
38+
}
39+
40+
NText {
41+
text: pluginApi?.tr("settings.dataSourceDesc")
42+
pointSize: Style.fontSizeS
43+
color: Color.mOnSurfaceVariant
44+
}
45+
}
46+
47+
NComboBox {
48+
Layout.preferredWidth: 260 * Style.uiScaleRatio
49+
Layout.preferredHeight: Style.baseWidgetSize
50+
model: [
51+
{ key: "open-meteo", name: pluginApi?.tr("settings.dataSourceOpenMeteo") },
52+
{ key: "aqicn", name: pluginApi?.tr("settings.dataSourceAqicn") }
53+
]
54+
currentKey: root.editDataSource
55+
onSelected: key => {
56+
root.editDataSource = key
57+
if (key === "aqicn") root.editAqiScale = "us"
58+
}
59+
}
60+
}
61+
62+
// --- AQICN Token ---
63+
NTextInput {
64+
Layout.fillWidth: true
65+
visible: root.editDataSource === "aqicn"
66+
label: pluginApi?.tr("settings.aqicnToken")
67+
description: pluginApi?.tr("settings.aqicnTokenDesc")
68+
placeholderText: pluginApi?.tr("settings.aqicnTokenPlaceholder")
69+
text: root.editAqicnToken
70+
onTextChanged: root.editAqicnToken = text
71+
}
72+
2373
// --- AQI Scale ---
2474
RowLayout {
2575
Layout.fillWidth: true
@@ -45,6 +95,7 @@ ColumnLayout {
4595
NComboBox {
4696
Layout.preferredWidth: 180 * Style.uiScaleRatio
4797
Layout.preferredHeight: Style.baseWidgetSize
98+
enabled: root.editDataSource !== "aqicn"
4899
model: [
49100
{ key: "us", name: pluginApi?.tr("settings.aqiScaleUs") },
50101
{ key: "eu", name: pluginApi?.tr("settings.aqiScaleEu") }
@@ -134,8 +185,12 @@ ColumnLayout {
134185
|| pluginApi.pluginSettings.customLatitude !== root.editCustomLatitude
135186
|| pluginApi.pluginSettings.customLongitude !== root.editCustomLongitude
136187
var scaleChanged = pluginApi.pluginSettings.aqiScale !== root.editAqiScale
188+
var dataSourceChanged = pluginApi.pluginSettings.dataSource !== root.editDataSource
189+
|| pluginApi.pluginSettings.aqicnToken !== root.editAqicnToken
137190

138191
pluginApi.pluginSettings.aqiScale = root.editAqiScale
192+
pluginApi.pluginSettings.dataSource = root.editDataSource
193+
pluginApi.pluginSettings.aqicnToken = root.editAqicnToken
139194
pluginApi.pluginSettings.useNoctaliaLocation = root.editUseNoctaliaLocation
140195
pluginApi.pluginSettings.customLatitude = root.editCustomLatitude
141196
pluginApi.pluginSettings.customLongitude = root.editCustomLongitude
@@ -144,8 +199,8 @@ ColumnLayout {
144199

145200
pluginApi.saveSettings()
146201

147-
// Only refresh if location or scale changed
148-
if (locationChanged || scaleChanged) {
202+
// Only refresh if location, scale, or data source changed
203+
if (locationChanged || scaleChanged || dataSourceChanged) {
149204
root.pluginApi.mainInstance?.refresh()
150205
}
151206

air-quality/i18n/de.json

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"widget": { "tooltip": "Luftqualität" },
3+
"scale": { "us": "US AQI", "eu": "EU AQI" },
4+
"location": { "custom": "Benutzerdefiniert" },
5+
"unit": { "ugm3": "µg/m³" },
6+
"levels": {
7+
"good": "Gut", "moderate": "Mäßig",
8+
"unhealthySensitive": "Ungesund für empfindliche Gruppen",
9+
"unhealthy": "Ungesund", "veryUnhealthy": "Sehr ungesund",
10+
"hazardous": "Gefährlich", "fair": "Akzeptabel", "poor": "Schlecht",
11+
"veryPoor": "Sehr schlecht", "extremelyPoor": "Extrem schlecht", "unknown": "Unbekannt"
12+
},
13+
"pollutants": {
14+
"pm25": "PM2.5", "pm10": "PM10", "ozone": "O\u2083", "no2": "NO\u2082", "co": "CO", "so2": "SO\u2082",
15+
"pm25Value": "PM2.5: {value} µg/m³", "pm10Value": "PM10: {value} µg/m³",
16+
"ozoneValue": "O\u2083: {value} µg/m³", "no2Value": "NO\u2082: {value} µg/m³",
17+
"coValue": "CO: {value} µg/m³", "so2Value": "SO\u2082: {value} µg/m³",
18+
"pm25Short": "PM2.5: {value}", "pm10Short": "PM10: {value}"
19+
},
20+
"errors": {
21+
"weatherDisabled": "Wetter in den Noctalia-Einstellungen aktivieren oder benutzerdefinierte Koordinaten verwenden",
22+
"locationUnavailable": "Standort noch nicht verfügbar, wird erneut versucht...",
23+
"aqicnTokenMissing": "AQICN ausgewählt, aber kein API-Token konfiguriert",
24+
"aqicnApiFailed": "AQICN API-Fehler"
25+
},
26+
"panel": {
27+
"title": "Luftqualität", "lastUpdate": "Letzte Aktualisierung", "pollutants": "Schadstoffe",
28+
"refresh": "Aktualisieren", "settings": "Einstellungen", "noData": "Keine Daten verfügbar", "loading": "Wird geladen..."
29+
},
30+
"settings": {
31+
"aqiScale": "AQI-Skala", "aqiScaleDesc": "Luftqualitätsindex-Skala auswählen",
32+
"aqiScaleUs": "US AQI (EPA)", "aqiScaleEu": "Europäischer AQI",
33+
"location": "Standort", "useNoctaliaLocation": "Noctalia-Standort verwenden",
34+
"useNoctaliaLocationDesc": "Den in den Noctalia-Einstellungen konfigurierten Standort verwenden",
35+
"customLatitude": "Breitengrad", "customLongitude": "Längengrad",
36+
"customLocationDesc": "Koordinaten manuell eingeben",
37+
"refreshInterval": "Aktualisierungsintervall", "refreshIntervalDesc": "Minuten zwischen Aktualisierungen: ",
38+
"boldText": "Fetter Text", "boldTextDesc": "AQI-Zahl fett anzeigen",
39+
"dataSource": "Datenquelle", "dataSourceDesc": "Auswählen, woher die Luftqualitätsdaten stammen",
40+
"dataSourceOpenMeteo": "Open-Meteo (kein Schlüssel erforderlich)", "dataSourceAqicn": "AQICN (echte Stationsdaten)",
41+
"aqicnToken": "AQICN API-Token", "aqicnTokenDesc": "Kostenlosen Token erhalten unter aqicn.org/data-platform/token",
42+
"aqicnTokenPlaceholder": "Token hier einfügen"
43+
},
44+
"desktop": {
45+
"noData": "Keine Daten", "tipLeft": "Linksklick: Panel öffnen",
46+
"tipMiddle": "Mittelklick: Aktualisieren", "tipRight": "Rechtsklick: Einstellungen"
47+
},
48+
"context": { "refresh": "Aktualisieren", "settings": "Einstellungen" }
49+
}

air-quality/i18n/en.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@
4343
},
4444
"errors": {
4545
"weatherDisabled": "Enable weather in Noctalia settings or use custom coordinates",
46-
"locationUnavailable": "Location not available yet, retrying..."
46+
"locationUnavailable": "Location not available yet, retrying...",
47+
"aqicnTokenMissing": "AQICN selected but no API token configured",
48+
"aqicnApiFailed": "AQICN API error"
4749
},
4850
"panel": {
4951
"title": "Air Quality",
@@ -68,7 +70,14 @@
6870
"refreshInterval": "Refresh interval",
6971
"refreshIntervalDesc": "Minutes between updates: ",
7072
"boldText": "Bold text",
71-
"boldTextDesc": "Display the AQI number in bold"
73+
"boldTextDesc": "Display the AQI number in bold",
74+
"dataSource": "Data source",
75+
"dataSourceDesc": "Choose where air quality data comes from",
76+
"dataSourceOpenMeteo": "Open-Meteo (no key needed)",
77+
"dataSourceAqicn": "AQICN (real station data)",
78+
"aqicnToken": "AQICN API Token",
79+
"aqicnTokenDesc": "Get your free token at aqicn.org/data-platform/token",
80+
"aqicnTokenPlaceholder": "Paste your token here"
7281
},
7382
"desktop": {
7483
"noData": "No data",

air-quality/i18n/es.json

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"widget": { "tooltip": "Calidad del aire" },
3+
"scale": { "us": "US AQI", "eu": "EU AQI" },
4+
"location": { "custom": "Personalizada" },
5+
"unit": { "ugm3": "µg/m³" },
6+
"levels": {
7+
"good": "Buena", "moderate": "Moderada",
8+
"unhealthySensitive": "No saludable para grupos sensibles",
9+
"unhealthy": "No saludable", "veryUnhealthy": "Muy perjudicial",
10+
"hazardous": "Peligrosa", "fair": "Aceptable", "poor": "Mala",
11+
"veryPoor": "Muy mala", "extremelyPoor": "Extremadamente mala", "unknown": "Desconocida"
12+
},
13+
"pollutants": {
14+
"pm25": "PM2.5", "pm10": "PM10", "ozone": "O\u2083", "no2": "NO\u2082", "co": "CO", "so2": "SO\u2082",
15+
"pm25Value": "PM2.5: {value} µg/m³", "pm10Value": "PM10: {value} µg/m³",
16+
"ozoneValue": "O\u2083: {value} µg/m³", "no2Value": "NO\u2082: {value} µg/m³",
17+
"coValue": "CO: {value} µg/m³", "so2Value": "SO\u2082: {value} µg/m³",
18+
"pm25Short": "PM2.5: {value}", "pm10Short": "PM10: {value}"
19+
},
20+
"errors": {
21+
"weatherDisabled": "Activa la ubicación en los ajustes de Noctalia o usa coordenadas personalizadas",
22+
"locationUnavailable": "Ubicación no disponible todavía, reintentando...",
23+
"aqicnTokenMissing": "AQICN seleccionado pero no hay ningún token de API configurado",
24+
"aqicnApiFailed": "Error en la API de AQICN"
25+
},
26+
"panel": {
27+
"title": "Calidad del aire", "lastUpdate": "Última actualización", "pollutants": "Contaminantes",
28+
"refresh": "Actualizar", "settings": "Ajustes", "noData": "No hay datos disponibles", "loading": "Cargando..."
29+
},
30+
"settings": {
31+
"aqiScale": "Escala AQI", "aqiScaleDesc": "Elige la escala del índice de calidad del aire",
32+
"aqiScaleUs": "US AQI (EPA)", "aqiScaleEu": "AQI europeo",
33+
"location": "Ubicación", "useNoctaliaLocation": "Usar la ubicación de Noctalia",
34+
"useNoctaliaLocationDesc": "Usa la ubicación configurada en los ajustes de Noctalia",
35+
"customLatitude": "Latitud", "customLongitude": "Longitud",
36+
"customLocationDesc": "Introduce las coordenadas manualmente",
37+
"refreshInterval": "Intervalo de actualización", "refreshIntervalDesc": "Minutos entre actualizaciones: ",
38+
"boldText": "Texto en negrita", "boldTextDesc": "Mostrar el número AQI en negrita",
39+
"dataSource": "Fuente de datos", "dataSourceDesc": "Elige de dónde provienen los datos de calidad del aire",
40+
"dataSourceOpenMeteo": "Open-Meteo (sin clave necesaria)", "dataSourceAqicn": "AQICN (datos de estaciones reales)",
41+
"aqicnToken": "Token de API de AQICN", "aqicnTokenDesc": "Obtén tu token gratuito en aqicn.org/data-platform/token",
42+
"aqicnTokenPlaceholder": "Pega tu token aquí"
43+
},
44+
"desktop": {
45+
"noData": "Sin datos", "tipLeft": "Clic izquierdo: Abrir panel",
46+
"tipMiddle": "Clic central: Actualizar", "tipRight": "Clic derecho: Ajustes"
47+
},
48+
"context": { "refresh": "Actualizar", "settings": "Ajustes" }
49+
}

0 commit comments

Comments
 (0)