One more (sorry for the spam) - using the same open meteo API as my UV/ illuminance sensor, and my temperature and humidity sensor, I also created an air quality sensor using the same API.
Hope this is of use to someone
/*
* Air Quality Sensor (Open-Meteo)
*
* Hubitat driver that reports current air quality data from Open-Meteo's Air
* Quality API for the hub's configured latitude and longitude.
*
* Author: Jon Wallace
* Copyright 2026 Jon Wallace
* License: MIT
*
* Notes:
* - Uses the United States AQI by default.
* - Pollutant concentrations are reported in ug/m^3 where provided by
* Open-Meteo.
* - Requires the hub location coordinates to be configured in Hubitat.
*/
metadata {
definition (
name: "Air Quality Sensor (Open-Meteo)",
namespace: "jonw",
author: "Jon Wallace"
) {
capability "AirQuality"
capability "Sensor"
capability "Refresh"
attribute "usAqi", "number"
attribute "aqiCategory", "string"
attribute "pm25", "number"
attribute "pm10", "number"
attribute "ozone", "number"
attribute "nitrogenDioxide", "number"
attribute "sulphurDioxide", "number"
attribute "carbonMonoxide", "number"
attribute "lastUpdated", "string"
}
preferences {
def lat = location?.latitude
def lon = location?.longitude
def hubLocationDescription = (lat != null && lon != null)
? "Using hub coordinates: ${lat}, ${lon}"
: "Hub coordinates are not configured. Set the hub location in Hubitat before this driver can retrieve air quality data."
input name: "hubLocationStatus", type: "paragraph", title: hubLocationDescription, displayDuringSetup: true
input name: "updateFrequency", type: "number", title: "Auto-refresh interval (minutes)", description: "How often the driver refreshes air quality data.", defaultValue: 60, range: "1..1440"
input name: "minAqiChange", type: "number", title: "Minimum AQI change to report", description: "Suppresses US AQI events until the change is at least this amount. Use 0 to report every refresh.", defaultValue: 1, range: "0..500"
input name: "logEnable", type: "bool", title: "Enable debug logging", description: "Log API requests and update decisions.", defaultValue: false
}
}
// ===== Lifecycle =====
def installed() {
log.info "Installed Open-Meteo Air Quality Sensor"
initialize()
}
def updated() {
log.info "Updated settings"
initialize()
}
def initialize() {
unschedule()
scheduleAutoRefresh()
refreshAirQualityData()
}
// ===== Commands =====
def refresh() {
refreshAirQualityData()
}
def scheduleAutoRefresh() {
def minutes = settings?.updateFrequency != null ? settings.updateFrequency as Integer : 60
minutes = Math.max(1, Math.min(1440, minutes))
if (logEnable) log.debug "Scheduling auto-refresh every ${minutes} minutes"
if (minutes == 60) {
runEvery1Hour(refreshAirQualityData)
} else {
schedule("0 */${minutes} * ? * *", refreshAirQualityData)
}
}
def refreshAirQualityData() {
def coords = getHubCoordinates()
if (!coords) {
log.error "Hub coordinates are not configured. Set the hub location in Hubitat before retrieving air quality data."
return
}
def lat = coords.lat
def lon = coords.lon
def currentFields = "us_aqi,pm2_5,pm10,ozone,nitrogen_dioxide,sulphur_dioxide,carbon_monoxide"
def url = "https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lon}¤t=${currentFields}&timezone=auto"
if (logEnable) log.debug "Requesting air quality data from: ${url}"
try {
httpGet([uri: url, contentType: "application/json"]) { resp ->
if (resp.status == 200) {
def current = resp.data?.current
updateAqi(current?.us_aqi)
sendOptionalNumberEvent("pm25", current?.pm2_5, "ug/m^3", 1)
sendOptionalNumberEvent("pm10", current?.pm10, "ug/m^3", 1)
sendOptionalNumberEvent("ozone", current?.ozone, "ug/m^3", 1)
sendOptionalNumberEvent("nitrogenDioxide", current?.nitrogen_dioxide, "ug/m^3", 1)
sendOptionalNumberEvent("sulphurDioxide", current?.sulphur_dioxide, "ug/m^3", 1)
sendOptionalNumberEvent("carbonMonoxide", current?.carbon_monoxide, "ug/m^3", 1)
sendEvent(name: "lastUpdated", value: new Date().format("yyyy-MM-dd HH:mm:ss", location.timeZone))
} else {
log.error "Failed to get air quality data: ${resp.status}"
}
}
} catch (Exception e) {
log.error "Error fetching air quality data: ${e.message}"
}
}
// ===== Updates =====
def updateAqi(aqi) {
if (aqi == null) {
log.warn "US AQI data missing in Open-Meteo response"
return
}
def aqiValue = (aqi as BigDecimal).setScale(0, BigDecimal.ROUND_HALF_UP)
def currentVal = device.currentValue("airQualityIndex")
def currentAqi = currentVal != null ? new BigDecimal(currentVal.toString()) : null
def threshold = settings?.minAqiChange != null ? (settings.minAqiChange as BigDecimal) : new BigDecimal("1")
if (currentAqi == null || (aqiValue - currentAqi).abs() >= threshold) {
sendEvent(name: "airQualityIndex", value: aqiValue)
sendEvent(name: "usAqi", value: aqiValue)
sendEvent(name: "aqiCategory", value: getUsAqiCategory(aqiValue))
if (logEnable) log.debug "US AQI updated to ${aqiValue}"
} else if (logEnable) {
log.debug "US AQI change (${aqiValue}) within threshold (${threshold}); event not sent."
}
}
def sendOptionalNumberEvent(name, value, unit, scale) {
if (value == null) {
log.warn "${name} data missing in Open-Meteo response"
return
}
def eventValue = (value as BigDecimal).setScale(scale, BigDecimal.ROUND_HALF_UP)
sendEvent(name: name, value: eventValue, unit: unit)
if (logEnable) log.debug "${name} updated to ${eventValue} ${unit}"
}
// ===== Helpers =====
def getUsAqiCategory(aqi) {
if (aqi <= 50) return "good"
if (aqi <= 100) return "moderate"
if (aqi <= 150) return "unhealthy for sensitive groups"
if (aqi <= 200) return "unhealthy"
if (aqi <= 300) return "very unhealthy"
return "hazardous"
}
def getHubCoordinates() {
def lat = location?.latitude
def lon = location?.longitude
return (lat != null && lon != null) ? [lat: lat, lon: lon] : null
}