Animated rain radar on ESPHome display

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.

2 Likes

Photo of it in action now we have some rain:

Unfortunately no videos allowed on this forum. :smiley: