Missing Devices Monitor (Script)

Hi. After looking for some monitoring of what devices had gone dark and stopped responding that would fit my needs, I created this script blueprint. GitHub link

Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.

Features

  • Persistent notification summarising all devices whose representative entity stays unavailable or unknown longer than allowed.
  • Area-aware grouping so you instantly see where outages are happening.
  • Split thresholds: fast vs. slow alert buffers, so if you have devices that report once a day or only when something changes, they can use different grace periods.
  • Automatic opt-outs for devices/entities that are disabled in HA.
  • Startup guard that skips the scan until HA has been up long enough (optional).

Report example

The links point to the offending device in an actual report. The one [slow] example device is configured to use the longer buffer delay and the lines limit is set to 4.

Missing devices: 10
Balcony
β€’ IKEA switch Christmass lights Switch β€” unavailable since 2h 46m 32s

Workshop
β€’ OctoPrint Current File Size β€” unavailable since 2h 46m 26s

Bedroom
β€’ Window sensor Battery β€” unavailable since 2h 46m 32s [slow]
β€’ Small lamp next to bed Light β€” unavailable since 2h 46m 32s

… 6 more not shown

Optional Dependency: Uptime Integration

The blueprint uses the built-in sensor.uptime entity to skip its first run while Home Assistant is still starting.
If the Uptime integration is unavailable, the script simply runs immediatelyβ€”no failures, just no startup grace.

Setup

  1. Install via link to this repo, or place missing_devices_blueprint.yaml under: config/blueprints/script/<namespace>/missing_devices.yaml
  2. If installing manually, reload blueprints (Settings β–Έ Automations & Scenes β–Έ Blueprints β–Έ β€œReload blueprints”) or restart HA.
  3. Create a new script from Missing Devices Monitor:
  4. Pick alert buffers, grace window, and domains.
  5. Choose the notification ID and line limit.
  6. Use the device selector to ignore or flag slow talkers, and/or paste device_id: note entries (device_id is part of the URL when you look at individual devices).
  7. Save the script and call it manually or via automation (e.g. on a time pattern). If using Uptime and your HA starts up, it will just keep aborting until enough time (that you set up) elapsed to avoid flagging slowly-initializig devices.

How It Works

  • The blueprint scans the configured domains of entities and picks a single entity per device.
  • It filters entities that are currently unavailable/unknown, skips disabled or ignored devices, and calculates how long each has been in that state.
  • If the duration exceeds the chosen buffer (slow talkers use the slow buffer, everyone else the fast one), the device is flagged.
  • The notification message groups flagged devices by area and renders each line using the entity’s friendly name (or the device name if the entity lacks one), so the alert shows the name you see in the UI, not the raw entity id.
  • When the script finds no missing devices, it dismisses the notification; otherwise, it updates/creates one with the grouped report.

I have over a thousand of entities and the runtime of this blueprint on a low-end Odroid board is about 0.2 second. If you have significantly more entities and you are not sure how it will be handled performance wise, you can first limit the number of entities processed and then increase it in steps. Also, I suggest running this script manually first, before actually creating an automation, because while I took care to avoid memory-hogging, it is not pleasant to be locked out the moment your automations initialize after startup and trigger OOM. Ask me how I know. :slight_smile:

YAML
blueprint:
  name: Missing Devices Monitor
  description: >
    Detects devices whose representative entity remains `unavailable` or
    `unknown` longer than allowed and posts a grouped persistent notification.
  domain: script
  author: Zopper
  source_url: https://github.com/Honza/home-assistant/missing-devices
  input:
    alert_fast_buffer_secs:
      name: Fast Alert Buffer (seconds)
      description: Seconds a normal device may remain `unavailable`/`unknown`
        before it is reported.
      default: 300
      selector:
        number:
          min: 0
          max: 604800
          unit_of_measurement: seconds
          mode: box
          step: 1
    alert_slow_buffer_secs:
      name: Slow Alert Buffer (seconds)
      description: Seconds a slow-talking device may remain `unavailable`/`unknown`
        before it is reported.
      default: 86400
      selector:
        number:
          min: 0
          max: 604800
          unit_of_measurement: seconds
          mode: box
          step: 1
    startup_grace_secs:
      name: Startup Grace (seconds)
      description: Skip checks while Home Assistant uptime is below this number.
      default: 300
      selector:
        number:
          min: 0
          max: 3600
          unit_of_measurement: seconds
          mode: box
          step: 1
    include_domains:
      name: Included Domains
      description: Domains whose entities will be considered when selecting
        device representatives.
      default:
        - binary_sensor
        - sensor
        - switch
        - light
        - climate
        - lock
        - cover
        - fan
        - vacuum
        - media_player
      selector:
        select:
          multiple: true
          mode: list
          options:
            - binary_sensor
            - sensor
            - switch
            - light
            - climate
            - lock
            - cover
            - fan
            - vacuum
            - media_player
    max_entities_processed:
      name: Max Entities Processed
      description: Optional cap on how many entities to inspect (0 disables the limit).
      default: 0
      selector:
        number:
          min: 0
          max: 5000
          mode: box
          step: 1
    notification_id:
      name: Notification ID
      default: missing_devices_monitor
      selector:
        text:
          multiline: false
    max_lines:
      name: Max Lines In Notification
      description: Trim the notification body to this many device lines (0 disables trimming).
      default: 30
      selector:
        number:
          min: 0
          max: 200
          mode: box
          step: 1
    ignored_devices_devices:
      name: Ignored Devices
      description: Select devices that should never be reported.
      default: []
      selector:
        device:
          multiple: true
    ignored_devices_text:
      name: Ignored Devices Override
      description: "Optional lines of `device_id: note` (or just `device_id`) for additional ignored devices."
      default: ""
      selector:
        text:
          multiline: true
    slow_talkers_devices:
      name: Slow-Talking Devices
      description: Devices that legitimately stay unavailable longer and should use the slow buffer.
      default: []
      selector:
        device:
          multiple: true
    slow_talkers_text:
      name: Slow Talkers Override
      description: "Optional lines of `device_id: note` (or just `device_id`) for additional slow talkers."
      default: ""
      selector:
        text:
          multiline: true

sequence:
  - variables:
      ALERT_FAST_BUFFER_SECS: !input alert_fast_buffer_secs
      ALERT_SLOW_BUFFER_SECS: !input alert_slow_buffer_secs
      STARTUP_GRACE_SECS: !input startup_grace_secs
      MAX_ENTITIES_PROCESSED: !input max_entities_processed
      INCLUDE_DOMAINS: !input include_domains
      NOTIF_ID: !input notification_id
      MAX_LINES: !input max_lines
      ignored_devices_list: !input ignored_devices_devices
      ignored_devices_text_raw: !input ignored_devices_text
      slow_talkers_list: !input slow_talkers_devices
      slow_talkers_text_raw: !input slow_talkers_text
      ignored_devices_json: >-
        {% set from_selector = ignored_devices_list %}
        {% set manual = ignored_devices_text_raw | default('', true) %}
        {% set lines = manual.splitlines() if manual else [] %}
        {% set comma = joiner(',') %}
        [
        {% if from_selector %}
          {% for dev in from_selector %}
            {{ comma() }}["{{ dev }}",""]
          {% endfor %}
        {% endif %}
        {% for raw in lines %}
          {% set line = raw.strip() %}
          {% if line %}
            {% if ':' in line %}
              {% set parts = line.split(':', 1) %}
              {{ comma() }}["{{ parts[0].strip() }}","{{ parts[1].strip() }}"]
            {% else %}
              {{ comma() }}["{{ line }}",""]
            {% endif %}
          {% endif %}
        {% endfor %}
        ]
      slow_talkers_json: >-
        {% set from_selector = slow_talkers_list %}
        {% set manual = slow_talkers_text_raw | default('', true) %}
        {% set lines = manual.splitlines() if manual else [] %}
        {% set comma = joiner(',') %}
        [
        {% if from_selector %}
          {% for dev in from_selector %}
            {{ comma() }}["{{ dev }}",""]
          {% endfor %}
        {% endif %}
        {% for raw in lines %}
          {% set line = raw.strip() %}
          {% if line %}
            {% if ':' in line %}
              {% set parts = line.split(':', 1) %}
              {{ comma() }}["{{ parts[0].strip() }}","{{ parts[1].strip() }}"]
            {% else %}
              {{ comma() }}["{{ line }}",""]
            {% endif %}
          {% endif %}
        {% endfor %}
        ]
      ignored_devices: "{{ ignored_devices_json | replace('\n','') | from_json }}"
      slow_talkers: "{{ slow_talkers_json | replace('\n','') | from_json }}"
    alias: Configuration options - edit this

  # ── Runtime guardrail: skip shortly after startup ───────────────────────────
  - variables:
      uptime_seconds: >-
        {% set uptime_state = states('sensor.uptime') %}
        {% if uptime_state not in ['unknown', 'unavailable', '', none] %}
          {% set uptime_ts = as_timestamp(uptime_state) %}
          {{ (as_timestamp(now()) - uptime_ts) if uptime_ts else '' }}
        {% endif %}
    alias: Capture current uptime

  - choose:
      - conditions:
          - condition: template
            value_template: >
              {{ uptime_seconds | length > 0 and uptime_seconds | float(0) < STARTUP_GRACE_SECS | float(0) }}
        sequence:
          - stop: Startup grace active; skipping missing device scan.
        alias: Still inside startup grace window
    alias: Abort if Home Assistant just started

  # ── Candidate discovery ─────────────────────────────────────────────────────
  - variables:
      now_ts: "{{ as_timestamp(now()) | float }}"
      candidate_info_json: >-
        {# 1) Gather a pre-filtered list of entities worth inspecting
              based on the listed domains
        #}
        {% set candidates = states
          | selectattr('domain', 'in', INCLUDE_DOMAINS)
          | list %}
        {% set visible = candidates if MAX_ENTITIES_PROCESSED <= 0
           else candidates[:MAX_ENTITIES_PROCESSED] %}
        {# 2) Emit JSON without mutating any list to avoid heavy State objects #}
        {% set comma = joiner(',') %}
        [
        {% for s in visible %}
          {% set lc = as_timestamp(s.last_changed) | default(0, true) %}
          {% set lu = as_timestamp(s.last_updated) | default(0, true) %}
          {% set is_bad = s.state in ['unavailable','unknown'] %}
          {% if is_bad %}
            {{ comma() }}
            {
              "entity_id": "{{ s.entity_id }}",
              "domain": "{{ s.domain }}",
              "state": "{{ s.state | trim | replace('\n', '\\n') }}",
              "last_changed": {{ lc }},
              "last_updated": {{ lu }},
              "device_class": "{% if s.domain == 'binary_sensor' %}{{ s.attributes.device_class | default('') }}{% else %}{% endif %}"
            }
          {% endif %}
        {% endfor %}
        ]
    alias: Build candidate entity snapshot

  - variables:
      reps_json: >-
        {# Pick one representative entity per device, honoring domain priority #}
        {% set info = candidate_info_json | replace('\n','') | from_json %}
        {% set group_conn = info
          | selectattr('domain','equalto','binary_sensor')
          | selectattr('device_class','equalto','connectivity')
          | map(attribute='entity_id')
          | list %}
        {% set group_bin_other = info
          | selectattr('domain','equalto','binary_sensor')
          | rejectattr('device_class','equalto','connectivity')
          | map(attribute='entity_id')
          | list %}
        {% set group_sensor = info
          | selectattr('domain','equalto','sensor')
          | map(attribute='entity_id')
          | list %}
        {% set group_switch = info
          | selectattr('domain','equalto','switch')
          | map(attribute='entity_id')
          | list %}
        {% set group_light = info
          | selectattr('domain','equalto','light')
          | map(attribute='entity_id')
          | list %}
        {% set group_climate = info
          | selectattr('domain','equalto','climate')
          | map(attribute='entity_id')
          | list %}
        {% set others_ids = info
          | rejectattr('domain', 'in', ['binary_sensor','sensor','switch','light','climate'])
          | map(attribute='entity_id')
          | list %}

        {# Order by groups, deduplicate eids, and take first per device #}
        {% set ignored_ids = ignored_devices | map(attribute=0) | list %}
        {% set groups = [group_conn, group_bin_other, group_sensor, group_switch,
          group_light, group_climate, others_ids] %}
        {% set seen_eids = ',' %}
        {% set seen_devs = ',' %}
        {% set comma = joiner(',') %}

        {
        {% for grp in groups %}
          {% for eid in grp %}
            {% set eid_tag = ',' ~ eid ~ ',' %}
            {% if eid_tag not in seen_eids %}
              {% set seen_eids = seen_eids ~ eid ~ ',' %}
              {% set dev = device_id(eid) %}
              {% set disabled_by = device_attr(eid, 'disabled_by') %}
              {# Skip disabled or ignored devices #}
              {% if dev and dev not in ignored_ids and disabled_by == None %}
                {% set dev_tag = ',' ~ dev ~ ',' %}
                {% if dev_tag not in seen_devs %}
                  {% set seen_devs = seen_devs ~ dev ~ ',' %}
                  {{ comma() }}"{{ dev }}": "{{ eid }}"
                {% endif %}
              {% endif %}
            {% endif %}
          {% endfor %}
        {% endfor %}
        }
    alias: Choose representative entity per device
    enabled: true

  # ── Evaluation helpers ──────────────────────────────────────────────────────
  - variables:
      candidate_entity_map: >-
        {# Rehydrate metadata by entity_id for quick lookups during evaluation #}
        {% set info = candidate_info_json | replace('\n','') | from_json %}
        {# Build JSON object mapping eid -> item without mutating structures #}
        { {% set comma = joiner(',') %}
          {% for item in info %}
            {{ comma() }}"{{ item.entity_id }}": {{ item | tojson }}
          {% endfor %}
        }
    alias: Map entities to cached metadata

  # ── Missing evaluation ──────────────────────────────────────────────────────
  - variables:
      missing_items_json: >-
        {# Evaluate each representative and emit JSON rows for devices needing attention #}
        {% set by_eid = candidate_entity_map | replace('\n','') | from_json %}
        {% set now_val = now_ts | float %}
        {% set reps = reps_json | replace('\n','') | from_json %}
        {% set slow_ids = slow_talkers | map(attribute=0) | list %}
        {% set comma = joiner(',') %}
        [
        {% for dev, eid in reps.items() %}
          {% set data = by_eid[eid] %}
          {% set state = data.state %}
          {% set lc = data.last_changed | float %}
          {#  Here is the final decision point to filter out false positives.
              If the device is in a bad state (unavailable/unknown) and has been
              so for longer than the threshold, we emit it as missing.  The
              threshold is longer for devices known to be slow talkers,
              that only show up when they detected a change.
          #}
          {% set is_bad = state in ['unavailable','unknown'] %}
          {% set bad_for = (now_val - lc) if is_bad else 0 %}
          {% set is_slow = dev in slow_ids %}
          {% set unavailable_threshold = ALERT_SLOW_BUFFER_SECS if is_slow else ALERT_FAST_BUFFER_SECS %}
          {% set missing_flag = (is_bad and bad_for >= unavailable_threshold) %}
          {% if missing_flag %}
            {% set since_secs = bad_for | int %}
            {% set name = state_attr(eid, 'friendly_name') or device_attr(eid, 'name') or eid %}
            {% set area = area_name(eid) %}
            {{ comma() }} {
              "device_id": "{{ dev }}",
              "entity_id": "{{ eid }}",
              "name": "{{ name }}",
              "area": "{{ area }}",
              "since": {{ since_secs }},
              "mode": "unavailable",
              "is_slow": {{ is_slow | int }}
            }
          {% endif %}
        {% endfor %}
        ]
    alias: Compute missing device details

  - variables:
      missing_count: |-
        {{ (missing_items_json | replace('
        ','') | from_json) | count }}
    alias: Count missing devices

  - variables:
      missing_items_json_by_area: >-
        {# Group missing devices by their area for friendlier presentation #}
        {% set items = missing_items_json | replace('\n','') | from_json %}
        {% set groups = items | groupby('area') %}
        {% set comma_area = joiner(',') %}
        {
        {% for group in groups %}
          {{ comma_area() }}{{ (group.grouper | default('', true)) | tojson }}: [
            {% set comma_item = joiner(',') %}
            {% for entry in group.list %}
              {{ comma_item() }}{{ entry | tojson }}
            {% endfor %}
          ]
        {% endfor %}
        }
    alias: Group missing devices by area

  # ── Notification text assembly ──────────────────────────────────────────────
  - variables:
      lines: >-
        {# Render grouped bullet sections while honoring MAX_LINES and whitespace expectations #}
        {% set grouped = missing_items_json_by_area | replace('\n','') | from_json %}
        {% set maxlines = MAX_LINES | int %}
        {% set ns = namespace(count=0, extra=0) %}
        {% set join_area = joiner('\n\n') %}
        {%- for area, entries in grouped | dictsort -%}
          {%- set area_block -%}
            {%- set join_entry = joiner('\n') -%}
            {%- for entry in entries -%}
              {%- if maxlines <= 0 or ns.count < maxlines -%}
                {% set ns.count = ns.count + 1 %}
                {{- join_entry() -}}β€’ [{{ entry.name }}](/config/devices/device/{{ entry.device_id }}) β€” unavailable since *{{ (entry.since // 3600)|int ~ 'h ' if entry.since // 3600 else '' }}{{ ((entry.since % 3600) // 60)|int ~ 'm ' if (entry.since // 60) else '' }}{{ (entry.since % 60)|int ~ 's' }}*{{ ' [slow]' if entry.is_slow else '' }}
              {%- elif maxlines > 0 -%}
                {% set ns.extra = ns.extra + 1 %}
              {%- endif -%}
            {%- endfor -%}
          {%- endset -%}
          {%- if area_block | trim -%}
            {{- join_area() -}}
            {{area if area else 'None'}}
            {{area_block | trim}}
          {%- endif -%}
        {%- endfor -%}
        {%- if ns.extra > 0 -%}
          {{- join_area() -}}
          {{'… ' ~ ns.extra ~ ' more not shown'}}
        {%- endif -%}
    alias: Render notification lines

  - variables:
      body_text: |-
        {# Choose between the all-clear message and the grouped device report #}
        {% if missing_count | int == 0 -%}
          All monitored devices are reachable.
        {% else -%}
          **Missing devices: {{ missing_count }}**
          {{ lines }}
        {% endif %}
    alias: Compose notification body
    enabled: true

  # ── Notify or dismiss persistent notification ──────────────────────────────
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ missing_count | int > 0 }}"
            alias: missing_count > 0
        sequence:
          - action: persistent_notification.create
            data:
              title: Connectivity Watch
              message: "{{ body_text }}"
              notification_id: "{{ NOTIF_ID }}"
    default:
      - action: persistent_notification.dismiss
        data:
          notification_id: "{{ NOTIF_ID }}"
    alias: Notify when missing devices exist

1 Like