[RELEASE] AccuWeather Driver

I have been using the Open Weather Alerts driver to check rain for my irrigation scheduler, but I have not been impressed with the accuracy of the forecasts.

Since AccuWeather is considered the most accurate weather site, I wanted a driver that would use the AccuWeather api data.

I literally spent about 30 minutes with AI to create this driver. I made a developer account on the AccuWeather site, and started a free trial. The free trial does not need a credit card.

A personal account is then $2/month for personal access. So, $24/year is not too bad...and you get 15,000 calls a month.

I had DeepSeek write a driver. It took only about six iterations to get it working how I wanted.

If interested, here is the driver:

/**
 * AccuWeather Driver for Hubitat Elevation Hub
 * v1.2.0 – supports both temperature (°C/°F) and rain (mm/in) unit preferences.
 */

import groovy.json.JsonSlurper

metadata {
    definition(
        name: "AccuWeather Driver",
        namespace: "hubitat.accuweather",
        author: "Hubitat Community",
        importUrl: "https://raw.githubusercontent.com/hubitat/accuweather-driver/main/accuweather-driver.groovy"
    ) {
        capability "TemperatureMeasurement"
        capability "RelativeHumidityMeasurement"
        capability "IlluminanceMeasurement"
        capability "Sensor"

        attribute "weatherCondition", "string"
        attribute "weatherIcon", "number"
        attribute "windSpeed", "number"
        attribute "windDirection", "string"
        attribute "pressure", "number"
        attribute "pressureTrend", "string"
        attribute "visibility", "number"
        attribute "uvIndex", "number"
        attribute "realFeel", "number"
        attribute "precipitationProbability", "number"
        attribute "forecastHigh", "number"
        attribute "forecastLow", "number"
        attribute "forecastDay1", "string"
        attribute "forecastDay2", "string"
        attribute "forecastDay3", "string"
        attribute "forecastDay4", "string"
        attribute "forecastDay5", "string"
        attribute "hourlyForecast", "string"
        attribute "lastUpdate", "string"
        attribute "apiCallsRemaining", "number"
        attribute "apiError", "string"

        attribute "rainProbabilityDay1", "number"
        attribute "rainProbabilityDay2", "number"
        attribute "rainProbabilityDay3", "number"
        attribute "rainProbabilityDay4", "number"
        attribute "rainProbabilityDay5", "number"

        attribute "rainAmountDay1", "number"
        attribute "rainAmountDay2", "number"
        attribute "rainAmountDay3", "number"
        attribute "rainAmountDay4", "number"
        attribute "rainAmountDay5", "number"

        command "refresh"
        command "poll"
        command "resetCounters"
        command "reschedule"
    }

    preferences {
        section("AccuWeather API Configuration") {
            input(
                name: "apiKey",
                type: "text",
                title: "API Key",
                description: "Your AccuWeather API key",
                required: true,
                displayDuringSetup: true
            )
            input(
                name: "locationKey",
                type: "text",
                title: "Location Key",
                description: "AccuWeather location key (e.g., 349727 for NYC)",
                required: true,
                displayDuringSetup: true
            )
        }

        section("Polling Configuration") {
            input(
                name: "pollingInterval",
                type: "number",
                title: "Polling Interval (seconds)",
                description: "How often to poll (300‑3600 seconds)",
                range: "300..3600",
                defaultValue: 900,
                displayDuringSetup: true
            )
            input(
                name: "enableCurrentConditions",
                type: "bool",
                title: "Enable Current Conditions",
                defaultValue: true,
                displayDuringSetup: true
            )
            input(
                name: "enableDailyForecast",
                type: "bool",
                title: "Enable 5‑Day Forecast",
                defaultValue: true,
                displayDuringSetup: true
            )
            input(
                name: "enableHourlyForecast",
                type: "bool",
                title: "Enable 12‑Hour Forecast",
                defaultValue: false,
                displayDuringSetup: true
            )
        }

        section("Unit Preferences") {
            input(
                name: "tempUnit",
                type: "enum",
                title: "Temperature Unit",
                options: ["C", "F"],
                defaultValue: "F",
                displayDuringSetup: true
            )
            input(
                name: "rainUnit",
                type: "enum",
                title: "Rain Amount Unit",
                options: ["mm", "in"],
                defaultValue: "mm",
                displayDuringSetup: true
            )
        }

        section("Advanced") {
            input(
                name: "logEnable",
                type: "bool",
                title: "Enable Debug Logging",
                defaultValue: false,
                displayDuringSetup: true
            )
            input(
                name: "apiCallsResetDay",
                type: "number",
                title: "API Call Counter Reset Day",
                range: "1..28",
                defaultValue: 1,
                displayDuringSetup: false
            )
        }
    }
}

// ============================================================================
// Lifecycle Methods
// ============================================================================

def installed() {
    log.info "AccuWeather Driver installed"
    initialize()
}

def updated() {
    log.info "AccuWeather Driver updated – rescheduling"
    unschedule()
    initialize()
}

def initialize() {
    if (!apiKey || !locationKey) {
        log.warn "API Key or Location Key not configured."
        return
    }

    def intervalSeconds = (pollingInterval ?: 900) as Integer
    def intervalMinutes = Math.max(1, Math.round(intervalSeconds / 60)) as Integer
    def cron = "0 */${intervalMinutes} * * * ?"
    schedule(cron, "pollWeather")

    runIn(2, "pollWeather")

    def resetDay = (apiCallsResetDay ?: 1) as Integer
    schedule("0 0 0 ${resetDay} * ? *", "resetApiCounter")

    log.info "Polling scheduled with cron '${cron}' (${intervalSeconds} seconds)"
}

// ============================================================================
// Polling Methods
// ============================================================================

def pollWeather() {
    if (!apiKey || !locationKey) {
        log.warn "Cannot poll: API Key or Location Key not configured"
        return
    }

    logDebug "Starting weather poll"

    def callsMade = 0
    sendEvent(name: "apiError", value: "")

    if (enableCurrentConditions != false) {
        fetchCurrentConditions { callsMade++ }
    }

    if (enableDailyForecast != false) {
        fetchDailyForecast { callsMade++ }
    }

    if (enableHourlyForecast != false) {
        fetchHourlyForecast { callsMade++ }
    }

    sendEvent(name: "lastUpdate", value: new Date().format("yyyy-MM-dd HH:mm:ss"))
    updateApiCounter(callsMade)

    logDebug "Poll completed - ${callsMade} API calls made"
}

// ============================================================================
// API Fetch Methods
// ============================================================================

def fetchCurrentConditions(closure = null) {
    def endpoint = "https://dataservice.accuweather.com/currentconditions/v1/${locationKey}"
    def params = [
        uri: endpoint,
        query: [apikey: apiKey, details: "true"],
        timeout: 15
    ]
    logDebug "Fetching Current Conditions from ${endpoint}"
    try {
        httpGet(params) { resp ->
            if (resp.status == 200 && resp.data) {
                parseCurrentConditions(resp.data)
                if (closure) closure()
            } else {
                log.warn "Current Conditions failed: HTTP ${resp.status}"
                sendEvent(name: "apiError", value: "Current Conditions: HTTP ${resp.status}")
            }
        }
    } catch (Exception e) {
        log.error "Error fetching Current Conditions: ${e.message}"
        sendEvent(name: "apiError", value: "Current Conditions: ${e.message}")
    }
}

def fetchDailyForecast(closure = null) {
    def endpoint = "https://dataservice.accuweather.com/forecasts/v1/daily/5day/${locationKey}"
    def params = [
        uri: endpoint,
        query: [apikey: apiKey, details: "true", metric: "true"],
        timeout: 15
    ]
    logDebug "Fetching 5‑Day Forecast from ${endpoint}"
    try {
        httpGet(params) { resp ->
            if (resp.status == 200 && resp.data) {
                parseDailyForecast(resp.data)
                if (closure) closure()
            } else {
                log.warn "Daily Forecast failed: HTTP ${resp.status}"
                sendEvent(name: "apiError", value: "Daily Forecast: HTTP ${resp.status}")
            }
        }
    } catch (Exception e) {
        log.error "Error fetching Daily Forecast: ${e.message}"
        sendEvent(name: "apiError", value: "Daily Forecast: ${e.message}")
    }
}

def fetchHourlyForecast(closure = null) {
    def endpoint = "https://dataservice.accuweather.com/forecasts/v1/hourly/12hour/${locationKey}"
    def params = [
        uri: endpoint,
        query: [apikey: apiKey, details: "true", metric: "true"],
        timeout: 15
    ]
    logDebug "Fetching 12‑Hour Forecast from ${endpoint}"
    try {
        httpGet(params) { resp ->
            if (resp.status == 200 && resp.data) {
                parseHourlyForecast(resp.data)
                if (closure) closure()
            } else {
                log.warn "Hourly Forecast failed: HTTP ${resp.status}"
                sendEvent(name: "apiError", value: "Hourly Forecast: HTTP ${resp.status}")
            }
        }
    } catch (Exception e) {
        log.error "Error fetching Hourly Forecast: ${e.message}"
        sendEvent(name: "apiError", value: "Hourly Forecast: ${e.message}")
    }
}

// ============================================================================
// Parsing Methods – with temperature conversion
// ============================================================================

// Helper: convert Celsius to Fahrenheit
private def cToF(celsius) {
    if (celsius == null) return null
    return (celsius * 9 / 5) + 32
}

// Helper: send temperature event with proper unit
private def sendTempEvent(name, valueCelsius, unitPreference) {
    if (valueCelsius == null) return
    def value = valueCelsius
    def unit = "°C"
    if (unitPreference == "F") {
        value = cToF(valueCelsius)
        unit = "°F"
    }
    // Round to 1 decimal
    value = Math.round(value * 10) / 10
    sendEvent(name: name, value: value, unit: unit)
}

def parseCurrentConditions(data) {
    try {
        if (!(data instanceof List) || data.size() == 0) {
            log.warn "Current Conditions: empty or invalid response"
            return
        }
        def conditions = data[0]
        if (!conditions) return

        def tempUnit = (tempUnit ?: "F") as String

        // Temperature
        def tempC = conditions.Temperature?.Metric?.Value
        sendTempEvent("temperature", tempC, tempUnit)

        // RealFeel
        def realFeelC = conditions.RealFeelTemperature?.Metric?.Value
        sendTempEvent("realFeel", realFeelC, tempUnit)

        // Humidity
        def humidity = conditions.RelativeHumidity
        if (humidity != null) sendEvent(name: "humidity", value: humidity, unit: "%")

        // Weather condition text & icon
        def weatherText = conditions.WeatherText
        if (weatherText) sendEvent(name: "weatherCondition", value: weatherText)

        def icon = conditions.WeatherIcon
        if (icon != null) sendEvent(name: "weatherIcon", value: icon)

        // Wind
        def windSpeed = conditions.Wind?.Speed?.Metric?.Value
        if (windSpeed != null) sendEvent(name: "windSpeed", value: windSpeed)

        def windDir = conditions.Wind?.Direction?.English
        if (windDir) sendEvent(name: "windDirection", value: windDir)

        // Pressure
        def pressure = conditions.Pressure?.Metric?.Value
        if (pressure != null) sendEvent(name: "pressure", value: pressure)

        def pressureTrend = conditions.PressureTendency?.LocalizedText
        if (pressureTrend) sendEvent(name: "pressureTrend", value: pressureTrend)

        // Visibility
        def visibility = conditions.Visibility?.Metric?.Value
        if (visibility != null) sendEvent(name: "visibility", value: visibility)

        // UV Index & Illuminance
        def uvIndex = conditions.UVIndex
        if (uvIndex != null) {
            sendEvent(name: "uvIndex", value: uvIndex)
            sendEvent(name: "illuminance", value: uvIndex * 1000, unit: "lux")
        }

        // Precipitation probability
        def precipProb = conditions.PrecipitationProbability
        if (precipProb != null) sendEvent(name: "precipitationProbability", value: precipProb)

        logDebug "Current Conditions parsed successfully"
    } catch (Exception e) {
        log.error "Error parsing Current Conditions: ${e.message}"
        if (logEnable) log.debug "Raw data: ${data}"
    }
}

def parseDailyForecast(data) {
    try {
        if (!data) {
            log.warn "Daily Forecast: null response"
            return
        }
        if (data.Code || data.Message) {
            log.warn "Daily Forecast API error: ${data.Code} - ${data.Message}"
            sendEvent(name: "apiError", value: "Daily Forecast: ${data.Message}")
            return
        }

        def dailyForecasts = data.DailyForecasts
        if (!dailyForecasts || dailyForecasts.size() == 0) {
            log.warn "Daily Forecast: no 'DailyForecasts' array"
            return
        }

        def tempUnit = (tempUnit ?: "F") as String
        def rainUnitPref = (rainUnit ?: "mm") as String
        def isInches = (rainUnitPref == "in")

        // Process first day for primary forecast attributes
        def today = dailyForecasts[0]
        if (today) {
            def highC = today.Temperature?.Maximum?.Value
            sendTempEvent("forecastHigh", highC, tempUnit)

            def lowC = today.Temperature?.Minimum?.Value
            sendTempEvent("forecastLow", lowC, tempUnit)

            def dayText = today.Day?.IconPhrase
            if (dayText) sendEvent(name: "weatherCondition", value: dayText)

            def dayIcon = today.Day?.Icon
            if (dayIcon != null) sendEvent(name: "weatherIcon", value: dayIcon)

            def precipProb = today.Day?.PrecipitationProbability
            if (precipProb != null) sendEvent(name: "precipitationProbability", value: precipProb)
        }

        // Process all 5 days
        dailyForecasts.eachWithIndex { day, index ->
            if (index < 5) {
                def dayNum = index + 1

                // Date abbreviation
                def dateStr = day.Date
                def shortDay = "???"
                try {
                    def parsedDate = Date.parse("yyyy-MM-dd'T'HH:mm:ssX", dateStr)
                    shortDay = parsedDate.format("EEE")
                } catch (Exception e) {
                    logDebug "Could not parse date '${dateStr}': ${e.message}"
                }

                // High/Low – convert if needed
                def highC = day.Temperature?.Maximum?.Value
                def lowC = day.Temperature?.Minimum?.Value
                def highDisplay = highC
                def lowDisplay = lowC
                def tempUnitSymbol = "°C"
                if (tempUnit == "F") {
                    highDisplay = cToF(highC)
                    lowDisplay = cToF(lowC)
                    tempUnitSymbol = "°F"
                }
                // Round
                highDisplay = highDisplay != null ? Math.round(highDisplay * 10) / 10 : null
                lowDisplay = lowDisplay != null ? Math.round(lowDisplay * 10) / 10 : null

                def condition = day.Day?.IconPhrase ?: "Unknown"
                def summary = "${shortDay}: ${condition}, ${highDisplay}${tempUnitSymbol}/${lowDisplay}${tempUnitSymbol}"
                sendEvent(name: "forecastDay${dayNum}", value: summary)

                // Rain Probability
                def rainProb = day.Day?.RainProbability ?: day.Day?.PrecipitationProbability
                logDebug "Day ${dayNum} - RainProbability: ${rainProb}"
                if (rainProb != null) {
                    sendEvent(name: "rainProbabilityDay${dayNum}", value: rainProb)
                }

                // Rain Amount
                def rainAmountMm = day.Day?.Rain?.Value ?: day.Day?.TotalLiquid?.Value
                logDebug "Day ${dayNum} - RainAmount (mm): ${rainAmountMm}"
                if (rainAmountMm != null) {
                    def finalAmount = rainAmountMm
                    def unitLabel = "mm"
                    if (isInches) {
                        finalAmount = rainAmountMm / 25.4
                        unitLabel = "in"
                    }
                    finalAmount = Math.round(finalAmount * 100) / 100
                    sendEvent(name: "rainAmountDay${dayNum}", value: finalAmount, unit: unitLabel)
                    logDebug "Day ${dayNum} - RainAmount (${unitLabel}): ${finalAmount}"
                } else {
                    logDebug "Day ${dayNum} - RainAmount is null"
                }
            }
        }

        logDebug "5‑Day Forecast parsed successfully"
    } catch (Exception e) {
        log.error "Error parsing Daily Forecast: ${e.message}"
        if (logEnable) log.debug "Raw Daily Forecast data: ${data}"
    }
}

def parseHourlyForecast(data) {
    try {
        if (!data || data.size() == 0) {
            log.warn "Hourly Forecast: no data"
            return
        }
        if (data.Code || data.Message) {
            log.warn "Hourly Forecast API error: ${data.Code} - ${data.Message}"
            sendEvent(name: "apiError", value: "Hourly Forecast: ${data.Message}")
            return
        }

        def hourlyData = data.collect { hour ->
            [
                time: hour.DateTime,
                temp: hour.Temperature?.Value,
                condition: hour.IconPhrase,
                icon: hour.WeatherIcon,
                precipProb: hour.PrecipitationProbability,
                windSpeed: hour.Wind?.Speed?.Value,
                humidity: hour.RelativeHumidity
            ]
        }

        def json = new groovy.json.JsonOutput().toJson(hourlyData)
        sendEvent(name: "hourlyForecast", value: json)

        logDebug "12‑Hour Forecast parsed successfully"
    } catch (Exception e) {
        log.error "Error parsing Hourly Forecast: ${e.message}"
        if (logEnable) log.debug "Raw Hourly data: ${data}"
    }
}

// ============================================================================
// API Counter Management
// ============================================================================

def updateApiCounter(callsMade) {
    try {
        def stateKey = "apiCallsMonth"
        def currentCalls = state[stateKey] ?: 0
        def newTotal = currentCalls + callsMade
        if (newTotal > 14000) log.warn "API calls this month: ${newTotal} - approaching 15,000 limit!"
        state[stateKey] = newTotal
        sendEvent(name: "apiCallsRemaining", value: Math.max(0, 15000 - newTotal))
    } catch (Exception e) {
        log.error "Error updating API counter: ${e.message}"
    }
}

def resetApiCounter() {
    state["apiCallsMonth"] = 0
    sendEvent(name: "apiCallsRemaining", value: 15000)
    log.info "API call counter reset"
}

// ============================================================================
// Utility & Commands
// ============================================================================

def logDebug(message) {
    if (logEnable) log.debug message
}

def refresh() { log.info "Manual refresh"; pollWeather() }
def poll() { log.info "Manual poll"; pollWeather() }
def resetCounters() { resetApiCounter() }
def reschedule() { log.info "Manually rescheduling"; unschedule(); initialize() }
def getApiCalls() { return state["apiCallsMonth"] ?: 0 }


Edit: I missed specifying units. The driver is updated now to let you choose in/mm rain, and F/C temp in preferences.

1 Like

I’m wondering if that’s a personal observation? Or has that statement been tested somewhere?

2 Likes

I know, right. I've been caught golfing in the rain several times because of, you know, "future radar". lol.

1 Like

That could absolutely be wrong, but the Weather Channel has really been pissing me off lately with all their site changes, and now you need to create an account to see extended hourly weather.

So I Googled what is considered the most accurate site, for an alternative to the Weather Channel, and the answer was AccuWeather.

I've been using that site in the browser for that last few days, and it has seemed to be more accurate, certainly more accurate than Open Weather.

That I why I wanted to try using it with my irrigation app. Open Weather has called for 100% chance of rain, causing some waterings to skip that should not have, as we would get little to no rain when it is often predicted.

def accurate_weather = 'oxymoron'

Between my technical weather shirt and the OpenWeather API, Hubitat and I are usually 50% spot-on!

2 Likes

A play on the old weather stone...

However, the stone also does earthquakes :grinning_face:

4 Likes

Open Weather and AccuWeather certainly do not match right now.

Open Weather:



AccuWeather:
image
image

This show the problem I was having with Open Weather... it really likes to report 100% chance of rain way too often.

I had to have AI modify the driver one more time, as the driver in the original post it was reporting rain in mm, so now there is a preference to change it to mm or inches. I will update the code in original post after I post this

Anyone using weather underground as a source? I don’t use it for watering but as an avid cyclist I find it to be mostly spot on to the hour in their forecasting for my specific northern Ohio location..

1 Like

Funny you mention that, as my Ecowitt Weather Station is a PWS contributor to Weather Underground. Since it is now one of the IBM Weather Company sites, it might get the same predictions as the Weather Channel. I will have to check it out, the Weather Channel predictions are pretty good, but they have no cheap personal-use api access tier for using with Hubitat.

I just found out that PWS contributors get free access to the Weather Underground APIs :grinning_face: As long as my personal weather station has been uploading data for five days, I can just log in with my account and generate a key. Mine hasn't been offline for years now.

OK, free is better than $24/year. I'm going to sic AI on writing a forecast driver using the Weather Underground APIs, after I get my free key.

I will also start comparing it to AccuWeather and The Weather Channel forecasts.

Edit: AI Says This below. I would get the same general forecasts as the Weather Channel, which is what I use for irrigation. The PWS data used for WU is probably only used to hyper-localize current conditions, so basically I can get free access to the Weather Channel forecast endpoints! :grin:

Because both platforms are owned by the same parent company (The Weather Company/IBM), they share the exact same underlying forecast models. However, they apply that data differently, resulting in distinct advantages for hyper-local versus general forecasting. [1, 2, 3]

Core Forecast Accuracy

  • The Models: Both The Weather Channel and Weather Underground draw from the same core IBM/The Weather Company forecasting models. [1]
  • General Accuracy: Because of this shared engine, their 1 to 2-day macro-level temperature and precipitation forecasts are typically identical. [1]
  • Accolade: The Weather Company has been independently recognized by ForecastWatch for maintaining a high rate of forecast accuracy across the United States. [1]

The Key Differences

  • Weather Underground: Excels at hyper-local microclimates. It sources real-time data from a vast network of over 250,000 user-operated personal weather stations. This means if you live in a valley or specific neighborhood that acts differently than the nearest official airport, WU may give you a more accurate read of your exact backyard, though it is occasionally prone to localized sensor errors. [1, 2]
  • The Weather Channel: Excels at regional trends and overall reliability. It relies primarily on official, professionally calibrated meteorological networks. It is less likely to be skewed by a faulty backyard sensor, making it more reliable for broader regional planning, severe weather alerts, and comprehensive national radar tracking. [1, 2, 3, 4]

I’ve had an Ambient PWS reporting to Weather Underground for about five years. The only watering we do is for some raised beds and a sizable pollinator garden. I use soil moisture sensors to automate those. Since we are on well water with no water table issues I don’t really concerned myself with not watering if rain is in the forecast, an occasional extra dose won’t hurt them. If the lawn gets dry that’s less mowing: makes me happy :wink:

That is what I used for years before this summer. I've really decided that moisture sensors are only good for irrigating plants in containers, where moisture is retained. In our ground gardens, the water really just drains through and away. A steep spike when the water is on, then a quick drop almost to where you started.

So I went to timed scheduled zone waterings this year, and I have been trying to make the watering smart with rain. The water bills in the summer get pretty high for irrigation, so in my case I really don't want to water if I don't have to.

I use my rain meter to track past rain for three days and current rain, and I have been using Open Weather for current day and future forecasts. I combine all that together to decide if a scheduled watering should be cancelled or delayed when it fires, based on past rain, current rain, current forecast, and future forecast, depending on the frequency of the watering schedule.

I was able to get my free key for the PWS APIs easily. I found there was a PWS Weather Underground API driver out there already, but it was overkill for me. I really just needed precip and chance of precip for today and the next three days.

Here is the Driver AI wrote. Deep Seek really stumbled on this one for some reason, so I used Gemini.

I will be using this one and not subscribing to AccuWeather APIs.

/**
 * Weather Underground PWS & Forecast Driver for Hubitat Elevation
 * Licensed under the Apache License, Version 2.0
 */
metadata {
    definition (
        name: "Weather Underground PWS with Forecast", 
        namespace: "custom", 
        author: "AI Collaborator"
    ) {
        capability "Sensor"
        capability "Refresh"
        capability "TemperatureMeasurement"
        capability "RelativeHumidityMeasurement"
        capability "PressureMeasurement"
        
        attribute "weather", "string"
        attribute "windSpeed", "number"
        attribute "windDirection", "number"
        attribute "windDirectionStr", "string"
        attribute "windGust", "number"
        attribute "solarRadiation", "number"
        attribute "uvIndex", "number"
        attribute "precipRate", "number"
        attribute "precipTotal", "number"
        attribute "dewPoint", "number"
        attribute "heatIndex", "number"
        attribute "windChill", "number"
        attribute "forecastTodayMax", "number"
        attribute "forecastTodayMin", "number"
        attribute "forecastTodayNarrative", "string"
        attribute "forecastTomorrowMax", "number"
        attribute "forecastTomorrowMin", "number"
        attribute "forecastTomorrowNarrative", "string"
        attribute "precipChanceToday", "number"
        attribute "precipChanceTomorrow", "number"
        attribute "precipChanceDay2", "number"
        attribute "precipChanceDay3", "number"
        attribute "rainExpectedToday", "number"
        attribute "rainExpectedTomorrow", "number"
        attribute "rainExpectedDay2", "number"
        attribute "rainExpectedDay3", "number"
        attribute "lastUpdateTime", "string"
    }
}

preferences {
    input name: "apiKey", type: "text", title: "Weather Underground API Key", required: true
    input name: "stationId", type: "text", title: "PWS Station ID", required: true
    input name: "unitSystem", type: "enum", title: "Unit System", 
          options: ["e", "m"], defaultValue: "e", required: true
    input name: "refreshInterval", type: "enum", title: "Poll Interval (Minutes)", 
          options: ["5", "10", "15", "30", "60"], defaultValue: "15", required: true
          
    // CHANGED TO BOOL: Renders as a true/false toggle slider component natively
    input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
}

def installed() { 
    log.info "WU PWS Driver Installed"
    initialize() 
}

def updated() { 
    log.info "WU PWS Driver Updated"
    unschedule()
    initialize() 
}

def initialize() {
    def cronMin = settings.refreshInterval ?: "15"
    schedule("0 */" + cronMin + " * ? * *", refresh)
    refresh()
}

def refresh() {
    if (logEnable) log.debug "Weather Underground refresh initiated"
    if (!settings.apiKey || !settings.stationId) { 
        log.warn "Missing configuration attributes"
        return 
    }
    def units = settings.unitSystem ?: "e"
    def qMap = [stationId: settings.stationId, format: "json", units: units, apiKey: settings.apiKey]
    
    // RESTORED: Fixed back to api.weather.com exactly as your working copy required
    asynchttpGet("parseCurrentResponse", [uri: "https://api.weather.com", path: "/v2/pws/observations/current", query: qMap, timeout: 10])
}
def parseCurrentResponse(response, data) {
    if (response.hasError() || response.status != 200) { 
        log.error "Current observations download failed with status code: ${response.status}"
        return 
    }
    try {
        def json = parseJson(response.data)
        if (logEnable) log.debug "Current Obs Response JSON correctly parsed"
        def obs = json.observations.get(0)
        def units = settings.unitSystem ?: "e"
        def u = (units == "e") ? obs.imperial : obs.metric
        
        device.sendEvent(name: "temperature", value: u.temp, unit: (units == "e" ? "°F" : "°C"))
        device.sendEvent(name: "dewPoint", value: u.dewpt, unit: (units == "e" ? "°F" : "°C"))
        device.sendEvent(name: "heatIndex", value: u.heatIndex, unit: (units == "e" ? "°F" : "°C"))
        device.sendEvent(name: "windChill", value: u.windChill, unit: (units == "e" ? "°F" : "°C"))
        device.sendEvent(name: "windSpeed", value: u.windSpeed, unit: (units == "e" ? "mph" : "km/h"))
        device.sendEvent(name: "windGust", value: u.windGust, unit: (units == "e" ? "mph" : "km/h"))
        device.sendEvent(name: "pressure", value: u.pressure, unit: (units == "e" ? "inHg" : "hPa"))
        device.sendEvent(name: "precipRate", value: u.precipRate, unit: (units == "e" ? "in/hr" : "mm/hr"))
        device.sendEvent(name: "precipTotal", value: u.precipTotal, unit: (units == "e" ? "in" : "mm"))
        device.sendEvent(name: "humidity", value: obs.humidity, unit: "%")
        device.sendEvent(name: "windDirection", value: obs.winddir, unit: "°")
        device.sendEvent(name: "solarRadiation", value: obs.solarRadiation, unit: "w/m²")
        device.sendEvent(name: "uvIndex", value: obs.uv)
        
        def dirs = ["N","NNE","NE","ENE","E","ESE","SE","SSE","S",
                    "SSW","SW","WSW","W","WNW","NW","NNW","N"]
        def idx = (obs.winddir != null) ? Math.round(((obs.winddir.toDouble() % 360) / 22.5)).toInteger() : 0
        device.sendEvent(name: "windDirectionStr", value: dirs.get(idx))
        
        // ADDED ATTRIBUTE: Safely populates the lastUpdateTime field
        def lTime = new Date().format("MM/dd/yyyy hh:mm:ss a", location.timeZone)
        device.sendEvent(name: "lastUpdateTime", value: lTime)
        
        // RESTORED: Fixed back to api.weather.com exactly as your working copy required
        asynchttpGet("parseForecastResponse", [uri: "https://api.weather.com", path: "/v3/wx/forecast/daily/5day", query: [geocode: obs.lat.toString() + "," + obs.lon.toString(), format: "json", units: units, language: "en-US", apiKey: settings.apiKey], timeout: 10], [currentTemp: u.temp])
    } catch (Exception e) { 
        log.error "Current Parse Error: " + e.message 
    }
}
def parseForecastResponse(response, data) {
    if (response.hasError() || response.status != 200) { 
        log.error "Extended forecast download failed with status code: ${response.status}"
        return 
    }
    try {
        def json = parseJson(response.data)
        if (logEnable) log.debug "Forecast Response JSON correctly parsed"
        def units = settings.unitSystem ?: "e"
        def rainUnit = (units == "e" ? "in" : "mm")
        def todayMax = json.temperatureMax.get(0)
        if (todayMax == null && data != null) {
            todayMax = data.currentTemp
        }
        device.sendEvent(name: "forecastTodayMax", value: todayMax)
        device.sendEvent(name: "forecastTodayMin", value: json.temperatureMin.get(0))
        device.sendEvent(name: "forecastTodayNarrative", value: json.narrative.get(0))
        device.sendEvent(name: "forecastTomorrowMax", value: json.temperatureMax.get(1))
        device.sendEvent(name: "forecastTomorrowMin", value: json.temperatureMin.get(1))
        device.sendEvent(name: "forecastTomorrowNarrative", value: json.narrative.get(1))
        
        def rToday = (json.qpf != null) ? json.qpf.get(0) : 0.0
        def rTomorrow = (json.qpf != null) ? json.qpf.get(1) : 0.0
        def rDay2 = (json.qpf != null) ? json.qpf.get(2) : 0.0
        def rDay3 = (json.qpf != null) ? json.qpf.get(3) : 0.0
        
        if (json.daypart != null && json.daypart.get(0) != null) {
            def dp = json.daypart.get(0)
            def names = dp.daypartName
            def pToday = 0, pTomorrow = 0, pDay2 = 0, pDay3 = 0
            def qpToday = 0.0, qpTomorrow = 0.0, qpDay2 = 0.0, qpDay3 = 0.0
            for (int i = 0; i < names.size(); i++) {
                def n = names.get(i)
                if (n == null) continue
                def pct = dp.precipChance.get(i) != null ? dp.precipChance.get(i).toInteger() : 0
                def amt = dp.qpf.get(i) != null ? dp.qpf.get(i).toDouble() : 0.0
                if (n.contains("Today") || n.contains("Tonight")) {
                    if (pct > pToday) pToday = pct
                    qpToday += amt
                } else if (n.contains("Tomorrow")) {
                    if (pct > pTomorrow) pTomorrow = pct
                    qpTomorrow += amt
                } else if (i == 4 || i == 5) {
                    if (pct > pDay2) pDay2 = pct
                    qpDay2 += amt
                } else if (i == 6 || i == 7) {
                    if (pct > pDay3) pDay3 = pct
                    qpDay3 += amt
                }
            }
            device.sendEvent(name: "precipChanceToday", value: pToday, unit: "%")
            device.sendEvent(name: "precipChanceTomorrow", value: pTomorrow, unit: "%")
            device.sendEvent(name: "precipChanceDay2", value: pDay2, unit: "%")
            device.sendEvent(name: "precipChanceDay3", value: pDay3, unit: "%")
            if (rToday == 0.0) rToday = qpToday
            if (rTomorrow == 0.0) rTomorrow = qpTomorrow
            if (rDay2 == 0.0) rDay2 = qpDay2
            if (rDay3 == 0.0) rDay3 = qpDay3
            def txt = dp.wxPhraseLong.get(0) ?: dp.wxPhraseLong.get(1)
            if (txt != null) {
                device.sendEvent(name: "weather", value: txt)
            }
        }
        device.sendEvent(name: "rainExpectedToday", value: rToday, unit: rainUnit)
        device.sendEvent(name: "rainExpectedTomorrow", value: rTomorrow, unit: rainUnit)
        device.sendEvent(name: "rainExpectedDay2", value: rDay2, unit: rainUnit)
        device.sendEvent(name: "rainExpectedDay3", value: rDay3, unit: rainUnit)
    } catch (Exception e) {
        log.error "Forecast Parse Error: " + e.message
    }
}

Edit: Updated code with a few fixes, debug logging in now a bool selection, the forecastTodayMax will no longer report null late in the day, and I added a timestamp attribute to report the last update.