Open Meteo API-Driven UV index and illuminance sensor

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
}


1 Like