Help with template sensor (comparing state string to list of ints/floats)

I have an automation that checks the battery level for devices and sensors and updates groups based on these checks. The full automation is below:

automation:
  - alias: Battery Groups
    id: battery_groups
    description: ""
    trigger:
      - platform: time_pattern
        minutes: '*'
    condition: []
    action:
      # Doesn't add devices with float-based battery_level
      - service: group.set
        data:
          object_id: battery_low_devices
          entities: >-
            {% set threshold = states('input_number.battery_threshold') | int(0) %}
            {% set sensors = states.sensor | selectattr('attributes.device_class', 'defined') | selectattr('attributes.device_class', '==', 'battery') | rejectattr('state', 'in', ['unavailable', 'unknown']) | list %}
            {% set device_ents = states.device_tracker | map(attribute='entity_id') | map('device_id') | map('device_entities') | sum(start=[]) | list %}
            {% set low = sensors | map(attribute='state') | map('int') | select('<=', threshold) | map('string') | list %}
            {{ sensors | selectattr('state', 'in', low) | selectattr('entity_id', 'in', device_ents) | sort(attribute='name') | map(attribute='entity_id') | list }}
      # Doesn't add devices with float-based battery_level
      - service: group.set
        data:
          object_id: battery_low_sensors
          entities: >-
            {% set threshold = states('input_number.battery_threshold') | int(0) %}
            {% set sensors = states.sensor | selectattr('attributes.device_class', 'defined') | selectattr('attributes.device_class', '==', 'battery') | rejectattr('state', 'in', ['unavailable', 'unknown']) | list %}
            {% set device_ents = states.device_tracker | map(attribute='entity_id') | map('device_id') | map('device_entities') | sum(start=[]) | list %}
            {% set low = sensors | map(attribute='state') | map('int') | select('<=', threshold) | map('string') | list %}
            {{ sensors | selectattr('state', 'in', low) | rejectattr('entity_id', 'in', device_ents) | sort(attribute='name') | map(attribute='entity_id') | list }}
      # Works as is
      - service: group.set
        data:
          object_id: battery_unavailable
          entities: >-
            {{ states.sensor | selectattr('attributes.device_class', 'defined') | selectattr('attributes.device_class', '==', 'battery') | selectattr('state', '==', 'unavailable') | map(attribute='entity_id') | list }}
      # Works as is
      - service: group.set
        data:
          object_id: battery_unknown
          entities: >-
            {{ states.sensor | selectattr('attributes.device_class', 'defined') | selectattr('attributes.device_class', '==', 'battery') | selectattr('state', '==', 'unknown') | map(attribute='entity_id') | list }}

For some background as to why Iā€™m using a list and not just building a comma-separated value (which the group.set service can take), itā€™s because thereā€™s usually not any devices with low batteries. The problem with building a comma-separated list is that if the template doesnā€™t find any devices, the template evaluates to an empty string, passing None to the service call, which the service call wonā€™t accept. In doing so, it breaks the automation.

That said, this mostly works. The problem is with my logic - the low list has to be converted to a type (int or float) that is comparable with the integer threshold, and later the states state attributes get mapped to either an int or float and checked if they exist in the low list. If so, they populate the list of devices to get stuffed into the corresponding group. the groups end up getting filled with only the devices that have an integer representation for their battery level. This is problematic because some devices (like Aqara Zigbee thermometers) report battery level as an int while others (like First Alert Z-Wave Smoke/CO2 alarms) report battery level as a float, so the low list is either a list of ints or floats, but checking the list with sensors | selectattr('state', 'in', low) | ... | list fails because floats donā€™t exist in a list of ints and ints donā€™t exist in a list of floats.

Iā€™ve tried playing around with the affected service calls (the first two in the above automation) to no avail. Iā€™ve also tried something like this:

    action:
      # Fails if entities: is empty (None); also stops execution of further service calls
      - service: group.set
        data:
          object_id: battery_low_devices
          entities: >-
            {% set threshold = states('input_number.battery_threshold') | int(0) %}
            {% set device_ents = states.device_tracker | map(attribute='entity_id') | map('device_id') | map('device_entities') | sum(start=[]) | list %}
            {% for state in states.sensor if state.state not in ['unavailable', 'unknown'] and state.entity_id in device_ents and is_state_attr(state.entity_id, 'device_class', 'battery') and state.state | int(0) <= threshold %}
              {{ state.entity_id }}{% if not loop.last %}, {% endif %}
            {% endfor %}
      # Fails if entities: is empty (None); also stops execution of further service calls
      - service: group.set
        data:
          object_id: battery_low_sensors
          entities: >-
            {%- set threshold = states('input_number.battery_threshold') | int(0) -%}
            {%- set device_ents = states.device_tracker | map(attribute='entity_id') | map('device_id') | map('device_entities') | sum(start=[]) | list -%}
            {%- for state in states.sensor if state.state not in ['unavailable', 'unknown'] and state.entity_id not in device_ents and is_state_attr(state.entity_id, 'device_class', 'battery') and state.state | int(0) <= threshold -%}
              {{ state.entity_id }}{% if not loop.last %}, {% endif %}
            {%- endfor -%}

This presents the issue I described above - when any of the templates return nothing, the service is called and gives an error ā€˜Failed to call service group.set. Entity ID is an invalid entity ID for dictionary value @ data['entities']. Got Noneā€™. This is why I am going the list route, which works (for example, calling group.set with entities: [] works without issue and clears the group).

I really just need a way to store a list of ints or floats in a variable called low and later call sensors | selectattr('state', 'in', low) | ... | list, sowehow converting the state attrs to ints or floats while comparing them. I just canā€™t figure out how to do this.

Ā 

Any help would be great. :heart:

First thought would be to use an input_text helper as a proxy for a global variable, and reading/writing the values in/out of the template using to_json and from_json via the input_text.set_value service call.

Can you explain specifically what you are trying to doā€¦? What is the use case for the group?

Also, the template you are using to define device_ents is confusingā€¦

What is the | sum(start=[]) for? It seems to make the your device_ents variable just return every entityā€¦ For me, a count() on device_ents returned more than 15K entity ids.

My use case is that I have four groups:

  • Device trackers (phones, dog collars, etc) with low battery
  • Non-device tracker sensors (thermometers, smoke alarms, etc) with low battery
  • Any sensors (either type above) with unavailable battery
  • Any sensors (either type above) with unknown battery

These groups are then displayed in Lovelace and sent in notifications when they need to be.

So {{ states.device_tracker | map(attribute='entity_id') | map('device_id') | map('device_entities') | list }} returns a multidimensional list, a list of device trackers and their entities (device_entities). It looks something like this (greatly redacted for brevity):

[
  [
    'device_tracker.barb_galaxy_s22_ultra',
    'sensor.barb_galaxy_s22_ultra_battery_level',
    'sensor.barb_galaxy_s22_ultra_battery_state'
  ],
  [
    'device_tracker.chris_galaxy_note_10',
    'sensor.chris_galaxy_note_10_battery_level',
    'sensor.chris_galaxy_note_10_detected_activity'
  ],
  [
    'device_tracker.hunter_tractive',
    'sensor.hunter_tractive_battery_level',
    'sensor.hunter_tractive_daily_goal'
  ]
]

Using sum(start=[]) ( resulting in {{ states.device_tracker | map(attribute='entity_id') | map('device_id') | map('device_entities') | sum(start=[]) | list }} ) is a bit of a trick to flatten it into a plain list. End result is like this (again abbreviated):

[
  'device_tracker.barb_galaxy_s22_ultra',
  'sensor.barb_galaxy_s22_ultra_battery_level',
  'sensor.barb_galaxy_s22_ultra_battery_state',
  'device_tracker.chris_galaxy_note_10',
  'sensor.chris_galaxy_note_10_battery_level',
  'sensor.chris_galaxy_note_10_detected_activity',
  'device_tracker.hunter_tractive',
  'sensor.hunter_tractive_battery_level',
  'sensor.hunter_tractive_daily_goal'
]

In short, itā€™s a way to flatten the array of device_entities into a single list so I can exclude them from one of the groups, via rejectattr('entity_id', 'in', device_ents) and selectattr('entity_id', 'in', device_ents).

Perhaps, I donā€™t understand how that would help my case though. What I have is mostly working, with the caveat that a list of low battery values is a list of strings (either floats or integers, because the states themselves are strings, and ā€˜inā€™ needs to have the same data type to match). Converting that to and back from JSON wouldnā€™t (or at least shouldnā€™t) change the data type.

Without the ā€œchecking for device_tracker entitiesā€ stuff, the basic idea is:

  • Define low as a list of strings:
    • ints <= threshold OR
    • floats <= threshold
  • Return a list of entity_id of battery sensors with state (which is a string) in low

Either way, trying to compare the states of sensors to the list low results in the opposing unit failing to match since comparing the state (which is a string) to another string needs to have an exact match:

  • Checking a float string is in a list of int strings :arrow_right: '23.0' in ['0', '23'] = False
  • Checking an int string is in a list of float strings :arrow_right: '23' in ['0.0', '23.0'] = False
23 in ['0', '23.0'] | map('float') | list

will be true

1 Like

Now how do you do that with selectattr ? :slight_smile:

{{ sensors | selectattr('state', 'in', low) | rejectattr('entity_id', 'in', device_ents) | sort(attribute='name') | map(attribute='entity_id') | list }}

Either way you represent low, comparing the state fails one way or the other - either when state is an int string and low is a list of float strings, or when state is a float string and low is a list of int strings. Itā€™d be handy if you could do something like:

{{ sensors | selectattr('state' | int(0), 'in', low) | rejectattr('entity_id', 'in', device_ents) | sort(attribute='name') | map(attribute='entity_id') | list }}

However, thatā€™s not valid. :frowning:

You canā€™t do that in selectattr. Just double it up.

{% set list_of_numbers = [ 0, 23 ] %}
{% set low = list_of_numbers | map('int') | map('string') | list + list_of_numbers | map('float') | map('string') | list %}
1 Like

Awesome! I didnā€™t even think about simply re-mapping the low list as both int strings and float strings. Hereā€™s what I ended up with, which works:

entities: >-
  {% set threshold = states('input_number.battery_threshold') | int(0) %}
  {% set sensors = states.sensor | selectattr('attributes.device_class', 'defined') | selectattr('attributes.device_class', '==', 'battery') | rejectattr('state', 'in', ['unavailable', 'unknown']) | list %}
  {% set device_ents = states.device_tracker | map(attribute='entity_id') | map('device_id') | map('device_entities') | sum(start=[]) | list %}
  {% set low = sensors | map(attribute='state') | map('int') | select('<=', threshold) | map('string') | list %}
  {% set low = low | map('int') | map('string') | list + low | map('float') | map('string') | list %}
  {{ sensors | selectattr('state', 'in', low) | selectattr('entity_id', 'in', device_ents) | sort(attribute='name') | map(attribute='entity_id') | list }}

The second {% set low = ... %} puts both the int and float versions of the values in there.

Thanks so much!