How to get wildcard state triggers

So I’m going to lead by saying that if you are interested in this topic, you should vote for this and/or this. Because my solution is such a hack. It definitely works, I’ve been using it for a while but its absolutely a hack and I wish this was native.

Ok, now that that’s out of the way, let’s get started. When I migrated my automation logic from Node RED to HA the experience was mostly positive but I ran into one major dealbreaker - the complete inability to use regexes and wildcards in entity_id in triggers.

I almost never hard-code entity ID lists anywhere. I am very diligent about following naming patterns so that I can easily add or remove devices and have my automations just handle that change. To give an example I made an automation maybe 2 years ago that tied together a door sensor to a closet light. Since then I’ve added door sensors and smart lights to 3 more closets and never once had to change the automation. As soon as I added the devices to HA the closet lights turned on and off with the door. I have similar patterns like this all over.

So I was completely unwilling to migrate until I solved this. I did and at this point its been running smoothly for like 6 months so I’m pretty confident in it. It may be a hack but at least its a hack that works well. Here’s the package you can use then I’m going to talk about some of the key pieces and instructions. If you don’t know how to use a package, see here.

And before anyone asks, no this cannot be a blueprint. Its not just one automation, there’s supporting pieces. Sorry.

automation:
- id: 5db0e93431054d038971552f904ac3b9_update_entity_lists
  alias: Update entity include lists
  description: List of all entity ID patterns used in triggers. This keeps files up to date with those lists for !includes.
  mode: single
  max_exceeded: silent
  variables:
    domains:
      #### TODO: REPLACE WITH YOUR LIST OF DOMAINS
      - alert
      - light
      - person
      - update
    matches:
      #### TODO: REPLACE WITH YOUR LIST OF MATCHES
      # Cannot use `options` here or it will break. Options is taken by input_select
      battery_level: sensor[.].+_battery_level
      closets/inputs/lights: switch[.].+_closet_light
      closets/inputs/sensors: binary_sensor[.].+_closet_(door_contact|motion_sensor_occupancy)
      closets/timers: timer[.].+_closet
      couch_occupied: binary_sensor.family_room_couch_.+_seat_contact
      detected_activity_processed: sensor[.].+_detected_activity_processed
      expected_travel_mode: select[.].*_expected_travel_mode
      guest_toggles: switch[.]guest_staying_.+
      is_traveling: binary_sensor[.].+_is_traveling
      lock_weekday_access: input_boolean[.]front_door_lock_weekday_access_.+
      phone_battery_level: sensor[.].+_phone_battery_level
      protect_status: binary_sensor[.]nest_protect_.+(co|heat|smoke)_status
      reached_a_destination: switch[.].+_reached_a_destination
      recurring_task: input_datetime[.]recurring_.+
      reminders/appointments: input_datetime[.]recurring_appointment_.+
      reminders/days: sensor[.]reminder_.+
      reminders/tasks: input_datetime[.]recurring_task_.+
      request_location_update: button[.].+_request_location_update
      room_illuminance: sensor[.].+_light_sensor_illuminance_lux
      room_illuminance_stats: sensor[.].+_illuminance_lux_stats
      room_presence/inputs/controllers: select[.]presence_lights_.+
      room_presence/inputs/detectors: binary_sensor[.]person_detected_.+
      room_presence/inputs/light_sensors: binary_sensor[.]bright_.+
      room_presence/off_scenes: scene[.]room_off_.+
      room_presence/on_scripts: script[.]room_on_.+
      sleep_sensors: binary_sensor[.][a-z]+_is_asleep(_in_.+)?
  trigger:
    - platform: homeassistant
      event: start
    - platform: event
      event_type:
        - entity_registry_updated
        - automation_reloaded
  action:
    - parallel:
        - sequence:
            - alias: Update domain lists
              repeat:
                for_each: "{{ domains }}"
                sequence:
                  - alias: Get existing and new list
                    variables:
                      path: "domain/{{ repeat.item }}"
                      comment: "Entities with domain `{{ repeat.item }}`"
                      old: &old-list "{{ state_attr('input_select.auto_lists', path) | default([]) }}"
                      new: >-
                        {{ states | selectattr('entity_id', 'search', '^' ~ repeat.item ~ '[.]')
                          | map(attribute='entity_id') | list }}
                  - &record-changes
                    alias: If list changed, record it
                    if: "{{ old != new }}"
                    then:
                      parallel:
                        - alias: Update the file
                          service: notify.update_entity_list
                          data:
                            message: |
                              {{ path }}
                              # {{ comment }}
                              - {{ new | join('\n- ') }}
                        - alias: Update the select
                          service: input_select.set_options
                          data:
                            entity_id: input_select.auto_lists
                            options: "{{ state_attr('input_select.auto_lists', 'options') + [path] }}"
        - sequence:
            - alias: Update match lists
              repeat:
                for_each: "{{ matches | dictsort }}"
                sequence:
                  - alias: Get existing and new list
                    variables:
                      path: "{{ repeat.item[0] }}"
                      regex: "{{ repeat.item[1] }}"
                      comment: "Entities with IDs matching `{{ regex }}`"
                      old: *old-list
                      new: >-
                        {{ states | selectattr('entity_id', 'match', regex)
                          | map(attribute='entity_id') | list }}
                  - *record-changes
    - alias: Stop if no files were updated
      condition: "{{ state_attr('input_select.auto_lists', 'options') | count > 1 }}"
    - alias: Update directory file
      service: notify.update_entity_list_directory
      data:
        message: |
          # Directory file. !include in customization to make an entity with all lists as its attributes
          {{ (  domains | map('regex_replace', '^(.*)$', 'domain/\\1') | list
                + matches | dictsort | map(attribute=0) | list
              ) | map('regex_replace', '^(.*)$', '\\1: !include \\1.yaml') | join('\n') }}
    - parallel:
        - alias: Notify about updates
          service: persistent_notification.create
          data:
            notification_id: entity_lists_updated
            title: Entity lists updated
            message: "Modified: {{ state_attr('input_select.auto_lists', 'options')[1:] | join(', ') }}"
        - alias: Reload customizations
          service: homeassistant.reload_core_config
        - alias: Reload input selects to reset auto_lists
          service: input_select.reload
        - alias: Reload scripts
          service: script.reload
        - alias: Reload groups
          service: group.reload
    - alias: Reload automations
      service: automation.reload
input_select:
  auto_lists:
    name: Auto lists
    icon: mdi:view-list
    initial: listing
    options: ['listing']
homeassistant:
  customize:
    input_select.auto_lists: !include /config/common/auto/.directory.yaml
notify:
- platform: command_line
  name: Update entity list
  command: >-
    read path
    && path="/config/common/auto/${path}.yaml"
    && cat /dev/stdin > "$path"
- platform: command_line
  name: Update entity list directory
  command: cat /dev/stdin > /config/common/auto/.directory.yaml

:warning: If you import this, add input_select.auto_lists to your list of entities to exclude in recorder. I cannot do that for you in the package because of how package import work. But definitely do this or you will get a lot of SQL errors from this huge entity :warning:

Ok so, how does this work? First it doesn’t actually enable triggers like this, it’s not possible for a package to change that:

trigger:
  platform: state
  entity_id: update.*

What it does instead is write out YAML files that can be used via !include. So for example if you were actually interested in all update entities what you would do is add update to the list of domains in the variables section of the automation (note the giant TODO comment):

  variables:
    domains:
      - update

And then you could use this as a trigger in your automation:

trigger:
  platform: state
  entity_id: !include /config/common/auto/domain/update.yaml

Or let’s say you wanted to make an automation that sent a notification any time any battery sensor was low. To do that you can add something like this to variables.matches in the automation (note the second giant TODO comment):

  variables:
    matches:
      battery_level: sensor[.].+_battery_level

And then you could use a trigger like this in your automation:

trigger:
  platform: state
  entity_id: !include /config/common/auto/battery_level.yaml

:information_source: Matches need to be regex. Glob would be nicer I know but I can’t do that. There’s no glob support in templates, only regex support. If you really hate regex here’s the bare minimum you need to know to use this:

  1. . means any character. If you want to actually match . then put [.] (or \\. I just dislike the double slash)
  2. If you want to do a wildcard match (like putting a * in a glob filter) then put .*

Pretty cool right? This also isn’t limited to automations since you can !include anywhere. Want to make a group with all your switch entities?

switch:
  - platform: group
    entities: !include /config/common/auto/domain/switch.yaml

Or want to use the same list of entities in a script?

sequence:
  - alias: Turn on all the things!
    service: switch.turn_on
    data:
      entity_id: !include /config/common/auto/domain/switch.yaml

And the automation itself is listening for any changes to the entity registry as well as Home Assistant restart. So if you add an entity to the system or change the entity ID of one the automation will immediately pick up that change, see if any lists need to be updated, update them and reload all your automations and scripts. It also listens for the automation_reloaded event so if you change the lists it re-runs and updates the files.

Some might ask “what about templates?” Its true, !include does not work in templates. So if you wanted to use all your battery sensors in a template or template sensor this trick won’t work. However, I’ve got you covered, this is where input_select.auto_lists comes in. For every list you make there is an matching attribute on this entity. So if you want to make a template binary sensor that turns on anytime anytime any battery level sensor is below a threshold you can do this:

template:
  binary_sensor:
    name: Low battery in house
    state: >-
      {{ expand(state_attr('input_select.auto_lists', 'battery_level')) 
          | map(attribute='state') | map('float', 0)
          | select('lt', 20) | count > 0 }}

:warning: Seriously, exclude input_select.auto_lists from recorder. It’s going to be massive. :warning:

The automation does not directly reload template entities but it does reload input_select entities. So template entities will see that state chnage and react to it like normal.

That’s pretty much it. I’m going to put a bit more of a technical deep dive below for anyone interested in questions like “why an input select” and “what’s the command line stuff”. But for those that just want to use it, feel free. Also I left all my domains and matches in there just to provide some examples for people. You should delete or comment out all of them and put in what you want.

Note that if you don’t like my folder choice (/config/common/auto) feel free to change it. Just find and replace /config/common/auto within the package to whatever you want.

Also I should probably note that the automation does not clean up after itself. If you remove a match or a domain then it won’t update that file anymore but it won’t delete it. You have to do that manually.

Technical details

What’s up with the input select?
For the most part it could’ve been any entity at all. You can see the real guts of it is in customization

homeassistant:
  customize:
    input_select.auto_lists: !include /config/common/auto/.directory.yaml

That’s how it gets all those attributes. The automation also writes out a directory which is a giant dictionary of includes.

The reason its an input select is purely for the automation internals. The automation goes through each domain and match you specified. It finds a list of entities that match that query and compares it to what the input select has. If its different it adds that domain/match to the list of options in the input select so the automation knows what changed. Then it can re-write those particular files and notify you what changed. That couldn’t be done with automation variables because they are scoped, you can’t add anything to a top-level variable in loop steps.

It never has any options except during the automation. You should not modify it, just treat it as read-only.

Why do you have command line notifies?
Those write out the files. They are notifies as opposed to shell commands because shell commands don’t accept any inputs and command line notifies do.

Shell commands require you to stuff values in helpers and then read out from those. Gross, I basically never use them. Command line notifies are the same thing except the message comes in as stdin so there’s no helpers required.

Wish there were multiple inputs though. This isn’t really ideal:

    read path
    && path="/config/common/auto/${path}.yaml"
    && cat /dev/stdin > "$path"

Basically the first line is the first argument and everything else is the second. Would be nice to just have multiple arguments.

What’s going on with matches that have names like room_presence/inputs/controllers?
An advanced feature I put in. If the name of your match includes slashes then those become folders. So to include that you would would do this:

!include /config/common/auto/room_presence/inputs/controllers.yaml

The reason for this is for those sometimes I wanted to specifically use the room presence controllers and sometimes I wanted to include the controllers, detectors and light sensors (i.e. everything in room_presence/inputs). Putting them in folders means you can do that like this:

!include_dir_merge_list /config/common/auto/room_presence/inputs

Could I use this to make a trigger that fires anytime any entity with a particular attribute changes?
Absolutely. Or whatever criteria for finding entities you want. I’m not going to write that for you but here’s an overview on how to do it.

Just add a new list variable to variables and then add a new sequence to handle it in the first parallel step of the automation like this:

        - sequence:
            - alias: Update <criteria> lists
              repeat:
                for_each: "{{ <criteria> }}"
                sequence:
                  - alias: Get existing and new list
                    variables:
                      path: "<criteria>/{{ repeat.item }}"
                      comment: "Entities with <criteria> `{{ repeat.item }}`"
                      old: &old-list "{{ state_attr('input_select.auto_lists', path) | default([]) }}"
                      new: >-
                        {{ states | selectattr('entity_id', 'search', '^' ~ repeat.item ~ '[.]')
                          | map(attribute='entity_id') | list }}
                  - *record-changes

Fill in the blank on <criteria> and replace the template in new with however you find entities based on the current loop item for <criteria>. Everything else will take care of itself.

Feel free to ask any other questions below!

8 Likes

Firstly, what you have done is very clever and you’ve explained it very well. Thanks!

I like the convenience it ultimately provides. However I prefer not to use an entity’s object_id (its “name”) to store additional properties … and that’s a prerequisite for this technique.

I currently use custom attributes to store additional properties (although not very conveniently because Manual Customization isn’t via the UI). It provides a lot of flexibility to select entities by specific properties. However it’s not a replacement for what you have created (allow a State Trigger to contain a dynamic selection of entities based on the properties stored in their name).

What I’m looking forward to is the “label” concept that frenck is exploring. My understanding, of what he is trying to achieve, is to allow a user to assign one or more label values to any entity. This would allows entities to be grouped/selected by multiple user-defined characteristics.

Given the availability of such a feature, an enhanced version of the State Trigger could select entities by their labels, thereby providing a means of dynamic entity selection.

Nevertheless, until that comes to pass (if ever), the technique you presented is the go-to solution.

1 Like

Yea I know, I’ve seen you mention this before on quite a few posts. Totally makes sense and I might rethink things when labels come into play.

Fwiw after I finished writing I realized this was going to come up and added this blurb to the technical details section:

This could definitely apply to that technique as well if you wanted it to make and update lists for you of all entities that had an attribute of say device_class = motion. Or every entity in a particular area. It’s actually a pretty straightforward and small addition and nearly everything else applies.

The only extra thing you might have to do is add more triggers. Like for example if you usually add your attributes via customization then you probably want this automation detect when customizations are reloaded and re-run to see if anything changed. Or if you are doing “entities in an area” option then I think you’d want to listen for the area registry being updated in the same way its currently listening for the entity_registry. Although putting an entity in an area probably fires the entity registry updated event so that might be covered.

Anyway its pretty flexible. Just insert a criteria for finding entities.

2 Likes