diff --git a/config/custom_templates/downstairs_hvac.jinja b/config/custom_templates/downstairs_hvac.jinja new file mode 100644 index 00000000..e95213d7 --- /dev/null +++ b/config/custom_templates/downstairs_hvac.jinja @@ -0,0 +1,65 @@ +{# Downstairs cooling policy. Durable intent lives in the climate package helpers. + Native Nest scheduling/learning must be cleared/disabled before enabling this policy. + Never infer a manual change from an unavailable state or an unconfirmed command. #} +{% macro downstairs_matches(expected) -%} + {% set mode = states('climate.downstairs') %} + {% set preset = state_attr('climate.downstairs', 'preset_mode') %} + {% set target = state_attr('climate.downstairs', 'temperature') | float(none) %} + {{ (mode == expected.get('mode') and + (mode == 'off' or (preset == expected.get('preset') and + (preset == 'eco' or target == expected.get('temperature'))))) | to_json }} +{%- endmacro %} + +{% macro downstairs_policy() -%} + {% set stamp = as_timestamp(now()) %} + {% set expected = states('input_text.downstairs_hvac_expected') | from_json({}) %} + {% set hold = states('input_text.downstairs_hvac_override') | from_json({}) %} + {% set phase = states('input_select.downstairs_hvac_period') %} + {% set held = hold.get('kind') == 'off' or hold.get('phase') == phase %} + {% set grid = states.binary_sensor.powerwall_grid_status %} + {% set entries = states.group.entry_points %} + {% set family = states.group.family %} + {% set grid_age = stamp - as_timestamp(grid.last_changed, stamp) if grid is not none else 0 %} + {% set entry_age = stamp - as_timestamp(entries.last_changed, stamp) if entries is not none else 0 %} + {% set away = is_state('group.family', 'not_home') and + stamp - as_timestamp(family.last_changed) >= 600 and is_state('input_boolean.guest_mode', 'off') %} + {% set guest = is_state('input_boolean.guest_mode', 'on') %} + {% set ns = namespace(valid=true, mode='cool', preset='none', + target=76 if guest else (77 if phase == 'sleep' else 80), + reason='guest mode' if guest else phase ~ ' schedule') %} + {% if is_state('binary_sensor.powerwall_grid_status', 'off') %} + {% set ns.valid = grid_age >= 360 %} + {% set ns.mode = 'off' %} + {% set ns.reason = 'grid outage' %} + {% elif not is_state('binary_sensor.powerwall_grid_status', 'on') or states('group.entry_points') not in ['on', 'off'] %} + {% set ns.valid = false %} + {% set ns.reason = 'waiting for safety sensors' %} + {% elif is_state('group.entry_points', 'on') %} + {% set ns.valid = entry_age >= 300 %} + {% set ns.mode = 'off' %} + {% set ns.reason = 'entry open' %} + {% elif (expected.get('reason') == 'grid outage' and grid_age < 600) or + (expected.get('reason') == 'entry open' and entry_age < 300) %} + {% set ns.valid = false %} + {% set ns.reason = 'safety recovery delay' %} + {% elif phase not in ['day', 'sleep'] or + states('group.family') not in ['home', 'not_home'] or not has_value('input_boolean.guest_mode') %} + {% set ns.valid = false %} + {% set ns.reason = 'waiting for bed period or presence' %} + {% elif held and hold.get('kind') == 'off' %} + {% set ns.mode = 'off' %} + {% set ns.reason = 'manual off' %} + {% elif away %} + {% set ns.preset = 'eco' %} + {% set ns.reason = 'family away' %} + {% elif held and hold.get('kind') == 'eco' %} + {% set ns.preset = 'eco' %} + {% set ns.reason = 'manual eco' %} + {% elif held and hold.get('kind') == 'temperature' %} + {% set ns.target = hold.get('temperature', ns.target) %} + {% set ns.reason = 'manual temperature' %} + {% endif %} + {{ dict(valid=ns.valid, mode=ns.mode, preset=ns.preset, + temperature=ns.target if ns.mode == 'cool' and ns.preset == 'none' else none, + reason=ns.reason, phase=phase, held=held) | to_json }} +{%- endmacro %} diff --git a/config/custom_templates/fresh_air.jinja b/config/custom_templates/fresh_air.jinja new file mode 100644 index 00000000..e527f741 --- /dev/null +++ b/config/custom_templates/fresh_air.jinja @@ -0,0 +1,77 @@ +{# Advisory only. Weather uses Pirate Weather and air quality uses Airly CAQI. + The dew-point comparison avoids recommending cool but moisture-laden air. #} +{% macro fresh_air_sunset() -%} + {% set sunset = as_datetime(state_attr('sun.sun', 'next_setting'), none) %} + {{ as_timestamp(sunset) if is_state('sun.sun', 'above_horizon') and sunset is not none + and as_local(sunset).date() == now().date() and sunset > now() else 0 }} +{%- endmacro %} + +{% macro fresh_air_conditions() -%} + {% set inside = state_attr('climate.downstairs', 'current_temperature') | float(none) %} + {% set target = state_attr('climate.downstairs', 'temperature') | float(none) %} + {% set humidity = state_attr('climate.downstairs', 'current_humidity') | float(none) %} + {% set outside = states('sensor.pirateweather_temperature') | float(none) %} + {% set outside_dew = state_attr('weather.pirateweather', 'dew_point') | float(none) %} + {% set airly = states.sensor.bear_stone_common_air_quality_index %} + {% set caqi = airly.state | float(none) if airly is not none else none %} + {% set rain = states('sensor.pirateweather_precip_intensity') | float(none) %} + {% set rain_chance = states('sensor.pirateweather_precip_probability') | float(none) %} + {% set gust = states('sensor.pirateweather_wind_gust') | float(none) %} + {% set alerts = states('sensor.pirateweather_alerts') | int(-1) %} + {% set values_valid = inside is not none and 40 <= inside <= 110 + and humidity is not none and 0 < humidity <= 100 + and outside is not none and -80 <= outside <= 140 and outside_dew is not none + and rain is not none and rain_chance is not none and gust is not none and alerts >= 0 + and has_value('climate.downstairs') and has_value('weather.pirateweather') + and state_attr('climate.downstairs', 'temperature_unit') == '\u00b0F' + and state_attr('weather.pirateweather', 'temperature_unit') == '\u00b0F' %} + {% set inside_dew = none %} + {% if values_valid %} + {% set celsius = (inside - 32) * 5 / 9 %} + {% set gamma = log(humidity / 100) + 17.625 * celsius / (243.04 + celsius) %} + {% set inside_dew = (243.04 * gamma / (17.625 - gamma)) * 9 / 5 + 32 %} + {% endif %} + {% set weather = states.weather.pirateweather %} + {% set climate = states.climate.downstairs %} + {% set fresh = values_valid and weather is not none and climate is not none + and 0 <= as_timestamp(now()) - as_timestamp(weather.last_reported) <= 3600 + and 0 <= as_timestamp(now()) - as_timestamp(climate.last_reported) <= 7200 %} + {% set daytime = fresh_air_sunset() | float(0) > 0 %} + {% set home = is_state('person.carlo', 'home') or is_state('person.stacey', 'home') %} + {% set awake = is_state('binary_sensor.sleepnumber_carlo_carlo_is_in_bed', 'off') + and is_state('binary_sensor.sleepnumber_carlo_stacey_is_in_bed', 'off') %} + {% set slider_closed = is_state('binary_sensor.mcu2_gpio12', 'off') %} + {% set entries = expand('group.entry_points') | list %} + {% set closed = is_state('group.entry_points', 'off') and entries | count > 0 + and entries | rejectattr('state', 'eq', 'off') | list | count == 0 %} + {% set guests_off = is_state('input_boolean.guest_mode', 'off') %} + {% set temperatures = fresh and is_number(target) and outside <= 76 + and inside - outside >= 2 and target - outside >= 1 %} + {% set dry = fresh and outside_dew <= 60 and outside_dew <= inside_dew - 2 %} + {% set weather_ok = fresh and 0 <= rain == 0 and 0 <= rain_chance <= 20 and 0 <= gust <= 20 + and alerts == 0 and states('weather.pirateweather') in ['sunny', 'partlycloudy', 'cloudy'] %} + {% set air_ok = airly is not none and caqi is not none and 0 <= caqi <= 25 + and airly.attributes.get('unit_of_measurement') == 'CAQI' + and 0 <= as_timestamp(now()) - as_timestamp(airly.last_reported) <= 7200 %} + {% set checks = dict(data_fresh=fresh, daytime=daytime, home=home, awake=awake, + slider_closed=slider_closed, windows_closed=closed, guest_mode_off=guests_off, temperatures=temperatures, + drier_outside=dry, weather=weather_ok, air_quality=air_ok) %} + {% set ns = namespace(blocked=[]) %} + {% for name, passes in checks.items() %} + {% if not passes %}{% set ns.blocked = ns.blocked + [name] %}{% endif %} + {% endfor %} + {{ dict(eligible=ns.blocked | count == 0, blocked=ns.blocked, + inside_f=inside, target_f=target if is_number(target) else none, indoor_humidity=humidity, outside_f=outside, + indoor_dew_point_f=inside_dew | round(1) if inside_dew is not none else none, + outdoor_dew_point_f=outside_dew, airly_caqi=caqi) | to_json }} +{%- endmacro %} + +{% macro fresh_air_message(reading=none) -%} + {% set reading = reading if reading is not none else state_attr('binary_sensor.fresh_air_window_opportunity', 'assessment') %} + {% if reading is mapping and reading.get('inside_f') is number and reading.get('outside_f') is number %} + {{ reading.inside_f | round(0) | int }}F inside, {{ reading.outside_f | round(0) | int }}F outside. + Consider opening the main sliding door for cooler, drier air. + {% else %} + Fresh-air opportunity details are updating. + {% endif %} +{%- endmacro %} diff --git a/config/dashboards/infrastructure/partials/hvac_sections.yaml b/config/dashboards/infrastructure/partials/hvac_sections.yaml index c2fa8ee3..ba623e60 100644 --- a/config/dashboards/infrastructure/partials/hvac_sections.yaml +++ b/config/dashboards/infrastructure/partials/hvac_sections.yaml @@ -3,12 +3,14 @@ # For more info visit https://www.vcloudinfo.com/click-here # Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig # ------------------------------------------------------------------- -# Systems HVAC Partial - Maintenance overview and details -# Production HVAC maintenance, completion history, and reset access. +# Systems HVAC Partial - Maintenance and downstairs cooling plan +# Live schedule context, cooling rules, maintenance history, and reset access. # ------------------------------------------------------------------- # Related Issue: 1817 # Notes: Downstairs filter maintenance is due at 550 runtime hours; upstairs remains 450 hours. # Notes: Reset actions reuse the existing HVAC maintenance scripts; no staging helpers are introduced. +# Notes: Read-only schedule cards document the climate package; live context imports its policy macro. +# Notes: Daily cooling bars use changes in the cumulative runtime statistic, integrated in hours; today's bar is partial. ###################################################################### - type: grid @@ -40,6 +42,14 @@ action: navigate navigation_path: /dashboard-infrastructure/home + # Maintenance remains the primary HVAC dashboard content. + - type: custom:button-card + template: bearstone_infra_panel_header_icon + name: Maintenance + icon: mdi:tools + view_layout: + grid-column: 1 / -1 + - type: custom:button-card template: bearstone_infra_status_kpi entity: sensor.downstairs_ac_runtime_since_last_filter_change @@ -260,3 +270,154 @@ tap_action: action: more-info entity: script.reset_hvac_condenser_lines_cleaned + +# Informational cooling plan follows maintenance and completion details. +- type: grid + column_span: 4 + columns: 1 + square: false + cards: + - type: custom:layout-card + grid_options: + columns: full + layout_type: custom:grid-layout + layout: + grid-template-columns: repeat(4, minmax(0, 1fr)) + grid-template-areas: | + "heading heading heading heading" + "current day bedtime guests" + "runtime runtime plan plan" + grid-auto-flow: row + grid-auto-rows: min-content + grid-gap: 12px + margin: 0 + mediaquery: + "(max-width: 900px)": + grid-template-columns: repeat(2, minmax(0, 1fr)) + grid-template-areas: | + "heading heading" + "current day" + "runtime runtime" + "bedtime guests" + "plan plan" + cards: + # Downstairs schedule: short values use existing shared KPI templates. + - type: custom:button-card + template: bearstone_infra_panel_header_icon + name: Downstairs Cooling Plan + icon: mdi:calendar-clock + view_layout: + grid-area: heading + + - type: custom:button-card + template: bearstone_infra_kpi + entity: climate.downstairs + name: Current Target + icon: mdi:thermostat + view_layout: + grid-area: current + state_display: > + [[[ + if (!entity || ['unknown', 'unavailable'].includes(entity.state)) return 'UNAVAILABLE'; + if (entity.state === 'off') return 'OFF'; + if (entity.attributes.preset_mode === 'eco') return 'ECO'; + const target = entity.attributes.temperature; + return typeof target === 'number' ? `${target}\u00b0F` : 'NO TARGET'; + ]]] + + - type: custom:button-card + template: bearstone_infra_kpi + name: 8 AM Every Day + icon: mdi:weather-sunny + view_layout: + grid-area: day + state_display: "80\u00b0F" + tap_action: + action: none + + - type: custom:button-card + template: bearstone_infra_kpi + name: Bedtime - 6 Min + icon: mdi:bed + view_layout: + grid-area: bedtime + state_display: "77\u00b0F" + tap_action: + action: none + + - type: custom:button-card + template: bearstone_infra_kpi + entity: input_boolean.guest_mode + name: Guest Mode + icon: mdi:account-group + view_layout: + grid-area: guests + state_display: > + [[[ return entity?.state === 'on' ? '76\u00b0F - ACTIVE' : '76\u00b0F WHEN ON'; ]]] + + # Fallback: Core statistics-graph provides calendar-day changes from the + # existing sum statistic; mini-graph-card uses raw history instead. + # Runtime has no stored unit label, but its source integrates 0/1 with unit_time: h. + - type: statistics-graph + title: Daily Cooling Hours - 30 Days + view_layout: + grid-area: runtime + chart_type: bar + days_to_show: 30 + period: day + stat_types: + - change + min_y_axis: 0 + entities: + - entity: sensor.downstairs_ac_runtime_raw + name: Cooling hours + + # Fallback: Markdown wraps policy explanations and renders the existing + # controller macro; KPI/list templates cannot safely fit this much text. + - type: markdown + view_layout: + grid-area: plan + content: >- + {% from 'downstairs_hvac.jinja' import downstairs_policy %} + {% set plan = downstairs_policy() | from_json %} + {% set labels = {'day schedule': 'Daytime schedule', 'sleep schedule': 'Bedtime schedule', + 'guest mode': 'Guest Mode', 'family away': 'Away Eco', 'manual temperature': 'Manual temperature hold', + 'manual eco': 'Manual Eco hold', 'manual off': 'Manually switched off', + 'grid outage': 'Grid outage protection', 'entry open': 'Open-door/window protection'} %} + {% set climate_ok = has_value('climate.downstairs') %} + {% set action = state_attr('climate.downstairs', 'hvac_action') %} + **Current plan:** {{ labels.get(plan.reason, plan.reason | capitalize) if climate_ok else 'Thermostat unavailable' }}. + {% if not plan.valid %}Waiting for reliable sensors or the protection delay.{% endif %} + {% if climate_ok and action in ['cooling', 'idle', 'off'] %} + {{ {'cooling': 'Cooling now.', 'idle': 'Cooling is idle.', 'off': 'System is off.'}[action] }} + {% endif %} + + + **Next routine change:** + {% if plan.phase == 'day' %} + 77°F after both beds are occupied for 6 minutes, from sunset until 8:00 a.m. + {% elif plan.phase == 'sleep' %} + 80°F at 8:00 a.m., even if you are still in bed. + {% else %} + Waiting for the day/bedtime period. + {% endif %} + Guest Mode, manual holds, Eco and protections can take priority. + + + - **Daily rhythm:** 80°F from 8:00 a.m. until bedtime; 77°F overnight. + Getting out of bed does not change the target. Cooling only; no heating. + + - **Guests and away:** Guest Mode selects 76°F and prevents away Eco. + With guests off, 10 minutes with the family away selects Eco. + Returning home restores the current period or a valid manual hold. + + - **Your dial adjustments:** Temperature and Eco holds last until bedtime, 8:00 a.m., + a Guest Mode change, or Resume Schedule. Manual Off stays off until you turn cooling + back on or resume. Guest changes clear temperature/Eco holds; you can still adjust during a party. + + - **Openings and power:** Open doors/windows pause cooling after 5 minutes; + closing them allows recovery after 5 minutes. Grid outages pause after 6 minutes, + with recovery after 10 minutes of stable grid power. + + Cooling history shows hours per day; today is still in progress. + Home Assistant owns this downstairs plan. Upstairs keeps its existing controls. diff --git a/config/dashboards/infrastructure/views/01_hvac.yaml b/config/dashboards/infrastructure/views/01_hvac.yaml index 566e8675..119c65cd 100644 --- a/config/dashboards/infrastructure/views/01_hvac.yaml +++ b/config/dashboards/infrastructure/views/01_hvac.yaml @@ -3,8 +3,8 @@ # For more info visit https://www.vcloudinfo.com/click-here # Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig # ------------------------------------------------------------------- -# Systems HVAC View - Filter and condenser maintenance -# Runtime, completion dates, and safe reset-script access. +# Systems HVAC View - Cooling schedule and equipment maintenance +# Downstairs plan, live context, runtime, completion dates, and reset access. # ------------------------------------------------------------------- # Related Issue: 1817 # Notes: Production view at `/dashboard-infrastructure/hvac`; promoted after live staging review. @@ -13,7 +13,7 @@ title: HVAC path: hvac type: sections -icon: mdi:hvac +icon: mdi:weather-windy theme: default max_columns: 4 dense_section_placement: true diff --git a/config/packages/README.md b/config/packages/README.md index 9697509d..73af3821 100755 --- a/config/packages/README.md +++ b/config/packages/README.md @@ -100,9 +100,10 @@ The kiosk package also exposes `sensor.kiosk_recent_phone_notifications`: a rest ![Garadget Automation](../www/custom_ui/floorplan/images/branding/Garadget%20Automation.png) ### Nest climate control -- Logic lives in [![YAML source: climate](https://img.shields.io/static/v1?label=YAML&message=climate&color=lightgrey&logo=github&logoColor=181717)](climate.yaml) and centralizes downstairs/upstairs Nest schedules, grid-aware guardrails, humidity pulses, and presence/weather-based targets. -- The HVAC maintenance walkthrough covers the runtime counters, filter reset scripts, dashboard cards, and 360-day condenser-line reminder: [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/nScl2JxYSB8). -- Shared script keeps daytime targets consistent: away -> eco, home + outside >90F -> 78F, home + outside <=90F -> 80F. Daytime weather crossings must remain above or below 90F for one hour before changing the target. Downstairs bedtime cooling stays dynamic: outside >80F -> 77F, 76-79F -> 79F, below 76F -> leave the current target alone. Grid-down conditions pause non-essential cool-downs. +- [Climate package](climate.yaml) manages the downstairs cooling schedule, presence, guest mode, and runtime-based HVAC maintenance. The upstairs thermostat retains its existing controls. +- Downstairs normally targets 80F from 8:00 a.m. and 77F after both beds have been occupied for six minutes overnight. Guest mode targets 76F; a sustained absence selects Eco. Manual adjustments are respected, including manual Off. The [controller](../script/downstairs_hvac.yaml) and [policy macros](../custom_templates/downstairs_hvac.jinja) contain the details and safety checks. +- Fresh-air suggestions consider the indoor and outdoor conditions, Airly CAQI, weather, presence, and door contacts. They can appear while cooling is idle or running, with at most one phone notice per day. See the [eligibility rules](../custom_templates/fresh_air.jinja), [notice lifecycle](../script/fresh_air_notice.yaml), and [HVAC dashboard](../dashboards/infrastructure/partials/hvac_sections.yaml). Adapt entities and thresholds to your own home. +- Filter reminders use runtime hours and a maximum age, with separate thresholds for each system. The [maintenance walkthrough](https://youtu.be/nScl2JxYSB8) covers counters, reset scripts and dashboard cards. ![Nest Climate Control](../www/custom_ui/floorplan/images/branding/Nest_Climate_Control.png) ### Dreame vacuum automations diff --git a/config/packages/climate.yaml b/config/packages/climate.yaml index 8f06ef3f..c9f311ce 100644 --- a/config/packages/climate.yaml +++ b/config/packages/climate.yaml @@ -10,6 +10,17 @@ # Notes: Filter due alerts use 550h downstairs/450h upstairs runtime thresholds, a 9-month hard limit, 3-day snooze, and Filter Changed push actions. # Notes: Condenser line cleanout uses a single 360-day reminder for both HVAC systems. # Notes: Native NFC tag triggers call the per-unit reset scripts; a short single-run guard suppresses duplicate phone scans. +# Notes: Downstairs: both beds occupied 6m overnight -> 77F; 08:00 daily -> 80F. +# Guest Mode -> 76F; toggling it resets temperature/Eco holds, never manual Off. +# Getting out of bed never changes the target. A durable wake deadline catches +# missed mornings after downtime; only a fresh off-to-on bed edge starts sleep. +# Notes: Nest learning/stored schedules are disabled. Manual temperature/Eco +# holds last until bedtime or 08:00; manual Off lasts until resumed. +# Notes: Restoring helpers retain the bed period, manual intent and verified +# command fingerprint because Nest exposes none of that ownership metadata. +# Notes: Fresh-air opportunities can notify while downstairs cooling is idle or running, before sunset. +# One daily passive parents push uses a stable tag; the notice clears when +# conditions stop qualifying. Helpers preserve daily allowance and active notice. # Notes: Filter history helpers restore across restarts; reset scripts seed the prior input_datetime when history is empty. # Notes: The snooze notification pattern is also covered at https://youtu.be/y47KSflS1aw. # Video: https://youtu.be/nScl2JxYSB8 @@ -53,6 +64,16 @@ # --------------------------------------------------------------------------- template: - binary_sensor: + - name: Fresh Air Window Opportunity + unique_id: fresh_air_window_opportunity + icon: mdi:window-open-variant + state: >- + {% from 'fresh_air.jinja' import fresh_air_conditions %} + {{ (fresh_air_conditions() | from_json).eligible }} + attributes: + assessment: >- + {% from 'fresh_air.jinja' import fresh_air_conditions %} + {{ fresh_air_conditions() | from_json }} - name: "Downstairs AC is Cooling" unique_id: downstairs_ac_cooling state: > @@ -235,6 +256,17 @@ template: icon: mdi:counter state: "{{ states('counter.upstairs_filter_change_count') | int(0) }}" +input_boolean: + fresh_air_notice_active: + name: Fresh Air Notice Active + icon: mdi:window-open-variant + +input_select: + downstairs_hvac_period: + name: Downstairs HVAC Bed Period + options: [day, sleep] + icon: mdi:bed-clock + counter: downstairs_filter_change_count: name: Downstairs Filter Change Count @@ -249,6 +281,14 @@ counter: icon: mdi:counter input_datetime: + fresh_air_last_notified: + name: Fresh Air Last Notified + has_date: true + has_time: true + downstairs_hvac_next_wake: + name: Downstairs HVAC Next Morning Reset + has_date: true + has_time: true downstairs_last_filter_change: name: Downstairs Last Filter Change has_date: true @@ -277,6 +317,16 @@ input_datetime: initial: "2026-06-28 00:00:00" input_text: + downstairs_hvac_override: + name: Downstairs HVAC Manual Intent + max: 255 + icon: mdi:hand-back-right + + downstairs_hvac_expected: + name: Downstairs HVAC Verified Command + max: 255 + icon: mdi:thermostat + downstairs_filter_change_history: name: Downstairs Filter Change History max: 255 @@ -420,6 +470,24 @@ script: ### There are also some automations in the POWERWALL.yaml package when the grid is down. ############################################################################## automation: + - alias: Fresh Air Reminder Lifecycle + id: fresh_air_reminder_lifecycle + mode: queued + max_exceeded: silent + trigger: + - trigger: climate.started_cooling + target: + entity_id: climate.downstairs + - trigger: state + entity_id: binary_sensor.fresh_air_window_opportunity + - trigger: time_pattern + minutes: '/1' + - trigger: homeassistant + event: start + - trigger: sun.sunset + action: + - action: script.reconcile_fresh_air_notice + - alias: HVAC Filter NFC Stickers id: hvac_filter_nfc_stickers description: Log the unit whose bound HVAC filter NFC sticker was scanned @@ -700,48 +768,123 @@ automation: - alias: Set Downstairs Bedtime Target When Both in Bed id: set_downstairs_nest_77_both_in_bed trigger: - - platform: state - entity_id: group.bed + # A known off-to-on edge excludes startup/unavailable sensor recovery. + - trigger: state + entity_id: + - binary_sensor.sleepnumber_carlo_carlo_is_in_bed + - binary_sensor.sleepnumber_carlo_stacey_is_in_bed + from: 'off' to: 'on' + for: &downstairs_bedtime_confirmation "00:06:00" condition: - - condition: and + # After the morning reset, returning to bed during the day is not bedtime. + - condition: or conditions: - - condition: state - entity_id: binary_sensor.sleepnumber_carlo_carlo_is_in_bed - state: 'on' - - condition: state - entity_id: binary_sensor.sleepnumber_carlo_stacey_is_in_bed - state: 'on' - - condition: state - entity_id: binary_sensor.powerwall_grid_status - state: 'on' + - condition: sun + after: sunset + - condition: time + before: "08:00:00" - condition: state - entity_id: sun.sun - state: 'below_horizon' + entity_id: + - binary_sensor.sleepnumber_carlo_carlo_is_in_bed + - binary_sensor.sleepnumber_carlo_stacey_is_in_bed + state: 'on' + for: *downstairs_bedtime_confirmation action: - - choose: - - conditions: - - condition: numeric_state - entity_id: sensor.pirateweather_temperature - above: 80 - sequence: - - service: climate.set_temperature - data: - entity_id: climate.downstairs - temperature: 77 - - conditions: - - condition: numeric_state - entity_id: sensor.pirateweather_temperature - above: 75 - below: 80 - sequence: - - service: climate.set_temperature - data: - entity_id: climate.downstairs - temperature: 79 + - action: script.set_downstairs_hvac_period + data: + period: sleep + + - alias: Downstairs HVAC Morning Reset + id: downstairs_hvac_morning_reset + trigger: + - trigger: time + at: "08:00:00" + action: + - action: script.set_downstairs_hvac_period + data: + period: day + reset_hold: true + + - alias: Downstairs HVAC Observe Manual Adjustments + id: downstairs_hvac_observe_manual_adjustments + mode: restart + trigger: + - trigger: state + entity_id: climate.downstairs + attribute: temperature + - trigger: state + entity_id: climate.downstairs + attribute: preset_mode + - trigger: state + entity_id: climate.downstairs + to: null + condition: + - condition: template + value_template: >- + {{ trigger.from_state is not none and trigger.to_state is not none and + trigger.from_state.state not in ['unknown', 'unavailable'] and + trigger.to_state.state not in ['unknown', 'unavailable'] }} + action: + - delay: "00:00:05" + - delay: + seconds: >- + {% set expected = states('input_text.downstairs_hvac_expected') | from_json({}) %} + {{ [0, [120, expected.get('settle_until', 0) - as_timestamp(now()) + 1] | min] | max | int }} + - action: script.reconcile_downstairs_hvac + + - alias: Downstairs HVAC Reconcile Bed Presence And Recovery + id: downstairs_hvac_reconcile_bed_presence_recovery + mode: queued + max: 5 + max_exceeded: silent + trigger: + - trigger: homeassistant + event: start + id: startup + - trigger: time_pattern + minutes: "/5" + - trigger: state + entity_id: group.family + to: not_home + for: "00:10:00" + - trigger: state + entity_id: input_boolean.guest_mode + from: ['on', 'off'] + to: ['on', 'off'] + id: guest + - trigger: state + entity_id: climate.downstairs + from: unavailable + id: recovery + action: + - if: + - condition: trigger + id: [startup, recovery] + then: + - delay: "00:01:00" + - if: + - condition: template + value_template: >- + {{ as_timestamp(states('input_datetime.downstairs_hvac_next_wake'), 0) <= as_timestamp(now()) }} + then: + - action: script.set_downstairs_hvac_period + data: + period: day + reset_hold: true + - if: + - condition: trigger + id: guest + then: + - action: script.set_downstairs_hvac_period + data: + period: "{{ states('input_select.downstairs_hvac_period') }}" + reset_hold: true + - action: script.reconcile_downstairs_hvac - alias: 'Set Downstairs Thermostat Daytime Target' id: set_downstairs_thermostat_daytime_target + description: Preserve legacy upstairs refreshes; downstairs re-evaluates its bed-led policy without clock/weather target changes. mode: single trigger: - platform: state @@ -856,31 +999,6 @@ automation: entity_id: group.entry_points state: 'off' action: - - choose: - - conditions: - - condition: numeric_state - entity_id: sensor.pirateweather_temperature - below: 75 - sequence: - - service: script.send_to_logbook - data: - topic: "CLIMATE" - message: "Skipping downstairs cool mode (outside temp <75F)." - default: - - service: climate.set_preset_mode - data: - entity_id: climate.downstairs - preset_mode: 'none' - - service: climate.set_hvac_mode - data: - entity_id: climate.downstairs - hvac_mode: cool - - service: climate.set_temperature - data: - entity_id: climate.downstairs - temperature: 77 - - delay: "00:20:00" - - service: script.reconcile_hvac_state - data: - reason: humidity cycle complete - - delay: "02:00:00" # stops it from being triggered again too soon. + - action: script.reconcile_downstairs_hvac + data: + reason: humidity review within bed and manual comfort limits diff --git a/config/packages/kiosk_tablet.yaml b/config/packages/kiosk_tablet.yaml index d532c2cb..69768dce 100644 --- a/config/packages/kiosk_tablet.yaml +++ b/config/packages/kiosk_tablet.yaml @@ -509,6 +509,10 @@ template: - trigger: homeassistant event: start id: startup + - trigger: state + entity_id: input_boolean.fresh_air_notice_active + to: 'off' + id: fresh_air_clear conditions: - condition: template value_template: >- @@ -517,6 +521,7 @@ template: actions: - variables: notification_items: >- + {% from 'fresh_air.jinja' import fresh_air_sunset %} {% set current = as_timestamp(now()) %} {% set incoming = trigger.event.data.service_data if trigger.id == 'notification' else {} %} {% set message = incoming.get('message', '') | string | striptags | regex_replace('\\s+', ' ') | trim %} @@ -530,20 +535,25 @@ template: {% set ns = namespace(items=[]) %} {% for item in state_attr('sensor.kiosk_recent_phone_notifications', 'items') | default([], true) %} {% if item is mapping and item.get('expires_at', 0) | float(0) > current + and (item.get('key') != 'tag:fresh_air_opportunity' or is_state('input_boolean.fresh_air_notice_active', 'on')) and not ((publish or (clear and tag)) and item.get('key') == key) %} {% set ns.items = ns.items + [item] %} {% endif %} {% endfor %} - {% if publish %} + {% set expires_at = fresh_air_sunset() | float(0) if tag == 'fresh_air_opportunity' else current + 300 %} + {% if publish and expires_at > current %} {% set icon = data.get('notification_icon', '') | default('', true) | string %} {% set color = data.get('color', '') | default('', true) | string %} {% set ns.items = ns.items + [dict(key=key, title=title or 'Household notification', - message=message | truncate(320, true), sent_at=current, expires_at=current + 300, + message=message | truncate(320, true), sent_at=current, + expires_at=expires_at, sent_time=now().strftime('%-I:%M %p'), icon=icon if icon.startswith('mdi:') else 'mdi:message-text-outline', color=color if color | regex_match('^#[0-9a-fA-F]{6}$') else '#1565c0')] %} {% endif %} - {{ ns.items[-12:] }} + {% set fresh_air = ns.items | selectattr('key', 'eq', 'tag:fresh_air_opportunity') | list %} + {% set others = ns.items | rejectattr('key', 'eq', 'tag:fresh_air_opportunity') | list %} + {{ fresh_air + others[-11:] if fresh_air else others[-12:] }} sensor: - name: "Kiosk Recent Phone Notifications" unique_id: kiosk_recent_phone_notifications @@ -665,6 +675,8 @@ template: {% elif battery < 50 and is_state('switch.spaceship_charge', 'off') and is_state('person.carlo', 'home') and is_state('person.stacey', 'home') %} PLUG IN THE TESLA + {% elif is_state('input_boolean.fresh_air_notice_active', 'on') %} + GOOD WEATHER FOR FRESH AIR {% elif record_slot > 0 %} {{ records[record_slot - 1].person | upper }}'S PERSONAL BEST {% elif ns.name %} @@ -724,6 +736,9 @@ template: {% elif battery < 50 and is_state('switch.spaceship_charge', 'off') and is_state('person.carlo', 'home') and is_state('person.stacey', 'home') %} Model Y battery is {{ battery | round(0) }}%. + {% elif is_state('input_boolean.fresh_air_notice_active', 'on') %} + {% from 'fresh_air.jinja' import fresh_air_message %} + {{ fresh_air_message() | trim }} {% elif record_slot > 0 %} {% set record = records[record_slot - 1] %} {{ record.label }}: {{ record.displayValue }}{% if record.activityType %} diff --git a/config/script/downstairs_hvac.yaml b/config/script/downstairs_hvac.yaml new file mode 100644 index 00000000..e39ff6d6 --- /dev/null +++ b/config/script/downstairs_hvac.yaml @@ -0,0 +1,225 @@ +###################################################################### +# @CCOSTAN - Follow Me on X +# For more info visit https://www.vcloudinfo.com/click-here +# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig +# ------------------------------------------------------------------- +# Downstairs HVAC - Bed-led cooling, durable overrides, verified commands +# One writer owns downstairs; upstairs remains in the shared legacy policy. +# Notes: Nest learning and the stored schedule must both be disabled/cleared. +# Notes: Physical changes have no reliable user context; compare confirmed +# command state after a settling window, never temperature/humidity telemetry. +###################################################################### + +capture_downstairs_hvac_override: + alias: Capture Downstairs HVAC Override + mode: queued + sequence: + - variables: + expected: "{{ states('input_text.downstairs_hvac_expected') | from_json({}) }}" + current_mode: "{{ states('climate.downstairs') }}" + current_preset: "{{ state_attr('climate.downstairs', 'preset_mode') }}" + current_target: "{{ state_attr('climate.downstairs', 'temperature') | float(none) }}" + phase: "{{ states('input_select.downstairs_hvac_period') }}" + - condition: template + value_template: >- + {% from 'downstairs_hvac.jinja' import downstairs_matches %} + {{ expected.get('confirmed', false) and + as_timestamp(now()) >= expected.get('settle_until', 0) and + not (downstairs_matches(expected) | from_json) and + (current_mode == 'off' or (current_mode == 'cool' and + (current_preset == 'eco' or current_target is not none))) }} + - variables: + override: >- + {{ dict(kind='off' if current_mode == 'off' else + ('eco' if current_preset == 'eco' else 'temperature'), + temperature=current_target, phase=phase) }} + - action: input_text.set_value + target: + entity_id: input_text.downstairs_hvac_override + data: + value: "{{ override | to_json }}" + - action: script.send_to_logbook + data: + topic: CLIMATE + message: >- + Downstairs {{ override.kind }} override captured + (target={{ current_target }}F; {{ 'until resumed' if override.kind == 'off' else 'until bedtime, 08:00, or a Guest Mode change' }}). + +reconcile_downstairs_hvac: + alias: Reconcile Downstairs HVAC + description: Bed-led cool-only policy; preserve manual intent and verify every command. + mode: queued + max: 10 + max_exceeded: silent + sequence: + - condition: state + entity_id: climate.downstairs + state: ['cool', 'off', 'heat', 'heat_cool'] + - if: "{{ capture_override | default(true) }}" + then: + - action: script.capture_downstairs_hvac_override + - variables: + desired: >- + {% from 'downstairs_hvac.jinja' import downstairs_policy %} + {{ downstairs_policy() | from_json }} + expected: "{{ states('input_text.downstairs_hvac_expected') | from_json({}) }}" + - condition: template + value_template: "{{ desired.valid }}" + - variables: + matched: >- + {% from 'downstairs_hvac.jinja' import downstairs_matches %} + {{ downstairs_matches(desired) | from_json }} + command: >- + {{ dict(mode=desired.mode, preset=desired.preset, + temperature=desired.temperature, reason=desired.reason) }} + - if: + - condition: template + value_template: "{{ matched }}" + then: + - if: + - condition: template + value_template: >- + {{ not expected.get('confirmed', false) or + expected.get('mode') != desired.mode or + expected.get('preset') != desired.preset or + expected.get('temperature') != desired.temperature or + expected.get('reason') != desired.reason }} + then: + - action: input_text.set_value + target: + entity_id: input_text.downstairs_hvac_expected + data: + value: "{{ dict(command, confirmed=true, settle_until=0) | to_json }}" + - stop: Downstairs already matches the current policy. + - condition: template + value_template: >- + {{ as_timestamp(now()) >= expected.get('settle_until', 0) + or desired.mode == 'off' }} + - action: input_text.set_value + target: + entity_id: input_text.downstairs_hvac_expected + data: + value: "{{ dict(command, confirmed=false, settle_until=as_timestamp(now()) + 90) | to_json }}" + - choose: + - conditions: "{{ desired.mode == 'off' }}" + sequence: + - action: climate.turn_off + target: + entity_id: climate.downstairs + default: + - if: + - condition: template + value_template: "{{ not is_state('climate.downstairs', 'cool') }}" + then: + - action: climate.set_hvac_mode + target: + entity_id: climate.downstairs + data: + hvac_mode: cool + - wait_template: "{{ is_state('climate.downstairs', 'cool') }}" + timeout: "00:00:20" + continue_on_timeout: false + - if: + - condition: template + value_template: "{{ state_attr('climate.downstairs', 'preset_mode') != desired.preset }}" + then: + - action: climate.set_preset_mode + target: + entity_id: climate.downstairs + data: + preset_mode: "{{ desired.preset }}" + - if: + - condition: template + value_template: "{{ desired.preset == 'none' }}" + then: + - wait_template: "{{ state_attr('climate.downstairs', 'preset_mode') == 'none' }}" + timeout: "00:00:20" + continue_on_timeout: false + - if: + - condition: template + value_template: "{{ state_attr('climate.downstairs', 'temperature') | float(none) != desired.temperature }}" + then: + - action: climate.set_temperature + target: + entity_id: climate.downstairs + data: + temperature: "{{ desired.temperature }}" + - wait_template: >- + {% from 'downstairs_hvac.jinja' import downstairs_matches %} + {{ downstairs_matches(desired) | from_json }} + timeout: "00:00:30" + continue_on_timeout: true + - variables: + verified: >- + {% from 'downstairs_hvac.jinja' import downstairs_matches %} + {{ downstairs_matches(desired) | from_json }} + - action: input_text.set_value + target: + entity_id: input_text.downstairs_hvac_expected + data: + value: "{{ dict(command, confirmed=verified, settle_until=as_timestamp(now()) + (15 if verified else 120)) | to_json }}" + - action: script.send_to_logbook + data: + topic: CLIMATE + message: >- + Downstairs {{ 'verified' if verified else 'NOT CONFIRMED; will retry after settling' }}: + {{ desired.mode }}/{{ desired.preset }}, target={{ desired.temperature }}F + ({{ desired.reason }}). + +resume_downstairs_hvac_schedule: + alias: Resume Downstairs HVAC Schedule + description: Clear the manual hold and apply the current bed/presence policy. + mode: single + sequence: + - action: input_text.set_value + target: + entity_id: + - input_text.downstairs_hvac_override + - input_text.downstairs_hvac_expected + data: + value: "{}" + - action: script.reconcile_downstairs_hvac + +set_downstairs_hvac_period: + alias: Set Downstairs HVAC Bed Period + mode: queued + fields: + period: + description: Day at 08:00, or sleep after both sides are occupied. + selector: + select: + options: [day, sleep] + reset_hold: + description: Reset at the morning or guest boundary even if the bed period is unchanged. + selector: + boolean: + sequence: + - condition: template + value_template: "{{ period in ['day', 'sleep'] and (states('input_select.downstairs_hvac_period') != period or reset_hold | default(false)) }}" + - action: script.capture_downstairs_hvac_override + - variables: + hold: "{{ states('input_text.downstairs_hvac_override') | from_json({}) }}" + - if: + - condition: template + value_template: "{{ hold.get('kind') != 'off' }}" + then: + - action: input_text.set_value + target: + entity_id: input_text.downstairs_hvac_override + data: + value: "{}" + - action: input_select.select_option + target: + entity_id: input_select.downstairs_hvac_period + data: + option: "{{ period }}" + - action: input_datetime.set_datetime + target: + entity_id: input_datetime.downstairs_hvac_next_wake + data: + datetime: >- + {% set morning = today_at('08:00') %} + {{ (morning if now() < morning else morning + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S') }} + - action: script.reconcile_downstairs_hvac + data: + capture_override: false diff --git a/config/script/fresh_air_notice.yaml b/config/script/fresh_air_notice.yaml new file mode 100644 index 00000000..4922ed78 --- /dev/null +++ b/config/script/fresh_air_notice.yaml @@ -0,0 +1,99 @@ +###################################################################### +# @CCOSTAN - Follow Me on X +# For more info visit https://www.vcloudinfo.com/click-here +# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig +# ------------------------------------------------------------------- +# Fresh Air Notice - One daily advisory across phones and household screens +# Never changes HVAC, windows, or security state. +# ------------------------------------------------------------------- +# Notes: One restoring timestamp reserves the daily send before fan-out. +# Qualifying conditions can issue a notice while downstairs is idle or cooling. +# The active helper tracks the notice lifecycle, not physical air quality. +# A stable tag replaces old pushes; clear commands also remove kiosk copies. +###################################################################### + +clear_fresh_air_notice: + alias: Clear Fresh Air Notice + mode: queued + sequence: + - action: input_boolean.turn_off + target: + entity_id: input_boolean.fresh_air_notice_active + - action: notify.ios_parents + continue_on_error: true + data: + message: clear_notification + data: + tag: fresh_air_opportunity + group: fresh_air + - action: persistent_notification.dismiss + data: + notification_id: fresh_air_opportunity + +reconcile_fresh_air_notice: + alias: Reconcile Fresh Air Notice + mode: single + max_exceeded: silent + sequence: + - variables: + assessment: >- + {% from 'fresh_air.jinja' import fresh_air_conditions %} + {{ fresh_air_conditions() | from_json }} + active: "{{ is_state('input_boolean.fresh_air_notice_active', 'on') }}" + sent_today: "{{ states('input_datetime.fresh_air_last_notified')[:10] == now().strftime('%Y-%m-%d') }}" + - choose: + - conditions: "{{ active and (not assessment.get('eligible', false) or not sent_today) }}" + sequence: + - action: script.clear_fresh_air_notice + - action: script.send_to_logbook + data: + topic: CLIMATE + message: "Fresh-air reminder cleared ({{ assessment.get('blocked', ['data unavailable']) | join(', ') if sent_today else 'previous day' }})." + - conditions: + - condition: state + entity_id: input_boolean.text_notifications + state: 'on' + - condition: template + value_template: >- + {{ assessment.eligible and not sent_today and is_state('climate.downstairs', 'cool') + and state_attr('climate.downstairs', 'hvac_action') in ['idle', 'cooling'] }} + sequence: + # Reserve first: a restart or failed delivery must not duplicate today's push. + - action: input_datetime.set_datetime + target: + entity_id: input_datetime.fresh_air_last_notified + data: + timestamp: "{{ as_timestamp(now()) }}" + - action: script.clear_fresh_air_notice + - action: input_boolean.turn_on + target: + entity_id: input_boolean.fresh_air_notice_active + - variables: + message: >- + {% from 'fresh_air.jinja' import fresh_air_message %} + {{ fresh_air_message(assessment) | trim }} + - action: persistent_notification.create + data: + notification_id: fresh_air_opportunity + title: Good weather for fresh air + message: "{{ message }}" + - action: script.notify_engine + continue_on_error: true + data: + who: parents + title: Good weather for fresh air + value1: "{{ message }}" + group: fresh_air + tag: fresh_air_opportunity + level: passive + notification_icon: mdi:currency-usd + color: '#00897B' + url: /?more-info-entity-id=binary_sensor.fresh_air_window_opportunity + - action: script.send_to_logbook + data: + topic: CLIMATE + message: >- + Fresh-air reminder issued to Carlo/Stacey and household screens: + inside={{ assessment.inside_f }}F/{{ assessment.indoor_humidity }}%, + outside={{ assessment.outside_f }}F, dew points={{ assessment.indoor_dew_point_f }}/{{ assessment.outdoor_dew_point_f }}F, + Airly CAQI={{ assessment.airly_caqi }}. Daily allowance reserved. diff --git a/config/script/hvac_control.yaml b/config/script/hvac_control.yaml index f8bdaaea..d9af6cbd 100644 --- a/config/script/hvac_control.yaml +++ b/config/script/hvac_control.yaml @@ -4,7 +4,7 @@ # Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig # ------------------------------------------------------------------- # HVAC Control - Shared thermostat target and safety reconciliation -# Keeps grid, open-entry, occupancy, guest, and weather rules aligned. +# Retains the upstairs policy and delegates downstairs to its own controller. # ------------------------------------------------------------------- ###################################################################### @@ -12,56 +12,7 @@ set_downstairs_target_temp_based_on_conditions: alias: Set Downstairs Target Temperature Based on Conditions mode: restart sequence: - - choose: - - conditions: - - condition: state - entity_id: group.family - state: 'not_home' - - condition: state - entity_id: input_boolean.guest_mode - state: 'off' - sequence: - - service: script.send_to_logbook - data: - topic: "CLIMATE" - message: "Downstairs HVAC set to ECO (family not_home)." - - service: climate.set_preset_mode - target: - entity_id: climate.downstairs - data: - preset_mode: 'eco' - default: - - variables: - outside_temp: "{{ states('sensor.pirateweather_temperature') | float(none) }}" - target_temp: >- - {% if outside_temp is none %} - 80 - {% elif outside_temp > 90 %} - 78 - {% else %} - 80 - {% endif %} - - service: climate.set_preset_mode - target: - entity_id: climate.downstairs - data: - preset_mode: 'none' - - service: climate.set_hvac_mode - target: - entity_id: climate.downstairs - data: - hvac_mode: cool - - service: climate.set_temperature - target: - entity_id: climate.downstairs - data: - temperature: "{{ target_temp }}" - - service: script.send_to_logbook - data: - topic: "CLIMATE" - message: >- - Downstairs target set to {{ target_temp }}F - (family/guest occupied; outside={{ outside_temp | default('unavailable', true) }}F). + - action: script.reconcile_downstairs_hvac reconcile_hvac_state: alias: Reconcile HVAC State @@ -69,6 +20,10 @@ reconcile_hvac_state: sequence: - variables: reconcile_reason: "{{ reason | default('state change', true) }}" + # Downstairs cloud command verification must not delay upstairs safety. + - action: script.turn_on + target: + entity_id: script.reconcile_downstairs_hvac - choose: - conditions: - condition: state @@ -78,7 +33,6 @@ reconcile_hvac_state: - service: climate.turn_off target: entity_id: - - climate.downstairs - climate.upstairs - service: script.send_to_logbook data: @@ -92,7 +46,6 @@ reconcile_hvac_state: - service: climate.turn_off target: entity_id: - - climate.downstairs - climate.upstairs - service: script.send_to_logbook data: @@ -106,11 +59,6 @@ reconcile_hvac_state: entity_id: input_boolean.guest_mode state: 'off' sequence: - - service: climate.set_preset_mode - target: - entity_id: climate.downstairs - data: - preset_mode: 'eco' - service: climate.set_hvac_mode target: entity_id: climate.upstairs @@ -126,7 +74,6 @@ reconcile_hvac_state: topic: "CLIMATE" message: "HVAC reconciled to away targets ({{ reconcile_reason }})." default: - - service: script.set_downstairs_target_temp_based_on_conditions - service: climate.set_hvac_mode target: entity_id: climate.upstairs