Incredibly repetitive template sensors. Is there a better way?

I’m setting up template sensors to make forecast data available for an ESPHome device and my YAML is revoltingly repetitive. It’s about as DRY as the bottom of the ocean. Surely I’m missing a better way to do this.

I want my forecast_h1 sensor to always contain data for the current hour, but the forecast API occasionally includes the forecast for the hour that just elapsed, so I can’t just assume the forecast data at index 0 is for the current hour. No problem, the forecasts each have timestamps so I just need to iterate through them until I find the one that matches the current hour, use that index for the current hour data, and increment it for subsequent hours, right?

As far as I can tell, there is no way for me to calculate this index (based on the data in response variable, hourly) once and then use it throughout my sensors. The only way I’ve been able to get this to work is to repeat the same calculation for every single point of data for every single hour. I’m doing the same calculation 48 times to get the exact same number. I made a jinja macro to help with this and apparently that needs to be imported for every single data point too.

# template: forecast.yaml
# Creates sensors from data returned by weather forecast API

- trigger:
    - platform: event
      event_type: homeassistant_started # update on startup
    - platform: time_pattern
      minutes: /10 # update every 10 minutes
  action:
    # get hourly forecast data
    - service: weather.get_forecasts
      target:
        entity_id: "weather.openweathermap"
      data:
        type: hourly
      response_variable: hourly
  sensor:
    - name: "Forecast H1"
      unique_id: forecast_h1
      state: >
        {% from 'forecast_helpers.jinja' import forecast_start_idx %}
        {% set hour_idx = forecast_start_idx(hourly['weather.openweathermap'].forecast, true) %}
        {{ hourly['weather.openweathermap'].forecast[hour_idx].condition }}
      attributes:
        temperature: >
          {% from 'forecast_helpers.jinja' import forecast_start_idx %}
          {% set hour_idx = forecast_start_idx(hourly['weather.openweathermap'].forecast, true) %}
          {{ hourly['weather.openweathermap'].forecast[hour_idx].temperature }}
        precip_prob: >
          {% from 'forecast_helpers.jinja' import forecast_start_idx %}
          {% set hour_idx = forecast_start_idx(hourly['weather.openweathermap'].forecast, true) %}
          {{ hourly['weather.openweathermap'].forecast[hour_idx].precipitation_probability }}
        precip_amount: >
          {% from 'forecast_helpers.jinja' import forecast_start_idx %}
          {% set hour_idx = forecast_start_idx(hourly['weather.openweathermap'].forecast, true) %}
          {{ hourly['weather.openweathermap'].forecast[hour_idx].precipitation }}
        datetime: >
          {% from 'forecast_helpers.jinja' import forecast_start_idx %}
          {% set hour_idx = forecast_start_idx(hourly['weather.openweathermap'].forecast, true) %}
          {{ hourly['weather.openweathermap'].forecast[hour_idx].datetime }}
        label: >
          {% from 'forecast_helpers.jinja' import forecast_start_idx %}
          {% set hour_idx = forecast_start_idx(hourly['weather.openweathermap'].forecast, true) %}
          {{ as_local(as_datetime(hourly['weather.openweathermap'].forecast[hour_idx].datetime)).strftime('%-I %p') }}
    # Forecast H2 - H8...
{# forecast_helpers.jinja #}

{% macro macro_forecast_start_idx(forecast_list, check_hour, returns) %}
  {% set ns = namespace(found = false) %}
  {% for forecast in forecast_list %}
    {% set dt = as_local(as_datetime(forecast.datetime, as_datetime("0"))) %}
    {% if dt.date() == now().date() and (dt.hour == now().hour or not check_hour) %}
      {% do returns(loop.index0) %}
      {% set ns.found = true %}
      {% break %}
    {% endif %}
  {% endfor %}
  {% do returns(0) if not ns.found %}
{% endmacro %}

{% set forecast_start_idx = macro_forecast_start_idx | as_function %}

Please tell me there’s a better way. I’m ready to feel like an idiot for missing it.

You could make the hour_idx its own template sensor, then (for example):

        precip_amount: >
          {% set hour_idx = states('sensor.hour_idx')|int(0) %}
          {{ hourly['weather.openweathermap'].forecast[hour_idx].precipitation }}

That saves a line in the state and each attribute, or two if you forgo the set and put the sensor state in place of hour_idx.

As a next step (also using that new sensor), you can generate the attributes in one step as a Jinja dictionary:

      attributes: >
        {% set f = hourly['weather.openweathermap'].forecast[states('sensor.hour_idx')|int(0)] %}
        {{ {'temperature': f.temperature,
            'precip_prob': f.precipitation_probability,
            'precip_amount': f.precipitation,
            'datetime': f.datetime,
            'label': as_local(as_datetime(f.datetime)).strftime('%-I %p') } }}

You could then write a macro to generate that attributes dictionary for a given hour, remembering that macro outputs are generally strings, so you’d need to use to_json and from_json filters.

1 Like

Immediately after the weather.get_forecasts action, define a variable, perhaps named wf for “weather forecast”, containing the functionality of your macro (i.e. it no longer needs to be a macro). However, instead of merely returning the list item’s index number, it should return the list item itself.

Each attribute simply references wf to get the value of the desired weather property (wf.temperature, wf.precipitation_probability, etc).

tl;dr
Create a variable in actions containing only the current hour’s weather forecast. Use it in attributes to get each weather property.

1 Like

Instead of using a for loop in a macro, reject the “old” values before you do anything else using a variable in the action block. This is easier if you merge the response first.

Since the old values are no longer an issue, you can use a variable at the entity level to set a static index for each sensor… which can be combined with @123 's suggestion:

  - triggers:
      - trigger: homeassistant
        event: start
      - trigger: time_pattern
        minutes: /10
    actions:
      - alias: Get hourly forecast data
        action: weather.get_forecasts
        target:
          entity_id: "weather.openweathermap"
        data:
          type: hourly
        response_variable: hourly
      - variables:
          hourly: |
            {# Merge Response and Reject "old" forecasts #}
            {% set current = today_at(now().hour~':00').isoformat() %}
            {{ merge_response(hourly) | rejectattr('datetime', 'lt', current) | list }}
    sensor:
      - name: "Forecast H1"
        unique_id: forecast_h1
        variables:
          idx: 0
          wf: "{{ hourly[idx] }}"
        state: "{{ wf.condition }}"
        attributes:
          temperature: "{{ wf.temperature }}"
          precip_prob: "{{ wf.precipitation_probability }}"
          precip_amount: "{{ wf.precipitation | default(0, true) }}"
          datetime: "{{ wf.datetime }}"
          label: "{{ (datetime|as_datetime|as_local).strftime('%-I %p') }}"
 # Forecast H2 - H8...
And if you really want to decrease repetition, use YAML anchors and merging
  - triggers:
      - alias: Update on restart
        trigger: homeassistant
        event: start
      - alias: update every 10 minutes
        trigger: time_pattern
        minutes: /10
    actions:
      - alias: Get hourly forecast data
        action: weather.get_forecasts
        target:
          entity_id: "weather.openweathermap"
        data:
          type: hourly
        response_variable: hourly
      - variables:
          hourly: |
            {# Merge Response and Reject "old" forecasts #}
            {% set current = today_at(now().hour~':00').isoformat() %}
            {{ merge_response(hourly) | rejectattr('datetime', 'lt', current) | list }}
    sensor:
      - name: "Forecast H1"
        unique_id: forecast_h1
        variables:
          wf: "{{ hourly[0] }}"
        <<: &forecasts_common
          state: "{{ wf.condition }}"
          attributes:
            temperature: "{{ wf.temperature }}"
            precip_prob: "{{ wf.precipitation_probability }}"
            precip_amount: "{{ wf.precipitation | default(0, true) }}"
            datetime: "{{ wf.datetime }}"
            label: "{{ (datetime|as_datetime|as_local).strftime('%-I %p') }}"
      - name: "Forecast H2"
        unique_id: forecast_h2
        variables:
          wf: "{{ hourly[1] }}"
        <<: *forecasts_common

 # Forecast H3 - H8...

Thomas Lovén - YAML for Non-programmers

NOTE: The value for the “script” variable hourly in the templates above are based on a Weather integration that uses localized values for datetime. If your weather integration returns the value of datetime in UTC, please see the post by Taras below for examples that cover that.

2 Likes

FWIW, in order to get your example to work on my system, I needed to make the following changes:

1. Convert statement into a comment.

I got an error when reloading Template entities and it was because a comment was expressed as a statement.

Replace this (a statement):

{% Merge Response and Reject "old" forecasts %}

with this (a comment):

{# Merge Response and Reject "old" forecasts #}

2. Report time as UTC, not with local timezone offset

The rejectattr() function is comparing two datetime strings so both strings should use the same datetime format (the weather forecast data uses UTC).

Replace this (datetime reported with local timezone offset):

{% set current = today_at(now().hour~':00').isoformat() %}

with this (datetime reported as UTC):

{% set current = today_at(now().hour~':00').timestamp() | timestamp_utc %}

NOTES

  • When using my weather provider, the precipitation property doesn’t exist when the probability of precipitation is zero. As a consequence, an error was logged for each reference to the non-existent property. To mitigate it, I simply added a default filter to wf.precipitation.

  • I incorporated the value of idx directly into the wf variable’s template. This eliminates one line from each sensor’s configuration.


Click to reveal modified version
  - triggers:
      - trigger: homeassistant
        event: start
      - trigger: time_pattern
        minutes: /10
    actions:
      - alias: Get hourly forecast data
        action: weather.get_forecasts
        target:
          entity_id: "weather.openweathermap"
        data:
          type: hourly
        response_variable: hourly
      - variables:
          hourly: |
            {# Merge Response and Reject "old" forecasts #}
            {% set current = today_at(now().hour~':00').timestamp() | timestamp_utc %}
            {{ merge_response(hourly) | rejectattr('datetime', 'lt', current) | list }}
    sensor:
      - name: "Forecast H1"
        variables:
          unique_id: forecast_h1
          wf: "{{ hourly[0] }}"
        <<: &cfg
          state: "{{ wf.condition }}"
          attributes:
            temperature: "{{ wf.temperature }}"
            precip_prob: "{{ wf.precipitation_probability }}"
            precip_amount: "{{ wf.precipitation | default(0, true) }}"
            datetime: "{{ wf.datetime }}"
            label: "{{ (datetime|as_datetime|as_local).strftime('%-I %p') }}"
      - name: "Forecast H2"
        variables:
          unique_id: forecast_h2
          wf: "{{ hourly[1] }}"
        <<: *cfg
      - name: "Forecast H3"
        variables:
          unique_id: forecast_h3
          wf: "{{ hourly[2] }}"
        <<: *cfg
      - name: "Forecast H4"
          unique_id: forecast_h4
        variables:
          wf: "{{ hourly[3] }}"
        <<: *cfg
2 Likes

Thanks for adding these.

I had meant to make a note about this one. Not all weather integration use UTC in the response, the one I use most often is localized… I should have checked which one OpenWeather uses.

1 Like

Thanks for all the suggestions! I learned a lot from these replies. Going from 27 lines per sensor to 5 is a massive improvement.