Following on from my temperature sensor I also created a UV index and illuminance sensor from the same API.
- Provides the illuminance measurement in an estimated lux value
- Provides a bunch of custom attributes including uvIndex, uvIndexClearSky, uvRisk and solarRadiation
- The lux conversation factor is configurable
Hope this is of use to someone
/*
* UV Index and Illuminance Sensor (Open-Meteo)
*
* Hubitat driver that creates an illuminance sensor from Open-Meteo solar
* radiation data and reports UV index data for the hub's configured latitude
* and longitude.
*
* Author: Jon Wallace
* Copyright 2026 Jon Wallace
* License: MIT
*
* Notes:
* - Open-Meteo provides solar radiation in W/m², not native lux.
* - Illuminance is estimated from shortwave radiation using a configurable
* lux-per-W/m² conversion factor.
* - Requires the hub location coordinates to be configured in Hubitat.
*/
metadata {
definition (
name: "UV Index and Illuminance Sensor (Open-Meteo)",
namespace: "jonw",
author: "Jon Wallace"
) {
capability "IlluminanceMeasurement"
capability "Sensor"
capability "Refresh"
attribute "uvIndex", "number"
attribute "uvIndexClearSky", "number"
attribute "uvRisk", "string"
attribute "solarRadiation", "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 UV and illuminance 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 UV and estimated illuminance data.", defaultValue: 30, range: "1..1440"
input name: "luxConversionFactor", type: "number", title: "Lux conversion factor", description: "Estimated lux per W/m² of shortwave radiation. 120 is a common outdoor approximation.", defaultValue: 120, range: "1..200"
input name: "minIlluminanceChange", type: "number", title: "Minimum illuminance change to report", description: "Suppresses illuminance events until the change is at least this many lux. Use 0 to report every refresh.", defaultValue: 100, range: "0..100000"
input name: "minUvIndexChange", type: "number", title: "Minimum UV index change to report", description: "Suppresses UV index events until the change is at least this amount. Use 0 to report every refresh.", defaultValue: 0.1, range: "0..20"
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 UV Index and Illuminance Sensor"
initialize()
}
def updated() {
log.info "Updated settings"
initialize()
}
def initialize() {
unschedule()
scheduleAutoRefresh()
refreshOpenMeteoData()
}
// ===== Commands =====
def refresh() {
refreshOpenMeteoData()
}
def scheduleAutoRefresh() {
def minutes = settings?.updateFrequency ?: 30
minutes = Math.max(1, Math.min(1440, minutes))
if (logEnable) log.debug "Scheduling auto-refresh every ${minutes} minutes"
if (minutes == 60) {
runEvery1Hour(refreshOpenMeteoData)
} else if (minutes > 60) {
runIn(minutes * 60, refreshOpenMeteoData)
} else {
schedule("0 */${minutes} * ? * *", refreshOpenMeteoData)
}
}
def refreshOpenMeteoData() {
def coords = getHubCoordinates()
if (!coords) {
log.error "Hub coordinates are not configured. Set the hub location in Hubitat before retrieving UV and illuminance data."
scheduleNextRefresh()
return
}
def lat = coords.lat
def lon = coords.lon
def hourlyFields = "uv_index,uv_index_clear_sky,shortwave_radiation,shortwave_radiation_instant"
def url = "https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=${hourlyFields}&past_hours=1&forecast_hours=2&timezone=auto"
if (logEnable) log.debug "Requesting UV and illuminance data from: ${url}"
try {
httpGet([uri: url, contentType: "application/json"]) { resp ->
if (resp.status == 200) {
def data = resp.data
def uvIndex = getClosestHourlyValue(data, "uv_index")
def uvIndexClearSky = getClosestHourlyValue(data, "uv_index_clear_sky")
def solarRadiation = getClosestHourlyValue(data, "shortwave_radiation_instant")
if (solarRadiation == null) {
solarRadiation = getClosestHourlyValue(data, "shortwave_radiation")
}
updateUvIndex(uvIndex)
updateUvIndexClearSky(uvIndexClearSky)
updateIlluminance(solarRadiation)
sendEvent(name: "lastUpdated", value: new Date().format("yyyy-MM-dd HH:mm:ss", location.timeZone))
} else {
log.error "Failed to get UV and illuminance data: ${resp.status}"
}
}
} catch (Exception e) {
log.error "Error fetching UV and illuminance data: ${e.message}"
}
scheduleNextRefresh()
}
// ===== Updates =====
def updateUvIndex(uvIndex) {
if (uvIndex == null) {
log.warn "UV index data missing in Open-Meteo response"
return
}
def uvValue = (uvIndex as BigDecimal).setScale(1, BigDecimal.ROUND_HALF_UP)
def currentVal = device.currentValue("uvIndex")
def currentUv = currentVal != null ? new BigDecimal(currentVal.toString()) : null
def threshold = settings?.minUvIndexChange != null ? (settings.minUvIndexChange as BigDecimal) : new BigDecimal("0.1")
if (currentUv == null || (uvValue - currentUv).abs() >= threshold) {
sendEvent(name: "uvIndex", value: uvValue)
sendEvent(name: "uvRisk", value: getUvRisk(uvValue))
if (logEnable) log.debug "UV index updated to ${uvValue}"
} else if (logEnable) {
log.debug "UV index change (${uvValue}) within threshold (${threshold}); event not sent."
}
}
def updateUvIndexClearSky(uvIndexClearSky) {
if (uvIndexClearSky == null) {
log.warn "Clear-sky UV index data missing in Open-Meteo response"
return
}
def uvValue = (uvIndexClearSky as BigDecimal).setScale(1, BigDecimal.ROUND_HALF_UP)
sendEvent(name: "uvIndexClearSky", value: uvValue)
if (logEnable) log.debug "Clear-sky UV index updated to ${uvValue}"
}
def updateIlluminance(solarRadiation) {
if (solarRadiation == null) {
log.warn "Solar radiation data missing in Open-Meteo response"
return
}
def radiationValue = (solarRadiation as BigDecimal).setScale(1, BigDecimal.ROUND_HALF_UP)
def conversionFactor = settings?.luxConversionFactor != null ? (settings.luxConversionFactor as BigDecimal) : new BigDecimal("120")
def illuminanceValue = (radiationValue * conversionFactor).setScale(0, BigDecimal.ROUND_HALF_UP)
def currentVal = device.currentValue("illuminance")
def currentIlluminance = currentVal != null ? new BigDecimal(currentVal.toString()) : null
def threshold = settings?.minIlluminanceChange != null ? (settings.minIlluminanceChange as BigDecimal) : new BigDecimal("100")
sendEvent(name: "solarRadiation", value: radiationValue, unit: "W/m²")
if (currentIlluminance == null || (illuminanceValue - currentIlluminance).abs() >= threshold) {
sendEvent(name: "illuminance", value: illuminanceValue, unit: "lux")
if (logEnable) log.debug "Illuminance updated to ${illuminanceValue} lux from ${radiationValue} W/m²"
} else if (logEnable) {
log.debug "Illuminance change (${illuminanceValue} lux) within threshold (${threshold} lux); event not sent."
}
}
// ===== Helpers =====
def scheduleNextRefresh() {
def minutes = settings?.updateFrequency != null ? settings.updateFrequency as Integer : 30
minutes = Math.max(1, Math.min(1440, minutes))
if (minutes > 60) {
runIn(minutes * 60, refreshOpenMeteoData)
}
}
def getClosestHourlyValue(data, variableName) {
def times = data?.hourly?.time
def values = data?.hourly?.get(variableName)
if (!times || !values) {
return null
}
def now = new Date()
def closestValue = null
def closestDiff = null
values.eachWithIndex { value, index ->
if (value != null && index < times.size()) {
def sampleTime = parseOpenMeteoTime(times[index])
if (sampleTime != null) {
def diff = Math.abs(sampleTime.time - now.time)
if (closestDiff == null || diff < closestDiff) {
closestDiff = diff
closestValue = value
}
}
}
}
return closestValue
}
def parseOpenMeteoTime(value) {
try {
return Date.parse("yyyy-MM-dd'T'HH:mm", value.toString())
} catch (Exception e) {
if (logEnable) log.debug "Unable to parse Open-Meteo time '${value}': ${e.message}"
return null
}
}
def getUvRisk(uvIndex) {
if (uvIndex < 3) return "low"
if (uvIndex < 6) return "moderate"
if (uvIndex < 8) return "high"
if (uvIndex < 11) return "very high"
return "extreme"
}
def getHubCoordinates() {
def lat = location?.latitude
def lon = location?.longitude
return (lat != null && lon != null) ? [lat: lat, lon: lon] : null
}