[RELEASE] Australian Bureau of Meteorology Data - Radar Images Data File

It's on my dashboard on my nest hub that's sitting on my desk! Yeah I had to run out to put the car under cover :grin:. Biggest hail I've seen in a few years, just under 10mm, luckily for only a short period.

Anyway I'm getting the same as you - no rain or clouds, only the background map.

Ok, at least we have something in common.... that is not destructive...

It's tempting to literally let the dust settle on their website update, but it feels like that may have already happened. My guess is the site layout I rely on has changed. I'll see what I can find out. Alternatively I could refer the issue to the guys in Colorado I borrowed the code from (?).... :slight_smile: Last I remember (2-3 years ago) I think there was also an FTP option, so I might have an alternative if the site has changed.

Awesome. thanks! Looks like the feedback on the new site is pretty negative. I only use the phone app which is pretty much unchanged.

Yeah, me too

Might take a bit more brain power than I can muster tonight... Hopefully I will have some time in the next few weeks.

No worries - when you have time to spare!

Lol I just spent the last half hour using AI to help solve this, this is what I got back eventually (after a few twists and turns) which actually works. As suspected BOM changed the website but fortunately left the legacy maps mostly intact.

Please have a look at this when you can although the changes look pretty benign to me.

/*

  • Australian BoM Weather Radar Images Data File Driver
  • Retrieves URLs for radar images from the Australian Bureau of Meteorology (BOM) and stores them in a local file
  • that is linked to a separate device to display the radar images

*/
metadata {
definition(name: 'BoM Radar Images Data File', namespace: 'simnet', author: 'sburke781') {
capability 'Actuator'

  attribute 'lastupdate', 'string'

    command 'refresh'
}

}

preferences {
input (name: 'idr', type: 'text', title: 'Observation ID number (e.g. IDR043)', required: true, defaultValue: '')
input (name: 'dataFileName', type: 'text', title: 'Data File Name (inc. file extension, e.g. BoMRadar.json)', required: true, defaultValue: 'BoMRadar.json')

    input (name: 'locations',    type: 'bool',   title:'Locations Image', description: 'Include Locations Image?', defaultValue: true,  required: true )
    input (name: 'range',        type: 'bool',   title:'Range Image', description: 'Include Range Image?',     defaultValue: false, required: true )
    input (name: 'topography',   type: 'bool',   title:'Topography Image', description: 'Include Topography?',      defaultValue: true,  required: true )
    
  input (name: 'AutoPolling',     type: 'bool',   title:'Automatic Polling', description: 'Enable / Disable automatic polling',          defaultValue: true, required: true )
    input (name: 'PollingInterval', type: 'string', title:'Polling Interval',  description: 'Number of minutes between automatic updates', defaultValue: 15,   required: true )

    input (name: 'DebugLogging', type: 'bool',   title:'Enable Debug Logging',                   defaultValue: false)
    input (name: 'WarnLogging',  type: 'bool',   title:'Enable Warning Logging',                 defaultValue: true )
    input (name: 'ErrorLogging', type: 'bool',   title:'Enable Error Logging',                   defaultValue: true )
    input (name: 'InfoLogging',  type: 'bool',   title:'Enable Description Text (Info) Logging', defaultValue: false)

}

// Standard device methods
void installed() {
debugLog('installed: BoM Radar Images device installed')
}

void updated() {
debugLog('updated: update process called')
refresh()
updateAutoPolling()
}

void refresh() {
debugLog('refresh: Refreshing radar images')
retrieveImageURLs()
debugLog('refresh: Refresh complete')
}

// Preference setting management methods
void setIdr(String pidr) {
idr = pidr
infoLog("IDR set to ${pidr}")
}

void setDataFileName(String pdataFileName) {
dataFileName = pdataFileName
infoLog("Data file name set to ${pdataFileName}")
}

// General driver-specific methods
void retrieveImageURLs() {
debugLog('retrieveImageURLs: updating radar image data')

// Use the OLD regional BoM site which still works!
def getParams = [
    uri: "https://reg.bom.gov.au/products/${idr}.loop.shtml",
    headers: ['User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'],
    contentType: 'text/plain',
    textParser: true,
    timeout: 30
]

try {
    asynchttpGet('retrieveImageURLsCallback', getParams) 
} catch (Exception e) {
    errorLog "retrieveImageURLs: call to update radar images failed: ${e}"
}
debugLog('retrieveImageURLs: process complete')

}

void retrieveImageURLsCallback(response, data) {
if (response.hasError()) {
errorLog("retrieveImageURLsCallback: HTTP error - ${response.getErrorMessage()}")
return
}

if (response.getStatus() != 200) {
    errorLog("retrieveImageURLsCallback: Unexpected status code - ${response.getStatus()}")
    return
}

String shtmlResponse = response.getData()

String[] lines = shtmlResponse.split('\\r\\n|\\n|\\r')
def images = lines.findAll { it.startsWith('theImageNames[') }

debugLog("retrieveImageURLsCallback: Found ${images.size()} radar image lines")

if (!images || images.size() == 0) {
    warnLog("retrieveImageURLsCallback: No radar images found")
    return
}

String staticImagesJson = '{\n'
int b = 1
int f = 0
staticImagesJson += "\"background0${b}\": \"https://reg.bom.gov.au/products/radar_transparencies/${idr}.background.png\"\n"
if (topography) {
    b++
    staticImagesJson += ",\"background0${b}\": \"https://reg.bom.gov.au/products/radar_transparencies/${idr}.topography.png\"\n"
}
if (locations) {
    f++
    staticImagesJson += ",\"foreground0${f}\": \"https://reg.bom.gov.au/products/radar_transparencies/${idr}.locations.png\"\n"
}
if (range) {
    f++
    staticImagesJson += ",\"foreground0${f}\": \"https://reg.bom.gov.au/products/radar_transparencies/${idr}.range.png\"\n"
}
staticImagesJson += '}'
debugLog("retrieveImageURLsCallback: background images JSON = ${staticImagesJson}")

String imagesJson = '{\n'
int i = 1
images.each { line ->
    // Extract the filename using regex
    def matcher = (line =~ /"([^"]+\.png)"/)
    if (matcher.find()) {
        String imagePath = matcher.group(1)
        // Remove leading /radar/ if present to avoid duplication
        if (imagePath.startsWith('/radar/')) {
            imagePath = imagePath.substring(7)  // Remove "/radar/"
        }
        String fullUrl = "https://reg.bom.gov.au/radar/${imagePath}"
        imagesJson += "\"image${i}\": \"${fullUrl}\""
        if (i != images.size()) { imagesJson += ',' }
        imagesJson += '\n'
        debugLog("retrieveImageURLsCallback: Added image ${i}: ${fullUrl}")
        i++
    }
}
imagesJson += '}'
debugLog("retrieveImageURLsCallback: radar images JSON = ${imagesJson}")

updateDataFile(staticImagesJson, imagesJson)
String lastUpdate = new Date().format('yyyy-MM-dd HH:mm:ss')
device.sendEvent(name: 'lastupdate', value: "${lastUpdate}")

}

void updateDataFile(String pbackgrounds, String pimages) {
def imageCycle = findChildDevice('radar','ImageCycle');
if (imageCycle == null) {
createChildDevice('Image Cycle', 'radar', 'BOM Radar', 'ImageCycle')
imageCycle = findChildDevice('radar','ImageCycle');
}
imageCycle.setFullImageList(pbackgrounds,pimages)
}

// Child Device methods

String deriveChildDNI(String childDeviceId, String childDeviceType) {
return "${device.deviceNetworkId}-id${childDeviceId}-type${childDeviceType}"
}

def findChildDevice(String childDeviceId, String childDeviceType) {
getChildDevices()?.find { it.deviceNetworkId == deriveChildDNI(childDeviceId, childDeviceType)}
}

void createChildDevice(String childDeviceDriver, String childDeviceId, String childDeviceName, String childDeviceType) {
debugLog("createChildDevice: Creating Child Device: ${childDeviceId}, ${childDeviceName}, ${childDeviceType}")

def childDevice = findChildDevice(childDeviceId, childDeviceType)

if (childDevice == null) {
    childDevice = addChildDevice('simnet', childDeviceDriver, deriveChildDNI(childDeviceId, childDeviceType), [label: "${device.displayName} - ${childDeviceName}"])
    infoLog("createChildDevice: New ${childDeviceDriver} created -  ${device.displayName} - ${childDeviceName}")

}
else {
debugLog("createChildDevice: child device ${childDevice.deviceNetworkId} already exists")
}
}

//Automatic Polling methods

void poll() {
refresh()
}

void updateAutoPolling() {
String sched
debugLog('updateAutoPolling: Update Automatic Polling called, about to unschedule polling')
unschedule('poll')
debugLog('updateAutoPolling: Unscheduling of automatic polling is complete')

if (AutoPolling == true) {
sched = "0 0/${PollingInterval} * ? * * *"

   debugLog("updateAutoPolling: Setting up scheduled refresh with settings: schedule(\"${sched}\",poll)")
   try {
       schedule("${sched}",'poll')
       infoLog("Refresh scheduled every ${PollingInterval} minutes")
   }
   catch (Exception e) {
       errorLog('updateAutoPolling: Error - ' + e)
   }

}
else { infoLog('Automatic polling disabled') }
}

void getSchedule() { }

//Logging methods
void debugLog(String debugMessage) {
if (DebugLogging == true) { log.debug(debugMessage) }
}

void errorLog(String errorMessage) {
if (ErrorLogging == true) { log.error(errorMessage) }
}

void infoLog(String infoMessage) {
if (InfoLogging == true) { log.info(infoMessage) }
}

void warnLog(String warnMessage) {
if (WarnLogging == true) { log.warn(warnMessage) }
}

In my line of work (Data Engineer (technically a senior)) I should advocate for the use of AI... but I still somehow doubt it in certain circumatances.... But hey, if it works, it must be right :wink:

But seriously, great work, and thanks for sticking at it more than I had the motivation to.

Hopefully there isn't too much more hail for us down this way....

Perhaps you could produce your own AI version of the driver.... :wink: Maybe even @cometfish 's BOM driver... :grin:

hmmmmmmmmm :thinking:

The changes do mostly centre around the change from HTTP to HTTPS and a www address to reg., e.g.:

uri: "http://www.bom.gov.au/products/${idr}.loop.shtml",

vs

uri: "https://reg.bom.gov.au/products/${idr}.loop.shtml",

There are some other points that I will also look at... But shouldn't be a big update to make. If I get some time at the weekend I'll try to sort it out.

Nice work to make use of a tool I haven't really embraced like I should (AI).

Yeh I did ask it to also do some optimisations but thought it was good enough at this stage to get it working. It sent me initially around in circles to try and get the javascript output from the new site lollll.

I'm going to start using it a lot more (perplexity pro - recommended by my BIL - they are very keen to get this out there and they're doing deals with quite a few companies to give out free subs to account holders). Got a few projects on the go which I want to mash up code from different sources.

I have released an update (v1.3.1) to now handle changes to the BOM site, thanks to @rocketwiz for doing the work on this.

After you update the driver I believe for the changes to take effect you will need to open the parent device and click "save" on the device info tab.

FYI - @user1025

Hahaha 7 months later.

Here is cometfish’s original BOM weather device for anyone interested, updated for the current BOM site. As a result of the changes the ftp child device is also no longer needed and can be deleted.

As an aside, max today is a scorching 20C, Woooooo!

/*

  • BoMWeather driver - updated for current BoM web access
  • Gets current Australian weather info (observations and forecasts)
  • from the Bureau of Meteorology (BoM)
  • Updated changes:
    • Forecasts now use direct HTTPS retrieval instead of FTP/Telnet
    • Forecast period selection updated so maximum temperature is found more reliably
      */

metadata {
definition(name: "BoM Weather", namespace: "community", author: "cometfish", importUrl: "") {
capability "Sensor"
capability "TemperatureMeasurement"
capability "Relative Humidity Measurement"

    // observations
    attribute "temperature", "number"
    attribute "lastupdate", "date"
    attribute "apparent_temperature", "number"
    attribute "dew_point", "number"
    attribute "humidity", "number"
    attribute "windDirection", "number"
    attribute "windSpeed", "number"

    // forecast
    attribute "area", "string"
    attribute "iconCode", "number"
    attribute "weatherIcon", "string"
    attribute "forecastlastupdate", "date"
    attribute "forecastnextupdate", "date"
    attribute "weather", "string"
    attribute "rainProbability", "number"
    attribute "rainRange", "string"
    attribute "forecastLow", "number"
    attribute "forecastHigh", "number"
    attribute "tile", "string"

    command "poll"
    command "refresh"
    command "clearForecastNextUpdate"
    command "updateTile"
}

}

preferences {
section("URIs") {
input "idv", "text", title: "Observation ID number (eg. IDV60901)", required: true
input "wmo", "text", title: "Observation WMO number for your local weather station (eg. 95936)", required: true

    input "forecastidv", "text", title: "Forecast Precis ID number (eg. IDV10753)", required: true
    input "aac", "text", title: "Forecast Precis AAC code for your local weather station (eg. VIC_PT042)", required: true

    input "iconcustomurl", "text", title: "Icon custom url (Leave blank for default. Include trailing slash)", required: false

    input "autoPoll", "bool", required: true, title: "Enable Auto Poll", defaultValue: false
    input "pollInterval", "text", title: "Poll interval (which minutes of the hour to run on, eg. 5,35)", required: true, defaultValue: "5,35"
    input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
}

}

def installed() {
if (logEnable) log.debug "installed"
}

def logsOff() {
log.warn "debug logging disabled..."
device.updateSetting("logEnable", [value: "false", type: "bool"])
}

def updated() {
log.info "updated..."
log.warn "debug logging is: ${logEnable == true}"

unschedule()
if (autoPoll) {
    def pollIntervalCmd = settings?.pollInterval
    Random rand = new Random(now())
    def randomSeconds = rand.nextInt(60)
    def sched = "${randomSeconds} ${pollIntervalCmd} * * * ?"
    schedule("${sched}", "refresh")
}

}

def poll() {
refresh()
}

def clearForecastNextUpdate() {
sendEvent(name: "forecastnextupdate", value: new Date(), isStateChange: true)
}

def refresh() {
if (logEnable) log.debug "Refreshing data"

refreshForecast()
refreshObservations()

}

def refreshForecast() {
try {
def fnextupdate = device.currentValue("forecastnextupdate")
Date nextupdate = null

    if (fnextupdate != null) {
        try {
            nextupdate = Date.parseToStringDate(fnextupdate)
        } catch (Exception ignored) {
            nextupdate = null
        }
    }

    if (nextupdate == null || nextupdate < new Date()) {
        def forecastUrl = "https://www.bom.gov.au/fwo/${settings.forecastidv}.xml"
        if (logEnable) log.debug "Forecast URL: ${forecastUrl}"

        httpGet([
            uri: forecastUrl,
            contentType: "text/plain",
            requestContentType: "application/xml",
            textParser: true,
            timeout: 30,
            headers: [
                "User-Agent": "Mozilla/5.0"
            ]
        ]) { resp ->
            if (resp?.status == 200) {
                String xmlText = resp.data?.text ?: resp.data?.toString()
                if (xmlText) {
                    readXMLData(xmlText)
                } else {
                    log.warn "Forecast response was empty"
                }
            } else {
                log.warn "Forecast fetch failed: HTTP ${resp?.status}"
            }
        }
    } else {
        if (logEnable) log.info "Forecast data still current, will not refresh until: ${nextupdate}"

        def icontype = "day"
        def nowdate = new Date()
        if (location?.sunrise && location?.sunset) {
            if (nowdate < location.sunrise || nowdate >= location.sunset) icontype = "night"
        }

        if (state.icontype != icontype) {
            state.icontype = icontype
            def iconCodeStr = device.currentValue("iconCode")?.toString()

            if (iconCodeStr) {
                def iconurl = settings.iconcustomurl
                if (iconurl == null || iconurl == '') {
                    iconurl = "https://raw.githubusercontent.com/cometfish/hubitat_driver_bomweather/master/images/white/"
                }
                def wIcon = "${iconurl}${iconCodeStr}${icontype}.png"
                sendEvent(name: "weatherIcon", value: wIcon, isStateChange: true)

                def vals = [:]
                vals.weatherIcon = device.currentValue("weatherIcon")
                vals.weather = device.currentValue("weather")
                vals.forecastHigh = device.currentValue("forecastHigh")
                vals.temperature = device.currentValue("temperature")
                vals.apparent_temperature = device.currentValue("apparent_temperature")
                vals.windDirection = device.currentValue("windDirection")
                vals.windSpeed = device.currentValue("windSpeed")
                updateTileWithVals(vals)
            }
        }
    }
} catch (Exception e) {
    log.warn "Refresh call forecasts failed: ${e.message}"
}

}

def refreshObservations() {
try {
def url = "https://www.bom.gov.au/fwo/${settings.idv}/${settings.idv}.${settings.wmo}.json"
if (logEnable) log.debug "Observations URL: ${url}"

    httpGet([
        uri: url,
        contentType: "application/json",
        timeout: 30,
        headers: [
            "User-Agent": "Mozilla/5.0"
        ]
    ]) { resp ->
        if (resp?.status == 200 && resp?.data?.observations?.data) {
            def obs = resp.data.observations.data[0]

            def date = Date.parse("yyyyMMddHHmmssX", obs.aifstime_utc + "Z")
            sendEvent(name: "lastupdate", value: date, isStateChange: true)

            sendEvent(name: "temperature", value: obs.air_temp, unit: "°C", isStateChange: true)
            sendEvent(name: "apparent_temperature", value: obs.apparent_t, unit: "°C", isStateChange: true)
            sendEvent(name: "dew_point", value: obs.dewpt, unit: "°C", isStateChange: true)
            sendEvent(name: "humidity", value: obs.rel_hum, unit: "%", isStateChange: true)

            def deg = windDirToDegrees(obs.wind_dir)
            sendEvent(name: "windDirection", value: deg, unit: "°", isStateChange: true)
            sendEvent(name: "windSpeed", value: obs.wind_spd_kmh, unit: "kmh", isStateChange: true)

            def vals = [:]
            vals.weatherIcon = device.currentValue("weatherIcon")
            vals.weather = device.currentValue("weather")
            vals.forecastHigh = device.currentValue("forecastHigh")
            vals.temperature = obs.air_temp
            vals.apparent_temperature = obs.apparent_t
            vals.windDirection = deg
            vals.windSpeed = obs.wind_spd_kmh
            updateTileWithVals(vals)
        } else {
            log.warn "Observations fetch failed: HTTP ${resp?.status}"
        }
    }
} catch (Exception e) {
    log.warn "Refresh observations failed: ${e.message}"
}

}

private Integer windDirToDegrees(String dir) {
Map dirs = [
"N": 0,
"NNE": 23,
"NE": 45,
"ENE": 68,
"E": 90,
"ESE": 113,
"SE": 135,
"SSE": 158,
"S": 180,
"SSW": 203,
"SW": 225,
"WSW": 248,
"W": 270,
"WNW": 293,
"NW": 315,
"NNW": 338,
"CALM": 0
]
return (dirs[dir ?: "CALM"] ?: 0) as Integer
}

private readXMLData(String xmlText) {
if (logEnable) log.debug "Parsing forecast XML"

def fcdata = parseXML(xmlText)

def date = Date.parse("yyyy-MM-dd'T'HH:mm:ssX", fcdata.amoc.'issue-time-utc'.text())
sendEvent(name: "forecastlastupdate", value: date, isStateChange: true)

date = Date.parse("yyyy-MM-dd'T'HH:mm:ssX", fcdata.amoc.'next-routine-issue-time-utc'.text())
sendEvent(name: "forecastnextupdate", value: date, isStateChange: true)

def local = fcdata.forecast.area.find { it.@aac == settings.aac }
if (!local) {
    log.warn "No matching AAC found in forecast XML for ${settings.aac}"
    return
}

sendEvent(name: "area", value: local.@description.toString(), isStateChange: true)

def today = local.'forecast-period'.find { period ->
    period.'**'.find { it.@type == 'air_temperature_maximum' }
}

if (!today) {
    today = local.'forecast-period'.find { it.@index == "1" } ?: local.'forecast-period'.find { it.@index == "0" }
}

if (!today) {
    log.warn "No suitable forecast-period found"
    return
}

if (logEnable) {
    log.debug "Using forecast-period index=${today.@index}"
}

def el = today.'**'.find { it.@type == 'precis' }
def w = el?.text()
sendEvent(name: "weather", value: w, isStateChange: true)

el = today.'**'.find { it.@type == 'probability_of_precipitation' }
if (el?.text()) {
    def rp = el.text().replace("%", "").trim()
    if (rp?.isInteger()) {
        sendEvent(name: "rainProbability", value: rp.toInteger(), unit: "%", isStateChange: true)
    }
}

el = today.'**'.find { it.@type == 'precipitation_range' }
if (el?.text()) {
    sendEvent(name: "rainRange", value: el.text(), isStateChange: true)
}

el = today.'**'.find { it.@type == 'air_temperature_minimum' }
if (el?.text()) {
    sendEvent(name: "forecastLow", value: el.text(), unit: "°C", isStateChange: true)
}

el = today.'**'.find { it.@type == 'air_temperature_maximum' }
def fhigh = el?.text()
if (fhigh != null && fhigh != '') {
    sendEvent(name: "forecastHigh", value: fhigh, unit: "°C", isStateChange: true)
} else {
    log.warn "No air_temperature_maximum found in selected forecast period"
}

el = today.'**'.find { it.@type == 'forecast_icon_code' }
def iconCodeStr = el?.text()
if (iconCodeStr) {
    sendEvent(name: "iconCode", value: iconCodeStr.toInteger(), isStateChange: true)
}

def icontype = "day"
def nowdate = new Date()
if (location?.sunrise && location?.sunset) {
    if (nowdate < location.sunrise || nowdate >= location.sunset) icontype = "night"
}
state.icontype = icontype

def iconurl = settings.iconcustomurl
if (iconurl == null || iconurl == '') {
    iconurl = "https://raw.githubusercontent.com/cometfish/hubitat_driver_bomweather/master/images/white/"
}

def wIcon = iconCodeStr ? "${iconurl}${iconCodeStr}${icontype}.png" : ""
if (wIcon) {
    sendEvent(name: "weatherIcon", value: wIcon, isStateChange: true)
    sendEvent(name: "tile", value: "<br /><img src=\"" + wIcon + "\" /><br />" + w, isStateChange: true)
}

def vals = [:]
vals.weatherIcon = wIcon ?: device.currentValue("weatherIcon")
vals.weather = w ?: device.currentValue("weather")
vals.forecastHigh = (fhigh != null && fhigh != '') ? fhigh : device.currentValue("forecastHigh")
vals.temperature = device.currentValue("temperature")
vals.apparent_temperature = device.currentValue("apparent_temperature")
vals.windDirection = device.currentValue("windDirection")
vals.windSpeed = device.currentValue("windSpeed")
updateTileWithVals(vals)

}

def updateTile() {
def vals = [:]
vals.weatherIcon = device.currentValue("weatherIcon")
vals.weather = device.currentValue("weather")
vals.forecastHigh = device.currentValue("forecastHigh")
vals.temperature = device.currentValue("temperature")
vals.apparent_temperature = device.currentValue("apparent_temperature")
vals.windDirection = device.currentValue("windDirection")
vals.windSpeed = device.currentValue("windSpeed")

updateTileWithVals(vals)

}

def updateTileWithVals(vals) {
def windArrow = ""
if ((vals.windSpeed ?: 0) != 0) {
windArrow = """↑ """
}

sendEvent(name: "tile", value: """
.wfc {display:inline-block;padding:0 8px;font-size:12px;} span.v {font-size:20px;}
Today

${vals.weather ?: ''}
Max: ${vals.forecastHigh ?: '-'}°C

${vals.temperature ?: '-'}°C
Currently
${vals.apparent_temperature ?: '-'}°C
Feels like
${windArrow}${vals.windSpeed ?: 0}km/h
Wind
""", isStateChange: true) }

def parseXML(String xml) {
return new XmlSlurper().parseText(xml)
}

Thanks @rocketwiz for the update and of course to @sburke781 for the original. Very helpful as I use the forecastHigh to to drive my blinds