This is less a project and more a proof of concept for creating an animated radar display for ESPHome based displays. It is from a working project - my LVGL weather display on a 7 inch Waveshare display with an ESP32-S3 MCU. It should work on any ESP32 with sufficient PSRAM.
The majority of the data processing is in this case done in HA using templates. It could have been done on an ESP32 but for this project memory is a concern, the project is at the moment 2500 lines of yaml with more to come.
Radar data is from Rainviewer, and is provided as a 256x256 tile with transparent background, this needs to be combined with a background image the same size and zoom from a suitable map provider. I used for the first pass Yandex - mainly because they don’t require an access token, but I will change this and possibly make the view dynamic sometime in the future.
Other tile sizes are available from Rainviewer but for the PoC I stuck with 256x256.
This is the code in action - unfortunately no rain at the moment so it looks like a static map.
Rainviewer.com uses a fairly simple api. The list of current radar images is provided at a static url: https://api.rainviewer.com/public/weather-maps.json
The data returned from this url is in json format, the fields include “host” - which is the host to download images from and some data structures - "radar:“past” and “nowcast” and “satellite:infrared”. “Past” is a list of radar timestamps up to the current time and includes “path”, which is the path that points to the image data for that timestamp. “nowcast” is future timestamps, i.e predicted radar data and “infrared” is satellite data.
As sample of the returned data is:
{"version":"2.0","generated":1748402428,"host":"https://tilecache.rainviewer.com","radar":{"past":[{"time":1748395200,"path":"/v2/radar/1748395200"},{"time":1748395800,"path":"/v2/radar/1748395800"},{"time":1748396400,"path":"/v2/radar/1748396400"},{"time":1748397000,"path":"/v2/radar/1748397000"},{"time":1748397600,"path":"/v2/radar/1748397600"},{"time":1748398200,"path":"/v2/radar/1748398200"},{"time":1748398800,"path":"/v2/radar/1748398800"},{"time":1748399400,"path":"/v2/radar/1748399400"},{"time":1748400000,"path":"/v2/radar/1748400000"},{"time":1748400600,"path":"/v2/radar/1748400600"},{"time":1748401200,"path":"/v2/radar/1748401200"},{"time":1748401800,"path":"/v2/radar/1748401800"},{"time":1748402400,"path":"/v2/radar/1748402400"}],"nowcast":[{"time":1748403000,"path":"/v2/radar/nowcast_a57dabd447dc"},{"time":1748403600,"path":"/v2/radar/nowcast_a57dc0420fb9"},{"time":1748404200,"path":"/v2/radar/nowcast_a57d11520394"}]},"satellite":{"infrared":[{"time":1748395200,"path":"/v2/satellite/b96f0bcc25d2"},{"time":1748395800,"path":"/v2/satellite/39cb4ed70cd9"},{"time":1748396400,"path":"/v2/satellite/1295867e57a8"},{"time":1748397000,"path":"/v2/satellite/ef966831832c"},{"time":1748397600,"path":"/v2/satellite/e715be217a38"},{"time":1748398200,"path":"/v2/satellite/f65d8731c09f"},{"time":1748398800,"path":"/v2/satellite/a1b4ffb73588"},{"time":1748399400,"path":"/v2/satellite/1335239d29f7"},{"time":1748400000,"path":"/v2/satellite/28efa8c99561"},{"time":1748400600,"path":"/v2/satellite/65317e03056c"},{"time":1748401200,"path":"/v2/satellite/08a3e72338f0"},{"time":1748401800,"path":"/v2/satellite/ed22dda88fe0"}]}}
We can extract this data for use in HA using a RESTful sensor - this one gets the host data, radar data and the timestamp the data was generated at.
sensor:
- platform: rest
name: RainViewer Radar
resource: https://api.rainviewer.com/public/weather-maps.json
method: GET
scan_interval: 300
value_template: "{{ value_json.generated }}"
json_attributes:
- host
- radar
- generated
We will use attributes from this sensor to create the urls for downloading the radar images, using template sensors.
I created a sensor (with attributes, yes I know they are frowned on these days) that creates URLs for the last 10 timestamps plus the first predicted one. They are numbered from 10 to zero, so to show them in the correct order you count down from 10. I have only used 6 images for this example - counted from url5 to url0, but more are in the sensor.
These templates combine the data we grabbed from the api url above with the tile size, zoom level, location, color scheme and options. These are described here: Weather Maps API
template:
- sensor:
- unique_id: weather_display_radar
state: >
{{ as_datetime(state_attr('sensor.rainviewer_radar', 'generated')) }}
attributes:
url10: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.past and radar.past | length > 10 and host %}
{{ host ~ radar.past[-10].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp10: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.past[-10].time)).strftime('%-I:%M') }}
url9: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.past and radar.past | length > 9 and host %}
{{ host ~ radar.past[-9].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp9: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.past[-9].time)).strftime('%-I:%M') }}
url8: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.past and radar.past | length > 8 and host %}
{{ host ~ radar.past[-8].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp8: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.past[-8].time)).strftime('%-I:%M') }}
url7: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.past and radar.past | length > 7 and host %}
{{ host ~ radar.past[-7].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp7: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.past[-7].time)).strftime('%-I:%M') }}
url6: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.past and radar.past | length > 6 and host %}
{{ host ~ radar.past[-6].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp6: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.past[-6].time)).strftime('%-I:%M') }}
url5: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.past and radar.past | length > 5 and host %}
{{ host ~ radar.past[-5].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp5: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.past[-5].time)).strftime('%-I:%M') }}
url4: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.past and radar.past | length > 4 and host %}
{{ host ~ radar.past[-4].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp4: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.past[-4].time)).strftime('%-I:%M') }}
url3: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.past and radar.past | length > 3 and host %}
{{ host ~ radar.past[-3].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp3: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.past[-3].time)).strftime('%-I:%M') }}
url2: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.past and radar.past | length > 2 and host %}
{{ host ~ radar.past[-2].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp2: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.past[-2].time)).strftime('%-I:%M') }}
url1: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.past and radar.past | length > 1 and host %}
{{ host ~ radar.past[-1].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp1: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.past[-1].time)).strftime('%-I:%M') }}
url0: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{% set host = state_attr('sensor.rainviewer_radar', 'host') %}
{% if radar and radar.nowcast and radar.nowcast | length > 0 and host %}
{{ host ~ radar.nowcast[0].path ~ '/256/8/-36.051961/146.458654/1/1_1.png' }}
{% else %}
{{ 'unavailable' }}
{% endif %}
timestamp0: >
{% set radar = state_attr('sensor.rainviewer_radar', 'radar') %}
{{ as_local(as_datetime(radar.nowcast[0].time)).strftime('%-I:%M') }}
The rest of the work is done by ESPHome - but first we need to download a matching map tile - this is the URL that matches the requested data in the sensor urls.
https://static-maps.yandex.ru/1.x/?lang=en_US&ll=146.458654,-36.051961&z=8&l=map&size=256,256
Download the returned image and place in the config/esphome directory on your HA server (or wherever you compile your ESPHome projects).
I’m not going to include the full yaml - just extracts.
We firstly need some homeassistant text sensors to retrieve the urls:
- platform: homeassistant
entity_id: sensor.template_weather_display_radar
attribute: url0
id: radar_url0
- platform: homeassistant
entity_id: sensor.template_weather_display_radar
attribute: url1
id: radar_url1
- platform: homeassistant
entity_id: sensor.template_weather_display_radar
attribute: url2
id: radar_url2
- platform: homeassistant
entity_id: sensor.template_weather_display_radar
attribute: url3
id: radar_url3
- platform: homeassistant
entity_id: sensor.template_weather_display_radar
attribute: url4
id: radar_url4
- platform: homeassistant
entity_id: sensor.template_weather_display_radar
attribute: url5
id: radar_url5
- platform: homeassistant
entity_id: sensor.template_weather_display_radar
id: radar_urls
on_value:
script.execute: script_downloads
The final sensor is a used to trigger the downloads.
We also need the online_image:
and image:
definitions:
http_request:
timeout: 15s
watchdog_timeout: 20s
online_image:
- url: "https://google.com/dummy.png"
id: radar0
format: png
type: RGB565
transparency: alpha_channel
- url: "https://google.com/dummy.png"
id: radar1
format: png
type: RGB565
transparency: alpha_channel
- url: "https://google.com/dummy.png"
id: radar2
format: png
type: RGB565
transparency: alpha_channel
- url: "https://google.com/dummy.png"
id: radar3
format: png
type: RGB565
transparency: alpha_channel
- url: "https://google.com/dummy.png"
id: radar4
format: png
type: RGB565
transparency: alpha_channel
- url: "https://google.com/dummy.png"
id: radar5
format: png
type: RGB565
transparency: alpha_channel
on_download_finished:
then:
- lvgl.animimg.update:
id: radar
src: [ radar5, radar4, radar3, radar2, radar1, radar0 ]
- lvgl.animimg.start: radar
on_error:
then:
- lvgl.animimg.update:
id: radar
src: [ radar5, radar4, radar3, radar2, radar1, radar0 ]
- lvgl.animimg.start: radar
image:
- file: "map.png"
id: background_map
type: RGB565
Note the timeouts in the http_request:
block. These are needed in my case more for wifi issues rather than download times and image size, you may not need them.
Final prep is the script to download the images when the api sensor is updated:
- id: script_downloads
then:
- delay: 10sec
- online_image.set_url:
id: radar0
url: !lambda return id(radar_url0).state;
- delay: 10sec
- online_image.set_url:
id: radar1
url: !lambda return id(radar_url1).state;
- delay: 10sec
- online_image.set_url:
id: radar2
url: !lambda return id(radar_url2).state;
- delay: 15sec
- online_image.set_url:
id: radar3
url: !lambda return id(radar_url3).state;
- delay: 15sec
- online_image.set_url:
id: radar4
url: !lambda return id(radar_url4).state;
- delay: 20sec
- online_image.set_url:
id: radar5
url: !lambda return id(radar_url5).state;
Note the rather extensive delays between each download. This is because there is a “feature” in the http_request:
component that causes ssl failure if we attempt to download images simultaneously. Have a fiddle with them - your mileage may vary.
Now we have all the images we require we can combine them on an LVGL page using an animimg:
:
- id: radar_page
bg_color: 0x000000
widgets:
- image:
id: map
x: 275
y: 160
src: background_map
- animimg:
id: radar
x: 100
y: 100
src: [ radar5, radar4, radar3, radar2, radar1, radar0 ]
duration: 12000ms
auto_start: true
Now this post is by no means complete - I assume you know how to do LVGL and all the other ESPHome stuff, how to edit configuration.yaml to add custom sensors.
If you try this and have any questions just ask - I am still fiddling and may discover new things as I go. Also if you have a better way of achieving this let me know.