All the information below reads the BoM radar, submits it to LLM Vision using OpenAI, reads the response in to an MQTT sensor and creates an animated GIF for use in Lovelace.
The aim is to get the approximate arrival time of a severe storm at my location, plus any associated data, but only run the automation if severe weather sensors have already been tripped which I created from my Weatherflow Tempest data.
I was looking at this from the point of view of replicating AI detection of storms from here: https://www.youtube.com/watch?v=SjGDH2vCv2I
But I needed all 5 separate BoM radar images to be correctly analysed by LLM Vision.
So I used this as a base:
Plus you will need LLM Vision on your HA setup and link it to OpenAI and get an API key that works which will cost you money!!! $10 min.
And on my Synology NAS I did thisβ¦
NOTE: I am not an expert. Do not ask me for advice!!! If you can get this working great.
STEP 1: Create a folder to contain all your Docker files such as /volume1/docker/weather_gif. Within that create two directories: app and images. On home assistant, create a folder to receive the files via smb: /config/www/weather_gif.
To the app directory save the background image from the BoM site as IDR.legend.0.png:
https://www.bom.gov.au/products/radar_transparencies/IDR.legend.0.png
Note that the final directory structure will look like this:
docker/
βββ weather_gif/
βββ app/
β βββ Dockerfile
β βββ IDR.legeng.0.png
β βββ requirements.txt
β βββ weather_gif.py
β βββ weather_gif_script.py
βββ images/
βββ image_1.png
βββ image_2.png
βββ image_3.png
βββ image_4.png
βββ image_5.png
βββ radar_animated.gif
STEP 2: In the docker app folder create a python script called weather_gif.py to build the image and send it to Home Assistant and put it in that folder. In the code, select your own radar source and set your SMB parameters for your local HA setup.
#!/usr/bin/env python3
import io
import ftplib
import smbclient
import os
from PIL import Image
from datetime import datetime
import pytz
# The ID for our radar image
product_id = 'IDR952' # CHANGE THIS!
# Timezone for timestamp display
local_timezone = 'Australia/Melbourne' # CHANGE THIS!
# Home Assistant SMB share details
smb_server = "192.168.X.X" # CHANGE THIS!
smb_share_name = "config"
smb_username = os.environ.get('SMB_USERNAME', 'XXXXXX') # CHANGE THIS!
smb_password = os.environ.get('SMB_PASSWORD', 'XXXXXX') # CHANGE THIS!
smb_remote_path = "/www/weather_gif"
# Timestamp file name
timestamp_filename = "radar_last_update.txt"
# Animated GIF settings
animated_gif_filename = "radar_animated.gif"
gif_duration = 500 # Duration of each frame in milliseconds
gif_loop = 0 # 0 = infinite loop
# Local directory containing images (mounted volume)
local_image_directory = "/images"
# Legend image path (in the Docker build directory)
legend_image_path = "/app/IDR.legend.0.png"
# Create the directory if it doesn't exist
os.makedirs(local_image_directory, exist_ok=True)
# List to store the images and filenames
frames = []
saved_filenames = []
# The layers that we want
layers = ['background', 'catchments', 'topography', 'locations']
try:
# Load the legend image as the base
base_image = None
if os.path.exists(legend_image_path):
base_image = Image.open(legend_image_path).convert('RGBA')
print(f"Loaded legend image as base: {legend_image_path} - Size: {base_image.size}")
else:
print(f"ERROR: Legend image not found at {legend_image_path}")
raise FileNotFoundError(f"Legend image required at {legend_image_path}")
# Connect to FTP server once
ftp = ftplib.FTP('ftp.bom.gov.au')
ftp.login()
# Build composite layers on top of the legend base
ftp.cwd('anon/gen/radar_transparencies/')
for layer in layers:
filename = f"{product_id}.{layer}.png"
file_obj = io.BytesIO()
ftp.retrbinary('RETR ' + filename, file_obj.write)
file_obj.seek(0) # Reset pointer to start
image = Image.open(file_obj).convert('RGBA')
# Paste each layer at (0, 0) on top of the base (which includes the legend)
base_image.paste(image, (0, 0), image)
print(f"Added layer: {layer}")
print(f"Base image with all layers size: {base_image.size}")
# Get radar images from the same FTP connection
ftp.cwd('/anon/gen/radar/') # Use absolute path
# Get all matching radar files
all_files = [file for file in ftp.nlst()
if file.startswith(product_id)
and file.endswith('.png')]
# Sort by timestamp in filename (format: IDR022.T.YYYYMMDDHHmm.png)
# Extract timestamp for sorting
def get_timestamp(filename):
try:
parts = filename.split('.')
if len(parts) >= 3:
return parts[2] # YYYYMMDDHHmm
return filename
except:
return filename
sorted_files = sorted(all_files, key=get_timestamp)
# Get the last 5 (most recent) radar images
files = sorted_files[-5:]
print(f"Found {len(all_files)} total radar files")
print(f"Selected most recent 5: {files}")
# Download and composite the radar images
for file in files:
file_obj = io.BytesIO()
try:
ftp.retrbinary('RETR ' + file, file_obj.write)
file_obj.seek(0) # Reset pointer
image = Image.open(file_obj).convert('RGBA')
frame = base_image.copy()
# Paste radar data at (0, 0) - it will overlay on the radar area, not the legend
frame.paste(image, (0, 0), image)
frames.append(frame)
print(f"Processed {file}")
except ftplib.all_errors as e:
print(f"Error downloading {file}: {e}")
ftp.quit()
# Save individual PNG images to the local directory
for i, img in enumerate(frames):
filename = f"image_{i+1}.png"
filepath = os.path.join(local_image_directory, filename)
img.save(filepath)
saved_filenames.append(filename)
print(f"Saved {filepath}")
# Save animated GIF to local directory
if frames:
gif_filepath = os.path.join(local_image_directory, animated_gif_filename)
frames[0].save(
gif_filepath,
save_all=True,
append_images=frames[1:],
duration=gif_duration,
loop=gif_loop,
optimize=False
)
saved_filenames.append(animated_gif_filename)
print(f"Saved animated GIF: {gif_filepath}")
# Extract timestamp from the last radar file
last_radar_file = files[-1] if files else None
timestamp_content = None
if last_radar_file:
# BOM radar files are formatted like: IDR022.T.202509302234.png
try:
parts = last_radar_file.split('.')
if len(parts) >= 3:
# The timestamp is in the third part (index 2)
datetime_str = parts[2] # YYYYMMDDHHmm format
# Parse the datetime (BOM times are in UTC)
dt_utc = datetime.strptime(datetime_str, "%Y%m%d%H%M")
dt_utc = pytz.utc.localize(dt_utc)
# Convert to local timezone
local_tz = pytz.timezone(local_timezone)
dt_local = dt_utc.astimezone(local_tz)
# Format both UTC and local times for the file content
utc_time = dt_utc.strftime("%Y-%m-%d %H:%M UTC")
local_time = dt_local.strftime("%Y-%m-%d %H:%M %Z")
timestamp_content = f"UTC Time: {utc_time}; Local Time ({local_timezone}): {local_time}\n"
print(f"Last radar image - UTC: {utc_time}, Local: {local_time}")
except Exception as e:
print(f"Error parsing timestamp from {last_radar_file}: {e}")
timestamp_content = f"Last file: {last_radar_file}\nError parsing timestamp: {e}\n"
# Transfer to SMB share
if saved_filenames:
# Configure SMB client
smbclient.ClientConfig(username=smb_username, password=smb_password)
# Build SMB destination path
smb_destination_path = f"//{smb_server}/{smb_share_name}{smb_remote_path}"
# Create destination directory
try:
smbclient.makedirs(smb_destination_path, exist_ok=True)
except Exception as e:
print(f"Warning: Could not create directory: {e}")
# Transfer each saved file (PNGs and GIF)
for file_name in saved_filenames:
local_file_path = os.path.join(local_image_directory, file_name)
smb_file_path = f"{smb_destination_path}/{file_name}"
print(f"Transferring {file_name}...")
try:
with open(local_file_path, 'rb') as local_file:
with smbclient.open_file(smb_file_path, mode="wb") as smb_file:
smb_file.write(local_file.read())
print(f"Successfully transferred {file_name}")
except Exception as e:
print(f"Failed to transfer {file_name}: {e}")
# Write timestamp file to SMB share
if timestamp_content:
timestamp_file_path = f"{smb_destination_path}/{timestamp_filename}"
try:
with smbclient.open_file(timestamp_file_path, mode="w") as timestamp_file:
timestamp_file.write(timestamp_content)
print(f"Successfully wrote timestamp file: {timestamp_filename}")
except Exception as e:
print(f"Failed to write timestamp file: {e}")
except ftplib.all_errors as e:
print(f"FTP Error: {e}")
except smbclient.exceptions.SMBException as e:
print(f"SMB Error: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
finally:
smbclient.reset_connection_cache()```
STEP 3: In the docker folder create a requirements.txt file
Pillow
smbprotocol
pin
pytz
STEP 4: In the docker app folder create a Dockerfile (litterally called Dockerfile with an uppercase D)
FROM python:3.13.7
WORKDIR /app
COPY weather_gif.py .
COPY requirements.txt .
COPY IDR.legend.0.png .
RUN pip install -r requirements.txt
CMD ["python", "weather_gif.py"]
STEP 5: Create compose.yaml file and put it somewhere near your other compose.yamlβs. It does not need to be in the same folder as the previous files. E.g. /volume1/docker/projects/weather_gif_compose
version: '3.8'
services:
weather_gif:
container_name: weather_gif
build: /volume1/docker/weather_gif/app
volumes:
- /volume1/docker/weather_gif/images:/images
STEP 6: In the docker app folder create a script to build and run the project. Call it weather_gif_script.py or something more useful
cd /volume1/docker/projects/weather_gif_compose && docker-compose up --build --force-recreate --abort-on-container-exit
STEP 7: Create a user-defined script on Synology to execute that script every 10 minutes (or whatever you consider appropriate).
STEP 8: Use the images in a Home Assistant automation, such as one that talks to LLM Vision.
STEP 9: I created an HA automation to send the images to LLM Vision:
You will need to update the sensor that triggers this, create the input_text helper to store the output, change the location of your radar station and change the local timezone. If you donβt use the 256k radar image, change this too.
This automation is still pretty rudimentry but seems to get the right data in to the helper.
alias: Weather - AI Alert System
description: >-
This automation provides AI-powered weather alerts based on radar analysis and
BoM warnings.
triggers:
- entity_id:
- binary_sensor.bom_severe_thunderstorm_warning
trigger: state
to: "on"
conditions: []
actions:
- alias: Analyze current doppler radar map
action: llmvision.image_analyzer
metadata: {}
data:
provider: FROM_OPENAI_ACCOUNT
model: gpt-4o
include_filename: false
max_tokens: 264
temperature: 0.5
message: >
Analyze 5 sequential radar images from Rainbow Wimmera (256km radius, center
-35.9955311297678, 142.01537171440987). Report how rain may impact home zone
({{ state_attr('zone.home','latitude') }},{{
state_attr('zone.home','longitude') }}).
Analyse whether a severe, damaging storm may strike the home zone within the
next hour.
Output must be a single valid JSON object, nothing else, no markdown, no code
fences, no commentary. Use exactly these keys: - current_rain_trend -
movement_dir_speed - arrival_time_home - intensity - home_zone_town_name -
last_image_time
Parse each radar image timestamp strictly as a UTC instant. As a reference,
image_5:png was recorded at {{
states('sensor.muckleford_south_bom_radar_last_update_raw') }}. Report all
converted times in the format "HH:MM AM/PM".
Example of required format (but with real data filled in): {
"current_rain_trend": "Rain is increasing and moving towards the home zone.",
"movement_dir_speed": "Eastward at a moderate speed.",
"arrival_time_home": "09:15 AM",
"intensity": "Heavy",
"damaging_storm_alert": "True",
"last_image_time": "08:54 AM",
"home_zone_town_name": "Newstead"
}
image_file: |-
/config/www/weather_gif/image_1.png
/config/www/weather_gif/image_2.png
/config/www/weather_gif/image_3.png
/config/www/weather_gif/image_4.png
/config/www/weather_gif/image_5.png
response_variable: radar_visual_analysis
enabled: true
- action: mqtt.publish
metadata: {}
data:
qos: 0
retain: true
topic: weather/alert
payload: "{{ radar_visual_analysis.response_text }}"
mode: single
initial_state: "on"
STEP 10: Create an MQTT sensor to read the Json-formatted response:
mqtt:
sensor:
- name: "BoM Radar Forecast"
unique_id: bom_radar_forecast
state_topic: "weather/alert"
value_template: "{{ value_json.arrival_time_home }}"
json_attributes_topic: "weather/alert"
Simples!