From 90e1222a2f09cda93ed2da04de4fde848839fdd5 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 22:20:06 +1200 Subject: [PATCH] Update appliance monitoring configuration --- packages/appliance_monitor_processor.yaml | 763 +++++++++++++++++++++ packages/appliances_status_monitoring.yaml | 255 +++++++ 2 files changed, 1018 insertions(+) create mode 100644 packages/appliance_monitor_processor.yaml create mode 100644 packages/appliances_status_monitoring.yaml diff --git a/packages/appliance_monitor_processor.yaml b/packages/appliance_monitor_processor.yaml new file mode 100644 index 0000000..3afc46f --- /dev/null +++ b/packages/appliance_monitor_processor.yaml @@ -0,0 +1,763 @@ +################################################################################ +# PACKAGE: Appliance Monitor Processor +################################################################################ +# +# Version: 1.2 +# Date: 2026-06-09 +# +# Purpose: +# Shared appliance power monitoring processor. +# +# This script is called by appliances_status_monitoring.yaml. It reads the +# previous appliance monitor state from the appliance monitor MQTT sensor, +# calculates the next state and cycle totals, publishes retained JSON state +# back to MQTT, and calls the View Road announcement router when a cycle +# finishes. +# +# Design: +# - One shared processing script. +# - MQTT retained JSON state per appliance. +# - One MQTT sensor per appliance. +# - Timestamp-based energy and cost calculation. +# - Rolling average state detection. +# +# Default values: +# debug_flow: false +# resolution: 7 +# standby_power: 2 +# operating_power_min: 12 +# paid_tariff_start: "00:00" +# paid_tariff_end: "21:00" +# paid_tariff_cost: 0.2023 +# offpeak_tariff_cost: 0.0 +# max_sample_gap_seconds: 900 +# min_cycle_notify_seconds: 300 +# +# States: +# Off +# Standby +# Operating +# Finished +# +# Notes: +# Version 1.2: +# - Added min_cycle_notify_seconds. +# - Finished cycles shorter than this are still recorded, but do not send +# notifications. +# - Added MQTT attributes showing whether notification was allowed or +# suppressed. +# +# Version 1.1: +# - Accepts lower-case retained states from previous package versions, such +# as standby, operating, and finished. +# +################################################################################ +# FINISHED ANNOUNCEMENT +# The finishing script calls the package view_road_announcement_router.yaml. +# If you want to use your own announcement system, it will occur at the end +# at am_cycle_notify_allowed being true, so change to your own announcement +# method there. +# +# Announcements can use: +# am_announcement_channels +# am_announcement_title +# am_short_announcement +# am_long_announcement +# am_matrix_indicator +# +################################################################################ + +script: + appliance_monitor_process_sample: + alias: Appliance Monitor - Process Sample + icon: mdi:meter-electric + mode: queued + max: 50 + + fields: + appliance_id: + name: Appliance ID + description: Short unique ID, for example washing_machine. + selector: + text: + + appliance_name: + name: Appliance Name + description: Human readable appliance name. + selector: + text: + + appliance_action: + name: Appliance Action + description: Human readable action, for example washing. + selector: + text: + + power_w: + name: Power W + description: Current instantaneous appliance power in watts. + selector: + number: + min: 0 + max: 10000 + step: 0.1 + unit_of_measurement: W + + monitor_sensor: + name: Monitor Sensor + description: MQTT monitor sensor used to read previous state. + selector: + entity: + domain: sensor + + state_topic: + name: MQTT State Topic + description: Retained MQTT topic used to publish monitor state JSON. + selector: + text: + + min_cycle_notify_seconds: + name: Minimum Cycle Notify Seconds + description: Finished cycles shorter than this are recorded but do not send notifications. + default: 300 + selector: + number: + min: 0 + max: 86400 + step: 1 + unit_of_measurement: s + + sequence: + ########################################################################## + # CORE DEFAULTS AND PASSED VALUES + ########################################################################## + + - variables: + am_appliance_id: "{{ appliance_id | default('appliance', true) | string }}" + am_appliance_name: "{{ appliance_name | default('Appliance', true) | string }}" + am_appliance_action: "{{ appliance_action | default('running', true) | string }}" + am_power_w: "{{ power_w | default(0, true) | float(0) }}" + am_monitor_sensor: "{{ monitor_sensor | default('', true) | string }}" + am_state_topic: "{{ state_topic | default('', true) | string }}" + + am_debug_flow: "{{ debug_flow | default(false, true) | bool(false) }}" + am_resolution: "{{ resolution | default(7, true) | int(7) }}" + am_standby_power: "{{ standby_power | default(2, true) | float(2) }}" + am_operating_power_min: "{{ operating_power_min | default(12, true) | float(12) }}" + + am_paid_tariff_start: "{{ paid_tariff_start | default('00:00', true) | string }}" + am_paid_tariff_end: "{{ paid_tariff_end | default('21:00', true) | string }}" + am_paid_tariff_cost: "{{ paid_tariff_cost | default(0.2023, true) | float(0.2023) }}" + am_offpeak_tariff_cost: "{{ offpeak_tariff_cost | default(0.0, true) | float(0.0) }}" + + am_max_sample_gap_seconds: "{{ max_sample_gap_seconds | default(900, true) | int(900) }}" + am_min_cycle_notify_seconds: "{{ min_cycle_notify_seconds | default(300, true) | int(300) }}" + + ########################################################################## + # ANNOUNCEMENT DEFAULTS AND PASSED VALUES + ########################################################################## + + - variables: + am_announcement_channels: >- + {{ announcement_channels | default('pushover_zorruno, sony_tv_lounge, lounge_google_home_voice, led_matrix_1', true) | string }} + + am_short_announcement: >- + {{ short_announcement | default((am_appliance_action | title) ~ ' complete', true) | string }} + + am_long_announcement_override: >- + {{ long_announcement | default('', true) | string }} + + am_announcement_title: >- + {{ title | default(am_appliance_name ~ ' Notification', true) | string }} + + am_matrix_indicator: "{{ matrix_indicator | default('', true) | string }}" + + ########################################################################## + # PREVIOUS STATE FROM MQTT MONITOR SENSOR ATTRIBUTES + ########################################################################## + + - variables: + am_now_ts: "{{ as_timestamp(now()) }}" + + am_previous_state: >- + {%- set s = states(am_monitor_sensor) | string | lower | trim -%} + {{ {'operating': 'Operating', 'finished': 'Finished', 'standby': 'Standby', 'off': 'Off'}.get(s, 'Off') }} + + am_last_power_w: "{{ state_attr(am_monitor_sensor, 'last_power_w') | float(am_power_w) }}" + am_last_sample_ts: "{{ state_attr(am_monitor_sensor, 'last_sample_ts') | float(0) }}" + + am_existing_cycle_start_ts: "{{ state_attr(am_monitor_sensor, 'cycle_start_ts') | float(0) }}" + am_existing_cycle_stop_ts: "{{ state_attr(am_monitor_sensor, 'cycle_stop_ts') | float(0) }}" + + am_existing_cycle_energy_kwh: "{{ state_attr(am_monitor_sensor, 'cycle_energy_kwh') | float(0) }}" + am_existing_cycle_paid_energy_kwh: "{{ state_attr(am_monitor_sensor, 'cycle_paid_energy_kwh') | float(0) }}" + am_existing_cycle_free_energy_kwh: "{{ state_attr(am_monitor_sensor, 'cycle_free_energy_kwh') | float(0) }}" + am_existing_cycle_cost: "{{ state_attr(am_monitor_sensor, 'cycle_cost') | float(0) }}" + + am_previous_recent_samples_json: >- + {% set raw = state_attr(am_monitor_sensor, 'recent_samples_json') | string | trim %} + {{ raw if raw.startswith('[') else '[]' }} + + ########################################################################## + # ELAPSED TIME + ########################################################################## + + - variables: + am_elapsed_sec_raw: >- + {% if am_last_sample_ts | float(0) > 0 %} + {{ (am_now_ts | float(0)) - (am_last_sample_ts | float(0)) }} + {% else %} + 0 + {% endif %} + + am_effective_elapsed_sec: >- + {% set elapsed = am_elapsed_sec_raw | float(0) %} + {% if elapsed <= 0 %} + 0 + {% elif elapsed > (am_max_sample_gap_seconds | int(900)) %} + {{ am_max_sample_gap_seconds | int(900) }} + {% else %} + {{ elapsed }} + {% endif %} + + ########################################################################## + # ROLLING AVERAGE + ########################################################################## + + - variables: + am_previous_recent_samples_list: >- + {% set raw_json = state_attr(am_monitor_sensor, 'recent_samples_json') %} + {% set raw_list = state_attr(am_monitor_sensor, 'recent_samples') %} + {% if raw_json is string %} + {{ raw_json | from_json(default=[]) }} + {% elif raw_json is iterable and raw_json is not string %} + {{ raw_json | list }} + {% elif raw_list is iterable and raw_list is not string %} + {{ raw_list | list }} + {% else %} + [] + {% endif %} + + am_recent_samples_list: >- + {% set old = am_previous_recent_samples_list %} + {% set ns = namespace(items=[am_power_w | float(0)]) %} + {% for item in old %} + {% if loop.index < (am_resolution | int(7)) %} + {% set ns.items = ns.items + [item | float(0)] %} + {% endif %} + {% endfor %} + {{ ns.items }} + + am_recent_samples_json: >- + {{ am_recent_samples_list | to_json }} + + am_average_power_w: >- + {% set samples = am_recent_samples_list %} + {% if samples | count > 0 %} + {{ ((samples | sum) / (samples | count)) | round(3) }} + {% else %} + 0 + {% endif %} + + ########################################################################## + # STATE MACHINE + ########################################################################## + + - variables: + am_new_state: >- + {% set avg = am_average_power_w | float(0) %} + {% set standby = am_standby_power | float(2) %} + {% set operate = am_operating_power_min | float(12) %} + {% set prev = am_previous_state | string %} + {% if prev == 'Operating' and avg < operate %} + Finished + {% elif avg < standby %} + Off + {% elif avg >= operate %} + Operating + {% elif avg >= standby and prev in ['Off', 'Finished'] %} + Standby + {% else %} + {{ prev }} + {% endif %} + + am_state_changed: "{{ (am_new_state | string) != (am_previous_state | string) }}" + am_cycle_started: "{{ am_new_state == 'Operating' and am_previous_state != 'Operating' }}" + am_cycle_finished: "{{ am_new_state == 'Finished' and am_previous_state == 'Operating' }}" + + ########################################################################## + # ACCUMULATION WINDOW + ########################################################################## + + - variables: + am_accumulation_seconds: >- + {% set elapsed = am_effective_elapsed_sec | float(0) %} + {% if elapsed <= 0 %} + 0 + {% elif am_previous_state == 'Operating' and am_new_state == 'Operating' %} + {{ elapsed }} + {% elif am_previous_state != 'Operating' and am_new_state == 'Operating' %} + {{ elapsed / 2 }} + {% elif am_previous_state == 'Operating' and am_new_state == 'Finished' %} + {{ elapsed / 2 }} + {% else %} + 0 + {% endif %} + + am_accumulation_average_power_w: >- + {% set last = am_last_power_w | float(0) %} + {% set current = am_power_w | float(0) %} + {% set operate = am_operating_power_min | float(12) %} + {% if am_previous_state == 'Operating' and am_new_state == 'Operating' %} + {{ ((last + current) / 2) | round(3) }} + {% elif am_previous_state != 'Operating' and am_new_state == 'Operating' %} + {{ ((operate + current) / 2) | round(3) }} + {% elif am_previous_state == 'Operating' and am_new_state == 'Finished' %} + {{ ((last + operate) / 2) | round(3) }} + {% else %} + 0 + {% endif %} + + am_accumulation_start_ts: >- + {% set elapsed = am_effective_elapsed_sec | float(0) %} + {% set accum = am_accumulation_seconds | float(0) %} + {% if accum <= 0 %} + {{ am_now_ts | float(0) }} + {% elif am_previous_state == 'Operating' and am_new_state == 'Operating' %} + {{ (am_now_ts | float(0)) - elapsed }} + {% elif am_previous_state != 'Operating' and am_new_state == 'Operating' %} + {{ (am_now_ts | float(0)) - accum }} + {% elif am_previous_state == 'Operating' and am_new_state == 'Finished' %} + {{ (am_now_ts | float(0)) - elapsed }} + {% else %} + {{ am_now_ts | float(0) }} + {% endif %} + + am_accumulation_stop_ts: >- + {% set elapsed = am_effective_elapsed_sec | float(0) %} + {% set accum = am_accumulation_seconds | float(0) %} + {% if accum <= 0 %} + {{ am_now_ts | float(0) }} + {% elif am_previous_state == 'Operating' and am_new_state == 'Operating' %} + {{ am_now_ts | float(0) }} + {% elif am_previous_state != 'Operating' and am_new_state == 'Operating' %} + {{ am_now_ts | float(0) }} + {% elif am_previous_state == 'Operating' and am_new_state == 'Finished' %} + {{ (am_now_ts | float(0)) - elapsed + accum }} + {% else %} + {{ am_now_ts | float(0) }} + {% endif %} + + am_interval_mid_ts: >- + {% set start = am_accumulation_start_ts | float(0) %} + {% set accum = am_accumulation_seconds | float(0) %} + {% if accum > 0 %} + {{ start + (accum / 2) }} + {% else %} + {{ am_now_ts | float(0) }} + {% endif %} + + ########################################################################## + # TARIFF CALCULATION + ########################################################################## + + - variables: + am_paid_start_minutes: >- + {% set p = (am_paid_tariff_start | string).split(':') %} + {% set hh = p[0] if p | count > 0 else 0 %} + {% set mm = p[1] if p | count > 1 else 0 %} + {{ (hh | int(0)) * 60 + (mm | int(0)) }} + + am_paid_end_minutes: >- + {% set p = (am_paid_tariff_end | string).split(':') %} + {% set hh = p[0] if p | count > 0 else 0 %} + {% set mm = p[1] if p | count > 1 else 0 %} + {{ (hh | int(0)) * 60 + (mm | int(0)) }} + + am_interval_mid_minutes: >- + {% set h = am_interval_mid_ts | float(0) | timestamp_custom('%H', true) | int(0) %} + {% set m = am_interval_mid_ts | float(0) | timestamp_custom('%M', true) | int(0) %} + {{ h * 60 + m }} + + am_is_paid_interval: >- + {% set start = am_paid_start_minutes | int(0) %} + {% set end = am_paid_end_minutes | int(0) %} + {% set mid = am_interval_mid_minutes | int(0) %} + {% if start == end %} + true + {% elif start < end %} + {{ mid >= start and mid < end }} + {% else %} + {{ mid >= start or mid < end }} + {% endif %} + + am_tariff_rate: >- + {% if am_is_paid_interval | bool(false) %} + {{ am_paid_tariff_cost | float(0.2023) }} + {% else %} + {{ am_offpeak_tariff_cost | float(0.0) }} + {% endif %} + + ########################################################################## + # INTERVAL ENERGY AND COST + ########################################################################## + + - variables: + am_interval_energy_kwh: >- + {{ + ( + (am_accumulation_average_power_w | float(0)) + * (am_accumulation_seconds | float(0)) + / 3600000 + ) | round(6) + }} + + am_interval_cost: >- + {{ ((am_interval_energy_kwh | float(0)) * (am_tariff_rate | float(0))) | round(6) }} + + am_should_accumulate: "{{ (am_accumulation_seconds | float(0)) > 0 }}" + + ########################################################################## + # CYCLE TOTAL BASES + ########################################################################## + + - variables: + am_cycle_energy_base_kwh: >- + {% if am_cycle_started | bool(false) %} + 0 + {% else %} + {{ am_existing_cycle_energy_kwh | float(0) }} + {% endif %} + + am_cycle_paid_energy_base_kwh: >- + {% if am_cycle_started | bool(false) %} + 0 + {% else %} + {{ am_existing_cycle_paid_energy_kwh | float(0) }} + {% endif %} + + am_cycle_free_energy_base_kwh: >- + {% if am_cycle_started | bool(false) %} + 0 + {% else %} + {{ am_existing_cycle_free_energy_kwh | float(0) }} + {% endif %} + + am_cycle_cost_base: >- + {% if am_cycle_started | bool(false) %} + 0 + {% else %} + {{ am_existing_cycle_cost | float(0) }} + {% endif %} + + ########################################################################## + # CYCLE TOTALS + ########################################################################## + + - variables: + am_cycle_energy_new_kwh: >- + {% set add_kwh = am_interval_energy_kwh | float(0) if am_should_accumulate | bool(false) else 0 %} + {{ ((am_cycle_energy_base_kwh | float(0)) + add_kwh) | round(6) }} + + am_cycle_cost_new: >- + {% set add_cost = am_interval_cost | float(0) if am_should_accumulate | bool(false) else 0 %} + {{ ((am_cycle_cost_base | float(0)) + add_cost) | round(6) }} + + am_cycle_paid_energy_new_kwh: >- + {% set add_kwh = am_interval_energy_kwh | float(0) if (am_should_accumulate | bool(false) and am_is_paid_interval | bool(false)) else 0 %} + {{ ((am_cycle_paid_energy_base_kwh | float(0)) + add_kwh) | round(6) }} + + am_cycle_free_energy_new_kwh: >- + {% set add_kwh = am_interval_energy_kwh | float(0) if (am_should_accumulate | bool(false) and not (am_is_paid_interval | bool(false))) else 0 %} + {{ ((am_cycle_free_energy_base_kwh | float(0)) + add_kwh) | round(6) }} + + ########################################################################## + # CYCLE TIMES + ########################################################################## + + - variables: + am_cycle_start_ts_new: >- + {% if am_cycle_started | bool(false) %} + {{ am_accumulation_start_ts | float(0) }} + {% elif am_existing_cycle_start_ts | float(0) > 0 %} + {{ am_existing_cycle_start_ts | float(0) }} + {% else %} + {{ am_now_ts | float(0) }} + {% endif %} + + am_cycle_stop_ts_new: >- + {% if am_cycle_finished | bool(false) %} + {{ am_accumulation_stop_ts | float(0) }} + {% elif am_cycle_started | bool(false) %} + {{ am_accumulation_start_ts | float(0) }} + {% elif am_existing_cycle_stop_ts | float(0) > 0 %} + {{ am_existing_cycle_stop_ts | float(0) }} + {% else %} + {{ am_now_ts | float(0) }} + {% endif %} + + am_cycle_duration_seconds_new: >- + {% set start = am_cycle_start_ts_new | float(0) %} + {% if am_new_state == 'Operating' %} + {% set stop = am_now_ts | float(0) %} + {% else %} + {% set stop = am_cycle_stop_ts_new | float(0) %} + {% endif %} + {% if start > 0 and stop > start %} + {{ (stop - start) | int(0) }} + {% else %} + 0 + {% endif %} + + ########################################################################## + # FORMATTED VALUES + ########################################################################## + + - variables: + am_cycle_energy_formatted: >- + {% set kwh = am_cycle_energy_new_kwh | float(0) %} + {% if kwh >= 1 %} + {{ '%.1f' | format(kwh) }}kWh + {% elif kwh < 0.001 %} + <1Wh + {% else %} + {{ '%.1f' | format(kwh * 1000) }}Wh + {% endif %} + + am_cycle_cost_formatted: >- + {% set val = am_cycle_cost_new | float(0) %} + {% if val < 0.01 %} + <1c + {% elif val < 1 %} + {{ (val * 100) | round(0) | int(0) }}c + {% else %} + ${{ '%.2f' | format(val) }} + {% endif %} + + am_cycle_offpeak_savings: >- + {% set val = ((am_cycle_energy_new_kwh | float(0)) * (am_paid_tariff_cost | float(0.2023))) - (am_cycle_cost_new | float(0)) %} + {{ ([0, val] | max) | round(4) }} + + am_cycle_offpeak_savings_formatted: >- + {% set val = am_cycle_offpeak_savings | float(0) %} + {% if val < 0.01 %} + <1c + {% elif val < 1 %} + {{ (val * 100) | round(0) | int(0) }}c + {% else %} + ${{ '%.2f' | format(val) }} + {% endif %} + + am_cycle_duration_formatted: >- + {% set d = am_cycle_duration_seconds_new | int(0) %} + {% set h = (d // 3600) | int(0) %} + {% set m = ((d % 3600) // 60) | int(0) %} + {% if h < 1 %} + {{ '%02dmin' | format(m) }} + {% else %} + {{ '%02dh %02dmin' | format(h, m) }} + {% endif %} + + am_cycle_start_time_formatted: >- + {{ am_cycle_start_ts_new | float(0) | timestamp_custom('%H:%M', true) }} + + am_cycle_stop_time_formatted: >- + {{ am_cycle_stop_ts_new | float(0) | timestamp_custom('%H:%M', true) }} + + am_cycle_start_local: >- + {{ am_cycle_start_ts_new | float(0) | timestamp_custom('%Y-%m-%d %H:%M:%S', true) }} + + am_cycle_stop_local: >- + {{ am_cycle_stop_ts_new | float(0) | timestamp_custom('%Y-%m-%d %H:%M:%S', true) }} + + am_last_sample_local: >- + {{ am_now_ts | float(0) | timestamp_custom('%Y-%m-%d %H:%M:%S', true) }} + + ########################################################################## + # SUMMARY STRINGS + ########################################################################## + + - variables: + am_summary_short: >- + Your {{ am_appliance_action }} is complete. + + am_summary: >- + Your {{ am_appliance_action }} is complete. + It started at {{ am_cycle_start_time_formatted | trim }}, + finished at {{ am_cycle_stop_time_formatted | trim }}, + and used {{ am_cycle_energy_formatted | trim }}, + taking {{ am_cycle_duration_formatted | trim }} + at a cost of {{ am_cycle_cost_formatted | trim }} + (Off-peak savings of {{ am_cycle_offpeak_savings_formatted | trim }}). + + am_last_summary_new: >- + {% if am_cycle_started | bool(false) %} + Cycle running + {% elif am_cycle_finished | bool(false) %} + {{ am_summary }} + {% else %} + {{ state_attr(am_monitor_sensor, 'last_summary') | default('No cycle recorded', true) }} + {% endif %} + + am_long_announcement: >- + {% if am_long_announcement_override | trim | length > 0 %} + {{ am_long_announcement_override }} + {% else %} + {{ am_summary }} + {% endif %} + + ########################################################################## + # NOTIFICATION DECISION + ########################################################################## + # + # Very short cycles are still recorded, but no announcement is sent. + # Default minimum is 300 seconds / 5 minutes. + # + ########################################################################## + + - variables: + am_cycle_notify_allowed: >- + {{ + am_cycle_finished | bool(false) + and (am_cycle_duration_seconds_new | int(0)) >= (am_min_cycle_notify_seconds | int(300)) + }} + + am_cycle_notification_suppressed: >- + {{ + am_cycle_finished | bool(false) + and not (am_cycle_notify_allowed | bool(false)) + }} + + am_cycle_notification_suppressed_reason: >- + {% if am_cycle_notification_suppressed | bool(false) %} + Cycle was shorter than {{ am_min_cycle_notify_seconds | int(300) }} seconds + {% endif %} + + ########################################################################## + # PUBLISH RETAINED MQTT STATE + ########################################################################## + + - action: mqtt.publish + data: + topic: "{{ am_state_topic }}" + retain: true + qos: 0 + payload: >- + {{ + dict( + appliance_id=am_appliance_id, + appliance_name=am_appliance_name, + appliance_action=am_appliance_action, + + state=am_new_state, + previous_state=am_previous_state, + state_changed=am_state_changed | bool(false), + + power_w=am_power_w | float(0), + last_power_w=am_power_w | float(0), + previous_power_w=am_last_power_w | float(0), + average_power_w=am_average_power_w | float(0), + recent_samples=am_recent_samples_list, + recent_samples_json=am_recent_samples_json, + + last_sample_ts=am_now_ts | float(0), + last_sample_local=am_last_sample_local, + + cycle_start_ts=am_cycle_start_ts_new | float(0), + cycle_stop_ts=am_cycle_stop_ts_new | float(0), + cycle_start_local=am_cycle_start_local, + cycle_stop_local=am_cycle_stop_local, + + cycle_duration_seconds=am_cycle_duration_seconds_new | int(0), + cycle_duration_formatted=am_cycle_duration_formatted | trim, + + min_cycle_notify_seconds=am_min_cycle_notify_seconds | int(300), + cycle_notify_allowed=am_cycle_notify_allowed | bool(false), + cycle_notification_suppressed=am_cycle_notification_suppressed | bool(false), + cycle_notification_suppressed_reason=am_cycle_notification_suppressed_reason | trim, + + cycle_energy_kwh=am_cycle_energy_new_kwh | float(0), + cycle_paid_energy_kwh=am_cycle_paid_energy_new_kwh | float(0), + cycle_free_energy_kwh=am_cycle_free_energy_new_kwh | float(0), + cycle_energy_formatted=am_cycle_energy_formatted | trim, + + cycle_cost=am_cycle_cost_new | float(0), + cycle_cost_formatted=am_cycle_cost_formatted | trim, + + cycle_offpeak_savings=am_cycle_offpeak_savings | float(0), + cycle_offpeak_savings_formatted=am_cycle_offpeak_savings_formatted | trim, + + paid_tariff_start=am_paid_tariff_start, + paid_tariff_end=am_paid_tariff_end, + paid_tariff_cost=am_paid_tariff_cost | float(0), + offpeak_tariff_cost=am_offpeak_tariff_cost | float(0), + + accumulation_seconds=am_accumulation_seconds | float(0), + accumulation_average_power_w=am_accumulation_average_power_w | float(0), + interval_energy_kwh=am_interval_energy_kwh | float(0), + interval_cost=am_interval_cost | float(0), + is_paid_interval=am_is_paid_interval | bool(false), + + summary_short=am_summary_short, + summary=am_summary, + last_summary=am_last_summary_new + ) | to_json + }} + + ########################################################################## + # DEBUG LOGBOOK ENTRY + ########################################################################## + + - if: + - condition: template + value_template: "{{ am_debug_flow | bool(false) }}" + then: + - action: logbook.log + data: + name: "Appliance Monitor" + message: >- + {{ am_appliance_name }}: {{ am_previous_state }} -> {{ am_new_state }}, + power {{ am_power_w | round(1) }}W, + average {{ am_average_power_w | round(1) }}W, + cycle {{ am_cycle_energy_new_kwh | round(3) }}kWh, + cost {{ am_cycle_cost_new | round(4) }} + + ########################################################################## + # SHORT CYCLE SUPPRESSION LOG + ########################################################################## + + - if: + - condition: template + value_template: "{{ am_cycle_notification_suppressed | bool(false) }}" + then: + - action: logbook.log + data: + name: "Appliance Monitor" + message: >- + {{ am_appliance_name }} finished after + {{ am_cycle_duration_formatted | trim }}, below the + {{ am_min_cycle_notify_seconds | int(300) }} second notification + threshold. No announcement was sent. + + ########################################################################## + # FINISHED ANNOUNCEMENT + ########################################################################## + # This script is in additional package view_road_announcement_router.yaml. + # If you want to use your own announcement system, it will occur here + # at am_cycle_notify_allowed being true. + # + # Change to your own announcement method here if needed. + ########################################################################## + + - if: + - condition: template + value_template: "{{ am_cycle_notify_allowed | bool(false) }}" + then: + - action: script.view_road_announcement + data: + channels: "{{ am_announcement_channels }}" + title: "{{ am_announcement_title }}" + short_announcement: "{{ am_short_announcement }}" + long_announcement: "{{ am_long_announcement }}" + payload: "" + indicator: "{{ am_matrix_indicator }}" diff --git a/packages/appliances_status_monitoring.yaml b/packages/appliances_status_monitoring.yaml new file mode 100644 index 0000000..a3ceff7 --- /dev/null +++ b/packages/appliances_status_monitoring.yaml @@ -0,0 +1,255 @@ +################################################################################ +# PACKAGE: Appliances Status Monitoring +################################################################################ +# +# Version: 1.4 +# Date: 2026-06-09 +# +# Purpose: +# Appliance status monitoring package for multiple appliances. +# +# This package defines a central appliance registry and one automation that: +# +# 1. Publishes MQTT discovery config for each appliance monitor sensor. +# 2. Samples appliance power sensors once per minute. +# 3. Calls script.appliance_monitor_process_sample. +# +# Processor package required: +# appliance_monitor_processor.yaml +# +# Announcement router required: +# view_road_announcement_router.yaml +# +# MQTT: +# MQTT is used only as retained state storage for appliance monitor sensors. +# Sensor entities are created using MQTT discovery. +# +# Adding another appliance: +# Add one entry to the am_appliances list. +# +# Version 1.4: +# Added min_cycle_notify_seconds support. +# Default is 300 seconds, so cycles shorter than 5 minutes are recorded but +# do not send finished notifications. +# Per-appliance overrides can be set in the am_appliances list. +# +# Version 1.3: +# Removed the is_number gate before calling the processor. The processor is +# now always called once per appliance per sample, and power is safely converted +# to 0 if the source sensor is unavailable or non-numeric. +# +################################################################################ + +automation: + - id: appliances_status_monitoring_process_samples + alias: Appliances Status Monitoring - Process Samples + description: Publish MQTT discovery, sample appliance power sensors, and process appliance status/cycle data. + mode: queued + max: 20 + + trigger: + - platform: homeassistant + event: start + id: ha_start + + - platform: time_pattern + minutes: "/1" + id: periodic_sample + + variables: + ########################################################################## + # MQTT LOCAL CONTROLS + ########################################################################## + + mqtt_device_name: "appliance_monitor" + mqtt_local_status_topic: !secret mqtt_status_main_topic + mqtt_discovery_prefix: "homeassistant" + + ########################################################################## + # DEFAULTS + ########################################################################## + + am_default_debug_flow: false + am_default_resolution: 2 + am_default_standby_power: 2 + am_default_operating_power_min: 12 + am_default_min_cycle_notify_seconds: 300 + + #am_default_announcement_channels: "pushover_zorruno, sony_tv_lounge, lounge_google_home_voice, led_matrix_1" + am_default_announcement_channels: "pushover_zorruno" + + # Optional appliance-specific overrides available: + # resolution: 4 + # The number of samples for the average, with one sample per minute. + # + # standby_power: 3 + # Below this value, the appliance will show "Off". + # Between this value and operating_power_min, it will show "Standby". + # + # operating_power_min: 20 + # The minimum average power needed for the appliance to show "Operating". + # + # min_cycle_notify_seconds: 600 + # Finished cycles shorter than this are recorded, but no notification + # is sent. Default is 300 seconds. + # + # announcement_channels: "pushover_zorruno" + # Announcement channels for this appliance. + # + # long_announcement: "The device has finished. Please empty it when convenient." + # Override the long announcement. + # + # min_cycle_notify_seconds: 600 + # Minimum time for an appliance to be operating before a completion announcement generated. + + ########################################################################## + # APPLIANCE REGISTRY + ########################################################################## + + am_appliances: + - appliance_id: washing_machine + appliance_name: "Washing Machine" + appliance_action: "washing2" + power_entity: "sensor.esp_laundrywashpow_load_power" + icon: "mdi:washing-machine" + short_announcement: "/s/sWashing complete" + matrix_indicator: "wash,1,15,10,6,5,1,0" + # Optional washing-machine-specific overrides + resolution: 5 + standby_power: 3 + operating_power_min: 20 + announcement_channels: "pushover_zorruno" + + - appliance_id: dryer + appliance_name: "Dryer" + appliance_action: "drying2" + power_entity: "sensor.esp_laundrydrypow_load_power" + icon: "mdi:tumble-dryer" + short_announcement: "/s/sDryer complete" + matrix_indicator: "dry,1,15,10,6,5,1,0" + # Optional dryer-specific overrides + resolution: 4 + standby_power: 3 + operating_power_min: 20 + announcement_channels: "pushover_zorruno" + + - appliance_id: main_dishwasher + appliance_name: "Main Dishwasher" + appliance_action: "dishwashing2" + power_entity: "sensor.esp_maindishwasherpower_power" + icon: "mdi:dishwasher" + short_announcement: "/sDishwasher complete" + matrix_indicator: "dish,1,15,10,6,5,1,0" + + action: + ########################################################################## + # PUBLISH / REFRESH MQTT DISCOVERY CONFIG + ########################################################################## + + - repeat: + for_each: "{{ am_appliances }}" + sequence: + - variables: + am_state_topic: >- + {{ mqtt_local_status_topic }}/{{ mqtt_device_name }}/{{ repeat.item.appliance_id }}/state + + am_discovery_topic: >- + {{ mqtt_discovery_prefix }}/sensor/{{ mqtt_device_name }}_{{ repeat.item.appliance_id }}/config + + am_unique_id: >- + {{ repeat.item.appliance_id }}_monitor + + am_object_id: >- + {{ mqtt_device_name }}_{{ repeat.item.appliance_id }}_monitor + + - action: mqtt.publish + data: + topic: "{{ am_discovery_topic }}" + retain: true + qos: 0 + payload: >- + {{ + dict( + name=repeat.item.appliance_name ~ ' Monitor', + unique_id=am_unique_id, + object_id=am_object_id, + icon=repeat.item.icon | default('mdi:power-plug'), + state_topic=am_state_topic, + value_template='{{ value_json.state | default("Off") }}', + json_attributes_topic=am_state_topic, + device=dict( + identifiers=[mqtt_device_name], + name='Appliance Monitor', + manufacturer='View Road Home Assistant', + model='Shared appliance power monitor' + ) + ) | to_json + }} + + ########################################################################## + # PROCESS SAMPLES + ########################################################################## + # + # Always process every appliance. The processor receives power_w as a safe + # float value, so unavailable/non-numeric sensors become 0 W. + # + ########################################################################## + + - repeat: + for_each: "{{ am_appliances }}" + sequence: + - variables: + am_state_topic: >- + {{ mqtt_local_status_topic }}/{{ mqtt_device_name }}/{{ repeat.item.appliance_id }}/state + + am_monitor_sensor: >- + sensor.{{ mqtt_device_name }}_{{ repeat.item.appliance_id }}_monitor + + am_title: >- + {{ repeat.item.title | default(repeat.item.appliance_name ~ ' Notification', true) }} + + am_announcement_channels: >- + {{ repeat.item.announcement_channels | default(am_default_announcement_channels, true) }} + + am_debug_flow: >- + {{ repeat.item.debug_flow | default(am_default_debug_flow, true) }} + + am_resolution: >- + {{ repeat.item.resolution | default(am_default_resolution, true) }} + + am_standby_power: >- + {{ repeat.item.standby_power | default(am_default_standby_power, true) }} + + am_operating_power_min: >- + {{ repeat.item.operating_power_min | default(am_default_operating_power_min, true) }} + + am_min_cycle_notify_seconds: >- + {{ repeat.item.min_cycle_notify_seconds | default(am_default_min_cycle_notify_seconds, true) }} + + am_power_w: >- + {{ states(repeat.item.power_entity) | float(0) }} + + am_long_announcement: >- + {{ repeat.item.long_announcement | default('', true) }} + + - action: script.appliance_monitor_process_sample + data: + appliance_id: "{{ repeat.item.appliance_id }}" + appliance_name: "{{ repeat.item.appliance_name }}" + appliance_action: "{{ repeat.item.appliance_action }}" + + power_w: "{{ am_power_w }}" + monitor_sensor: "{{ am_monitor_sensor }}" + state_topic: "{{ am_state_topic }}" + + debug_flow: "{{ am_debug_flow }}" + resolution: "{{ am_resolution }}" + standby_power: "{{ am_standby_power }}" + operating_power_min: "{{ am_operating_power_min }}" + min_cycle_notify_seconds: "{{ am_min_cycle_notify_seconds }}" + + announcement_channels: "{{ am_announcement_channels }}" + short_announcement: "{{ repeat.item.short_announcement }}" + long_announcement: "{{ am_long_announcement }}" + title: "{{ am_title }}" + matrix_indicator: "{{ repeat.item.matrix_indicator }}"