#:########################################################################################:# # Battery Notes Alerts Package # #:########################################################################################:# # # TITLE: # Battery Notes Alerts # # FILE: # packages/battery_notes_alerts.yaml # # VERSION: # V1.1.0 2026-08-26 # # PURPOSE: # Sends immediate Pushover alerts when a non-temporary Battery Notes device newly # reports a low battery or becomes unavailable, plus a Thursday weekly digest. # # Immediate alerts are limited to one alert of each type per device per calendar day. # # SAFETY / PERFORMANCE DESIGN: # - No automation-level templated variables. # - Low battery detection uses the native Battery Notes threshold event. # - Weekly Battery Notes checks use raise_events: false. # - Immediate automations never scan all Home Assistant states. # - Immediate automations never call device_entities(). # - The offline watcher does only cheap filtering on state_changed events. # - Offline alerts are inhibited for 10 minutes after Home Assistant starts. # - Weekly list processing is bounded to 100 returned items per category. # - Devices with "Temporary" anywhere in the Battery Notes note are always ignored. # # CONTROLS: # - input_boolean.battery_notes_alerts_master_enable # Master enable/disable for all notifications from this package. # # - Battery Notes - Immediate Low Alert # May also be independently enabled/disabled in Automations. # # - Battery Notes - Immediate Offline Alert # May also be independently enabled/disabled in Automations. # # - Battery Notes - Weekly Digest # May also be independently enabled/disabled in Automations. # # OPERATION: # - LOW: # Native Battery Notes threshold event only. # Reminder-generated events are ignored. # Maximum one low alert per device per calendar day. # # - OFFLINE: # Battery Notes Battery+ entities changing from a valid state to # unknown/unavailable. # Suppressed for 10 minutes after Home Assistant starts. # Maximum one offline alert per device per calendar day. # # - WEEKLY: # Thursday at 19:00. # Reports current low batteries and batteries not reporting for 2+ days. # Battery Notes devices marked Temporary are excluded. # # DEPENDENCIES: # - Battery Notes integration # - packages/view_road_announcement_router.yaml # # VERSION HISTORY: # - V1.1.0 2026-08-26: Complete redesign using native Battery Notes events/actions. # Removes high-frequency state scanning and top-level templates. # Adds Temporary filtering, daily per-device alert suppression, # startup offline suppression and master enable control. # - V1.0.2 2026-08-25: RETIRED - unsafe high-frequency architecture. # - V1.0.1 2026-08-25: RETIRED - unsafe high-frequency architecture. # - V1.0.0 2026-08-25: RETIRED - unsafe high-frequency architecture. # #:########################################################################################:# #:########################################################################################:# # HELPERS # #:########################################################################################:# input_boolean: # Master safety switch. # # No "initial" value is specified intentionally: # - First creation defaults to OFF. # - Thereafter Home Assistant restores the previous state. battery_notes_alerts_master_enable: name: "Battery Notes Alerts - Master Enable" icon: mdi:battery-alert # Internal startup guard. # # This MUST start false on every Home Assistant restart. battery_notes_offline_alerts_armed: name: "Battery Notes Alerts - Offline Armed" icon: mdi:shield-clock initial: false input_text: # Daily de-duplication caches. # # Format: # YYYY-MM-DD|xxxxxxxxxx|xxxxxxxxxx|... # # Only the final 10 characters of the device identifier are stored. # A maximum of the 20 most recent device tokens is retained. # # No initial value is set so the cache survives Home Assistant restarts. battery_notes_low_alert_cache: name: "Battery Notes - Low Alert Cache" max: 255 icon: mdi:battery-alert-variant battery_notes_offline_alert_cache: name: "Battery Notes - Offline Alert Cache" max: 255 icon: mdi:battery-off-outline #:########################################################################################:# # AUTOMATIONS # #:########################################################################################:# automation: #:######################################################################################:# # Offline Alert Startup Guard # #:######################################################################################:# - id: battery_notes_arm_offline_alerts alias: "Battery Notes - Arm Offline Alerts" description: > Arms Battery Notes offline notifications 10 minutes after Home Assistant starts. This prevents normal startup/integration initialisation from being interpreted as devices suddenly going offline. mode: restart triggers: - trigger: homeassistant event: start actions: - action: input_boolean.turn_off target: entity_id: input_boolean.battery_notes_offline_alerts_armed - delay: "00:10:00" - action: input_boolean.turn_on target: entity_id: input_boolean.battery_notes_offline_alerts_armed #:######################################################################################:# # Immediate Low Battery Alert # #:######################################################################################:# - id: battery_notes_immediate_low_alert alias: "Battery Notes - Immediate Low Alert" description: > Sends one immediate Pushover alert when a non-temporary Battery Notes device genuinely crosses into its low-battery state. Reminder-generated Battery Notes events are ignored. A device can generate at most one low alert per calendar day. mode: queued max: 10 triggers: - trigger: event event_type: battery_notes_battery_threshold event_data: battery_low: true reminder: false conditions: # Master control. - condition: state entity_id: input_boolean.battery_notes_alerts_master_enable state: "on" # Temporary Battery Notes devices must never generate notifications. - condition: template value_template: >- {% set note = trigger.event.data.note | default('', true) | lower %} {{ 'temporary' not in note }} # Require some stable identifier for daily de-duplication. - condition: template value_template: >- {{ (trigger.event.data.device_id | default('', true)) != '' or (trigger.event.data.source_entity_id | default('', true)) != '' }} actions: # IMPORTANT: # This de-duplication check is deliberately an ACTION condition rather than an # automation-level condition. Because this automation is mode: queued, two events # arriving together are serialised before checking/updating the cache. - condition: template value_template: >- {% set identifier = trigger.event.data.device_id | default('', true) or trigger.event.data.source_entity_id | default('', true) %} {% set token = identifier[-10:] %} {% set today = now().date() | string %} {% set cache = states('input_text.battery_notes_low_alert_cache') %} {% set previous = cache.split('|')[1:] if cache.startswith(today ~ '|') else [] %} {{ token not in previous }} - action: script.view_road_announcement data: channels: "pushover_zorruno" title: "Battery Alert" short_announcement: >- {% set name = trigger.event.data.device_name | default('Unknown Battery Device', true) %} {% set level = trigger.event.data.battery_level | default(-1, true) | float(-1) %} {% set battery_type = trigger.event.data.battery_type_and_quantity | default('Not recorded', true) %} New Alert: {{ name }}, Battery Low{% if level >= 0 %} {{ level | round(0) | int }}%{% endif %} {{ battery_type }} long_announcement: >- {% set name = trigger.event.data.device_name | default('Unknown Battery Device', true) %} {% set level = trigger.event.data.battery_level | default(-1, true) | float(-1) %} {% set battery_type = trigger.event.data.battery_type_and_quantity | default('Not recorded', true) %} New Alert: {{ name }}, Battery Low{% if level >= 0 %} {{ level | round(0) | int }}%{% endif %} {{ battery_type }} # Record this device only after the notification action has completed. - action: input_text.set_value target: entity_id: input_text.battery_notes_low_alert_cache data: value: >- {% set identifier = trigger.event.data.device_id | default('', true) or trigger.event.data.source_entity_id | default('', true) %} {% set token = identifier[-10:] %} {% set today = now().date() | string %} {% set cache = states('input_text.battery_notes_low_alert_cache') %} {% set previous = cache.split('|')[1:] if cache.startswith(today ~ '|') else [] %} {% set updated = (previous + [token])[-20:] %} {{ today ~ '|' ~ updated | join('|') }} #:######################################################################################:# # Immediate Offline Alert # #:######################################################################################:# - id: battery_notes_immediate_offline_alert alias: "Battery Notes - Immediate Offline Alert" description: > Sends one immediate Pushover alert when a non-temporary Battery Notes Battery+ entity genuinely changes from an available state to unknown/unavailable. Alerts are inhibited for the first 10 minutes after Home Assistant starts. A device can generate at most one offline alert per calendar day. mode: queued max: 20 triggers: - trigger: event event_type: state_changed conditions: # Keep the first checks extremely cheap because state_changed is a global event. - condition: state entity_id: input_boolean.battery_notes_alerts_master_enable state: "on" - condition: state entity_id: input_boolean.battery_notes_offline_alerts_armed state: "on" # Only Battery Notes generated Battery+ entities are interesting. # No regex and no global state lookup is required. - condition: template value_template: >- {% set entity_id = trigger.event.data.entity_id | default('', true) %} {{ (entity_id.startswith('sensor.') and entity_id.endswith('_battery_plus')) or (entity_id.startswith('binary_sensor.') and entity_id.endswith('_battery_plus_low')) }} # Require a genuine transition from available -> unavailable. - condition: template value_template: >- {% set old = trigger.event.data.old_state %} {% set new = trigger.event.data.new_state %} {{ old is not none and new is not none and old.state not in ['unknown', 'unavailable'] and new.state in ['unknown', 'unavailable'] }} # Temporary devices are ignored. # # Prefer the new state's note but fall back to the old state's attributes because # integrations do not always retain every attribute on an unavailable state. - condition: template value_template: >- {% set old = trigger.event.data.old_state %} {% set new = trigger.event.data.new_state %} {% set note = new.attributes.get( 'note', old.attributes.get('note', '') ) | default('', true) | lower %} {{ 'temporary' not in note }} actions: # As with low alerts, do this after entering the queued action sequence so two # related Battery+ entities becoming unavailable together cannot both notify. - condition: template value_template: >- {% set old = trigger.event.data.old_state %} {% set new = trigger.event.data.new_state %} {% set identifier = new.attributes.get('device_id', '') or old.attributes.get('device_id', '') or new.entity_id %} {% set token = identifier[-10:] %} {% set today = now().date() | string %} {% set cache = states('input_text.battery_notes_offline_alert_cache') %} {% set previous = cache.split('|')[1:] if cache.startswith(today ~ '|') else [] %} {{ token not in previous }} - action: script.view_road_announcement data: channels: "pushover_zorruno" title: "Battery Alert" short_announcement: >- {% set old = trigger.event.data.old_state %} {% set new = trigger.event.data.new_state %} {% set name = new.attributes.get('device_name') or old.attributes.get('device_name') or new.attributes.get('friendly_name') or old.attributes.get('friendly_name') or new.name %} {% set battery_type = new.attributes.get('battery_type_and_quantity') or old.attributes.get('battery_type_and_quantity') or 'Not recorded' %} New Alert: {{ name }}, Battery Unavailable {{ battery_type }} long_announcement: >- {% set old = trigger.event.data.old_state %} {% set new = trigger.event.data.new_state %} {% set name = new.attributes.get('device_name') or old.attributes.get('device_name') or new.attributes.get('friendly_name') or old.attributes.get('friendly_name') or new.name %} {% set battery_type = new.attributes.get('battery_type_and_quantity') or old.attributes.get('battery_type_and_quantity') or 'Not recorded' %} New Alert: {{ name }}, Battery Unavailable {{ battery_type }} - action: input_text.set_value target: entity_id: input_text.battery_notes_offline_alert_cache data: value: >- {% set old = trigger.event.data.old_state %} {% set new = trigger.event.data.new_state %} {% set identifier = new.attributes.get('device_id', '') or old.attributes.get('device_id', '') or new.entity_id %} {% set token = identifier[-10:] %} {% set today = now().date() | string %} {% set cache = states('input_text.battery_notes_offline_alert_cache') %} {% set previous = cache.split('|')[1:] if cache.startswith(today ~ '|') else [] %} {% set updated = (previous + [token])[-20:] %} {{ today ~ '|' ~ updated | join('|') }} #:######################################################################################:# # Weekly Digest # #:######################################################################################:# - id: battery_notes_weekly_digest alias: "Battery Notes - Weekly Digest" description: > Sends one Battery Notes summary every Thursday at 19:00. Reports current low batteries and batteries which have not reported for at least two days. Temporary Battery Notes devices are excluded. mode: single triggers: - trigger: time at: "19:00:00" conditions: - condition: time weekday: - thu - condition: state entity_id: input_boolean.battery_notes_alerts_master_enable state: "on" actions: # IMPORTANT: # raise_events MUST remain false. # # This retrieves the current low list without creating Battery Notes threshold # reminder events. - action: battery_notes.check_battery_low data: raise_events: false response_variable: battery_low_response # Ask Battery Notes for devices which have not reported for at least two days. # Again, retrieve a response only; do not raise one event per device. - action: battery_notes.check_battery_last_reported data: days_last_reported: 2 raise_events: false response_variable: battery_report_response - action: script.view_road_announcement data: channels: "pushover_zorruno" title: "Weekly Battery Digest" short_announcement: "Weekly Battery Digest" long_announcement: >- {# ------------------------------------------------------------------------- #} {# Obtain response arrays. #} {# #} {# check_battery_battery_low is confirmed from this HA installation. #} {# Two possible last-reported response keys are accepted defensively. #} {# ------------------------------------------------------------------------- #} {% set low_items = battery_low_response.get( 'check_battery_battery_low', battery_low_response.get('check_battery_low', []) ) %} {% set report_items = battery_report_response.get( 'check_battery_last_reported', battery_report_response.get( 'check_battery_battery_last_reported', [] ) ) %} {# ------------------------------------------------------------------------- #} {# Resolve the Battery Notes note. #} {# #} {# The low action response on this installation does not include "note", #} {# so fall back to the attributes of Battery Notes entities. #} {# This lookup occurs ONCE PER WEEK ONLY and is bounded below. #} {# ------------------------------------------------------------------------- #} {% macro battery_note(item) -%} {% set ns = namespace( note=item.get('note', '') | default('', true) ) %} {% if not ns.note %} {% set item_device_id = item.get('device_id', '') | default('', true) %} {% set item_source_id = item.get('source_entity_id', '') | default('', true) %} {% for entity_id in integration_entities('battery_notes') %} {% if not ns.note %} {% set entity_device_id = state_attr(entity_id, 'device_id') | default('', true) %} {% set entity_source_id = state_attr(entity_id, 'source_entity_id') | default('', true) %} {% if (item_device_id and entity_device_id == item_device_id) or (item_source_id and entity_source_id == item_source_id) %} {% set found_note = state_attr(entity_id, 'note') | default('', true) %} {% if found_note %} {% set ns.note = found_note %} {% endif %} {% endif %} {% endif %} {% endfor %} {% endif %} {{ ns.note }} {%- endmacro %} {# ------------------------------------------------------------------------- #} {# Build bounded problem lists, excluding Temporary devices. #} {# ------------------------------------------------------------------------- #} {% set summary = namespace(low=[], not_reporting=[]) %} {% for item in low_items[:100] %} {% set note = battery_note(item) | trim | lower %} {% if 'temporary' not in note %} {% set name = item.get('device_name', 'Unknown Battery Device') | default('Unknown Battery Device', true) %} {% set level = item.get('battery_level', -1) | default(-1, true) | float(-1) %} {% set battery_type = item.get('battery_type_and_quantity', 'Not recorded') | default('Not recorded', true) %} {% if level >= 0 %} {% set line = name ~ ' - ' ~ (level | round(0) | int) ~ '% - ' ~ battery_type %} {% else %} {% set line = name ~ ' - ' ~ battery_type %} {% endif %} {% set summary.low = summary.low + [line] %} {% endif %} {% endfor %} {% for item in report_items[:100] %} {% set note = battery_note(item) | trim | lower %} {% if 'temporary' not in note %} {% set name = item.get('device_name', 'Unknown Battery Device') | default('Unknown Battery Device', true) %} {% set days = item.get('battery_last_reported_days', 0) | default(0, true) | int(0) %} {% set last_level = item.get('battery_last_reported_level', -1) | default(-1, true) | float(-1) %} {% set battery_type = item.get('battery_type_and_quantity', 'Not recorded') | default('Not recorded', true) %} {% if last_level >= 0 %} {% set line = name ~ ' - ' ~ days ~ ' days - last ' ~ (last_level | round(0) | int) ~ '% - ' ~ battery_type %} {% else %} {% set line = name ~ ' - ' ~ days ~ ' days - ' ~ battery_type %} {% endif %} {% set summary.not_reporting = summary.not_reporting + [line] %} {% endif %} {% endfor %} Weekly Battery Digest {% if summary.low %} Low Battery: {{ summary.low | count }} {% for line in summary.low %} • {{ line }} {% endfor %} {% else %} Low Battery: 0 {% endif %} {% if summary.not_reporting %} Not Reporting (2+ days): {{ summary.not_reporting | count }} {% for line in summary.not_reporting %} • {{ line }} {% endfor %} {% else %} Not Reporting (2+ days): 0 {% endif %} {% if not summary.low and not summary.not_reporting %} All monitored Battery Notes devices are OK. {% endif %}