Manage downstairs cooling and fresh air reminders

master
Carlo Costanzo 13 hours ago
parent 656a3106d4
commit ef6273eb90

@ -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 %}

@ -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 %}

@ -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&#176;F after both beds are occupied for 6 minutes, from sunset until 8:00 a.m.
{% elif plan.phase == 'sleep' %}
80&#176;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&#176;F from 8:00 a.m. until bedtime; 77&#176;F overnight.
Getting out of bed does not change the target. Cooling only; no heating.
- **Guests and away:** Guest Mode selects 76&#176;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.

@ -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

@ -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

@ -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

@ -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 %}

@ -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

@ -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.

@ -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

Loading…
Cancel
Save

Powered by TurnKey Linux.