From 49c3cfa1e92839360eac66eb3cb82142f599565d Mon Sep 17 00:00:00 2001 From: Carlo Costanzo Date: Thu, 15 Oct 2026 17:09:56 -0400 Subject: [PATCH] Calculate holidays locally and retire remote countdowns Preserve existing countdown and calendar entity IDs while computing floating holidays from weekday, Gregorian Easter, and Hebrew-calendar rules. Remove obsolete example credentials and expiring date tables, correct Memorial Day and Mardi Gras, and cover the Hanukkah lighting window. Align holiday speech with today/tomorrow countdown values and extend the existing validation gate with calendar, rollover, lighting, and speech checks. --- .github/scripts/holiday_lighting_coverage.py | 168 ++++++++++- config/json_data/flag_days.json | 45 +-- config/json_data/holidays.json | 60 +--- config/packages/README.md | 2 +- config/packages/holiday.yaml | 276 ++++++++----------- config/shell_scripts/README.md | 3 + config/shell_scripts/holiday_countdowns.py | 144 ++++++++++ config/templates/speech/briefing.yaml | 48 ++-- config/travis_secrets.yaml | 9 - config/www/json_data/flag_days.json | 67 +---- config/www/json_data/holidays.json | 88 +----- requirements-dev.txt | 3 + 12 files changed, 461 insertions(+), 452 deletions(-) create mode 100644 config/shell_scripts/holiday_countdowns.py diff --git a/.github/scripts/holiday_lighting_coverage.py b/.github/scripts/holiday_lighting_coverage.py index 01029b51..81aa4657 100644 --- a/.github/scripts/holiday_lighting_coverage.py +++ b/.github/scripts/holiday_lighting_coverage.py @@ -5,13 +5,16 @@ from __future__ import annotations import argparse import calendar +import importlib.util import json import sys from dataclasses import dataclass -from datetime import date, timedelta +from datetime import date, datetime, timedelta from pathlib import Path from typing import Callable, Iterable +from jinja2 import Environment, StrictUndefined + from validate_dashboards import ValidationError as YamlValidationError from validate_dashboards import parse_yaml @@ -93,12 +96,27 @@ def last_weekday(year: int, month: int, weekday: int) -> date: DYNAMIC_RULES = ( + DynamicRule("MLK Day", "holidays", lambda year: nth_weekday(year, 1, 0, 3)), + DynamicRule("Presidents Day", "holidays", lambda year: nth_weekday(year, 2, 0, 3)), + DynamicRule("Mardi Gras", "holidays", lambda year: gregorian_easter(year) - timedelta(days=47)), + DynamicRule("Ash Wednesday", "holidays", lambda year: gregorian_easter(year) - timedelta(days=46)), + DynamicRule("Palm Sunday", "holidays", lambda year: gregorian_easter(year) - timedelta(days=7)), + DynamicRule("Good Friday", "holidays", lambda year: gregorian_easter(year) - timedelta(days=2)), DynamicRule("Easter Sunday", "holidays", gregorian_easter), DynamicRule("Mothers Day", "holidays", lambda year: nth_weekday(year, 5, 6, 2)), DynamicRule("Fathers Day", "holidays", lambda year: nth_weekday(year, 6, 6, 3)), DynamicRule("Thanksgiving Day", "holidays", lambda year: nth_weekday(year, 11, 3, 4)), + DynamicRule("Memorial Day", "holidays", lambda year: last_weekday(year, 5, 0)), + DynamicRule("Labor Day", "holidays", lambda year: nth_weekday(year, 9, 0, 1)), + DynamicRule("Columbus Day", "holidays", lambda year: nth_weekday(year, 10, 0, 2)), + DynamicRule("Martin Luther King Jr. Day", "flag_days", lambda year: nth_weekday(year, 1, 0, 3)), + DynamicRule("Washington's Birthday (Presidents Day)", "flag_days", lambda year: nth_weekday(year, 2, 0, 3)), + DynamicRule("Armed Forces Day", "flag_days", lambda year: nth_weekday(year, 5, 5, 3)), DynamicRule("Memorial Day", "flag_days", lambda year: last_weekday(year, 5, 0)), DynamicRule("Labor Day", "flag_days", lambda year: nth_weekday(year, 9, 0, 1)), + DynamicRule("National POW/MIA Recognition Day", "flag_days", lambda year: nth_weekday(year, 9, 4, 3)), + DynamicRule("Columbus Day", "flag_days", lambda year: nth_weekday(year, 10, 0, 2)), + DynamicRule("Election Day", "flag_days", lambda year: nth_weekday(year, 11, 0, 1) + timedelta(days=1)), ) @@ -178,7 +196,7 @@ def validate_dynamic_rule( actual = { parse_calendar_date(key, rule.source) for key, value in dynamic_map.items() - if value == rule.name and start <= parse_calendar_date(key, rule.source) < end + if rule.name in value.split("; ") and start <= parse_calendar_date(key, rule.source) < end } if actual != expected: missing = sorted(expected - actual) @@ -239,8 +257,8 @@ def validate_routing_contract(package_path: Path, script_path: Path) -> None: raise CoverageError(f"cannot read holiday routing configuration: {exc}") from exc required_package_fragments = ( - "resource: http://localhost:8123/local/json_data/holidays.json", - "resource: http://localhost:8123/local/json_data/flag_days.json", + "unique_id: holiday_calendar", + "unique_id: flag_calendar", "unique_id: holiday_lighting_mode", "unique_id: holiday_lighting_scene", "scene.month_{{ mode }}_colors", @@ -277,10 +295,23 @@ def validate_coverage(root: Path, start: date, months: int) -> dict[str, tuple[d holidays = _require_mapping(read_json(holiday_path).get("MAJOR_US"), "MAJOR_US") flags = _require_mapping(read_json(flag_path).get("Flag_Days_US"), "Flag_Days_US") holiday_static = _require_mapping(holidays.get("static"), "MAJOR_US.static") - holiday_dynamic = _require_mapping(holidays.get("dynamic"), "MAJOR_US.dynamic") - flag_dynamic = _require_mapping(flags.get("dynamic"), "Flag_Days_US.dynamic") - validate_dynamic_map(holiday_dynamic, "holidays") - validate_dynamic_map(flag_dynamic, "flag_days") + for source, data in (("holidays", holidays), ("flag_days", flags)): + additions = _require_mapping(data.get("dynamic"), f"{source}.dynamic") + validate_dynamic_map(additions, source) + ruled_names = {rule.name for rule in DYNAMIC_RULES if rule.source == source} + ruled_names |= {"Chanukkah", "Inauguration Day"} + for name in additions.values(): + if any(event in ruled_names for event in name.split("; ")): + raise CoverageError(f"{source}: {name} belongs in perpetual rules, not dated additions") + + calculator = load_calculator(root) + holiday_dynamic: dict[str, object] = {} + flag_dynamic: dict[str, object] = {} + for year in range(start.year, end.year + 1): + anchor = date(year, 7, 1) + data_dir = holiday_path.parent + holiday_dynamic.update(calculator.calendar_data("holiday", anchor, data_dir)["MAJOR_US"]["dynamic"]) + flag_dynamic.update(calculator.calendar_data("flag", anchor, data_dir)["Flag_Days_US"]["dynamic"]) for key, expected_name in STATIC_REQUIREMENTS.items(): actual_name = holiday_static.get(key) @@ -292,7 +323,9 @@ def validate_coverage(root: Path, start: date, months: int) -> dict[str, tuple[d dates: dict[str, tuple[date, ...]] = {} for rule in DYNAMIC_RULES: source_map = holiday_dynamic if rule.source == "holidays" else flag_dynamic - dates[rule.name] = validate_dynamic_rule(rule, source_map, start, end) + dates[f"{rule.source}: {rule.name}"] = validate_dynamic_rule(rule, source_map, start, end) + + validate_runtime_boundaries(root, calculator) validate_scenes(scene_path) validate_routing_contract( @@ -302,6 +335,123 @@ def validate_coverage(root: Path, start: date, months: int) -> dict[str, tuple[d return dates +def load_calculator(root: Path): + path = root / "config" / "shell_scripts" / "holiday_countdowns.py" + spec = importlib.util.spec_from_file_location("holiday_countdowns", path) + if spec is None or spec.loader is None: + raise CoverageError(f"cannot load local calendar calculator: {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def validate_runtime_boundaries(root: Path, calculator) -> None: + """Exercise real templates, including faults the old JSON-only gate missed.""" + package = parse_yaml(root / "config" / "packages" / "holiday.yaml") + sensors = package["template"][0]["sensor"] + mode_template = next(sensor["state"] for sensor in sensors if sensor["unique_id"] == "holiday_lighting_mode") + env = Environment(undefined=StrictUndefined) + data_dir = root / "config" / "www" / "json_data" + + def require(actual, expected, label): + if actual != expected: + raise CoverageError(f"{label}: expected {expected!r}, got {actual!r}") + + # Independently published 25 Kislev dates, including a late December festival. + # https://www.hebcal.com/holidays/chanukah-2026 + # https://www.hebcal.com/holidays/chanukah-2027 + for first_day in (date(2026, 12, 5), date(2027, 12, 25)): + for offset in (-1, 0, 1): + today = first_day + timedelta(days=offset) + target = calculator.next_holiday("chanukkah", today) + if offset <= 0: + require(target, first_day, f"Chanukkah {today}") + elif target <= today: + raise CoverageError(f"Chanukkah failed annual rollover: {today}") + + countdown_rules = { + "halloween": lambda year: date(year, 10, 31), + "christmas": lambda year: date(year, 12, 25), + "easter": gregorian_easter, + "mothers": lambda year: nth_weekday(year, 5, 6, 2), + "fathers": lambda year: nth_weekday(year, 6, 6, 3), + "memorial_day": lambda year: last_weekday(year, 5, 0), + "labor_day": lambda year: nth_weekday(year, 9, 0, 1), + "thanksgiving_day": lambda year: nth_weekday(year, 11, 3, 4), + } + for year in (2026, 2027, 2028, 2031, 2038, 2040): + for name, rule in countdown_rules.items(): + event = rule(year) + for offset in (-1, 0, 1): + today = event + timedelta(days=offset) + expected = event if offset <= 0 else rule(year + 1) + require(calculator.next_holiday(name, today), expected, f"{name} rollover {today}") + + cases = { + "2026-01-01": "new_years_day", "2026-01-19": "RWB", + "2026-02-09": "standard", "2026-02-10": "valentine", "2026-02-12": "RWB", + "2026-02-17": "mardi_gras", "2026-03-05": "standard", + "2026-03-14": "pi", "2026-03-15": "st_patty", + "2026-04-02": "easter", "2026-04-06": "standard", "2026-04-20": "standard", + "2026-05-04": "starwars", "2026-05-05": "cinco_de_mayo", + "2026-05-07": "mothers_day", "2026-05-10": "mothers_day", "2026-05-11": "standard", + "2026-05-23": "RWB", "2026-05-25": "RWB", + "2026-06-18": "fathers_day", "2026-06-21": "fathers_day", + "2026-09-05": "RWB", "2026-09-07": "RWB", + "2026-10-01": "halloween", "2026-10-27": "RWB", "2026-11-11": "RWB", + "2026-11-23": "thanksgiving", "2026-11-26": "thanksgiving", + "2026-11-27": "standard", "2026-11-30": "christmas", "2026-12-03": "christmas", + "2026-12-04": "hanukkah", "2026-12-05": "hanukkah", "2026-12-06": "hanukkah", "2026-12-07": "RWB", + "2026-12-08": "hanukkah", "2026-12-09": "hanukkah", "2026-12-10": "hanukkah", + "2026-12-11": "hanukkah", "2026-12-12": "christmas", + "2026-12-25": "christmas", "2026-12-26": "standard", "2026-12-31": "new_years_day", + "2027-02-09": "mardi_gras", "2028-02-29": "mardi_gras", "2029-02-13": "mardi_gras", + "2027-12-24": "hanukkah", "2027-12-25": "hanukkah", "2027-12-26": "hanukkah", "2027-12-27": "hanukkah", + "2027-12-28": "hanukkah", "2027-12-29": "hanukkah", "2027-12-30": "hanukkah", "2027-12-31": "hanukkah", "2028-01-01": "new_years_day", + "2038-04-22": "easter", "2040-05-28": "RWB", + } + for iso_date, expected in cases.items(): + today = date.fromisoformat(iso_date) + holiday = calculator.calendar_data("holiday", today, data_dir) + flag = calculator.calendar_data("flag", today, data_dir) + states = {"sensor.holiday": holiday["state"], "sensor.flag": flag["state"]} + states.update({f"sensor.{name}_countdown": str((calculator.next_holiday(name, today) - today).days) + for name in [*countdown_rules, "chanukkah"]}) + env.globals.update( + now=lambda: datetime.combine(today, datetime.min.time()), + strptime=lambda text, fmt, default=None: datetime.strptime(text, fmt), + states=lambda entity: states.get(entity, "unknown"), + is_state=lambda entity, value: states.get(entity) == value, + state_attr=lambda entity, attr: holiday.get(attr) if entity == "sensor.holiday" else flag.get(attr), + ) + require(env.from_string(mode_template).render().strip(), expected, f"lighting {today}") + + # On Jan 20, 2025 both events must survive the calendar's date-key collision. + collision = calculator.calendar_data("flag", date(2025, 1, 20), data_dir) + names = collision["Flag_Days_US"]["dynamic"]["01/20/2025"].split("; ") + require(set(names), {"Martin Luther King Jr. Day", "Inauguration Day"}, "shared flag date") + require(calculator.calendar_data("holiday", date(2027, 5, 31), data_dir)["state"], "Memorial Day", "Memorial name") + require(calculator.calendar_data("holiday", date(2027, 4, 19), data_dir)["state"], "", "false April Memorial Day") + require(calculator.calendar_data("flag", date(2033, 1, 20), data_dir)["state"], "True", "future inauguration") + + speech = (root / "config" / "templates" / "speech" / "briefing.yaml").read_text(encoding="utf-8") + macro_start = speech.index("{%- macro days_until() -%}") + macro_end = speech.index("{%- endmacro -%}", macro_start) + len("{%- endmacro -%}") + speech_template = env.from_string(speech[macro_start:macro_end] + "{{ days_until() }}") + speech_names = {"mothers": "Mothers Day", "fathers": "Fathers Day", "easter": "Easter Sunday", + "thanksgiving_day": "Thanksgiving", "halloween": "Halloween", "chanukkah": "Chanukkah", "christmas": "Christmas"} + for name, spoken in speech_names.items(): + for days in (0, 1, 5): + states = {f"sensor.{key}_countdown": "9999" for key in speech_names} + states[f"sensor.{name}_countdown"] = str(days) + result = speech_template.render().strip() + expected = f"Today is {spoken}!" if days == 0 else (f"{spoken} is tomorrow!" if days == 1 else f"days until {spoken}!") + if expected not in result: + raise CoverageError(f"speech {name}/{days}: missing {expected!r}, got {result!r}") + states = {f"sensor.{key}_countdown": "unavailable" for key in speech_names} + require(speech_template.render().strip(), "", "unavailable countdown speech") + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=repo_root()) diff --git a/config/json_data/flag_days.json b/config/json_data/flag_days.json index b8a20507..7214c4ae 100755 --- a/config/json_data/flag_days.json +++ b/config/json_data/flag_days.json @@ -18,48 +18,7 @@ "11/10": "Marine Corps Birthday", "11/11": "Veterans Day", "12/7": "Pearl Harbor Remembrance Day" - } }, - "dynamic": { - "01/18/2027": "Martin Luther King Jr. Day", - "01/17/2028": "Martin Luther King Jr. Day", - "01/15/2029": "Martin Luther King Jr. Day", - "01/21/2030": "Martin Luther King Jr. Day", - "01/20/2029": "Inauguration Day", - "01/20/2033": "Inauguration Day", - "02/15/2027": "Washington's Birthday (Presidents Day)", - "02/21/2028": "Washington's Birthday (Presidents Day)", - "02/19/2029": "Washington's Birthday (Presidents Day)", - "02/18/2030": "Washington's Birthday (Presidents Day)", - "05/16/2026": "Armed Forces Day", - "05/15/2027": "Armed Forces Day", - "05/20/2028": "Armed Forces Day", - "05/19/2029": "Armed Forces Day", - "05/18/2030": "Armed Forces Day", - "05/25/2026": "Memorial Day", - "05/31/2027": "Memorial Day", - "05/29/2028": "Memorial Day", - "05/28/2029": "Memorial Day", - "05/27/2030": "Memorial Day", - "09/07/2026": "Labor Day", - "09/06/2027": "Labor Day", - "09/04/2028": "Labor Day", - "09/03/2029": "Labor Day", - "09/02/2030": "Labor Day", - "09/18/2026": "National POW/MIA Recognition Day", - "09/17/2027": "National POW/MIA Recognition Day", - "09/15/2028": "National POW/MIA Recognition Day", - "09/21/2029": "National POW/MIA Recognition Day", - "09/20/2030": "National POW/MIA Recognition Day", - "10/12/2026": "Columbus Day", - "10/11/2027": "Columbus Day", - "10/09/2028": "Columbus Day", - "10/08/2029": "Columbus Day", - "10/14/2030": "Columbus Day", - "11/03/2026": "Election Day", - "11/02/2027": "Election Day", - "11/07/2028": "Election Day", - "11/06/2029": "Election Day", - "11/05/2030": "Election Day" - } + "dynamic": {} } +} diff --git a/config/json_data/holidays.json b/config/json_data/holidays.json index 3712054e..25f4b6ee 100755 --- a/config/json_data/holidays.json +++ b/config/json_data/holidays.json @@ -26,64 +26,6 @@ "12/25": "Christmas Day", "12/31": "New Years Eve" }, - "dynamic": { - "03/29/2026": "Palm Sunday", - "04/03/2026": "Good Friday", - "04/05/2026": "Easter Sunday", - "04/20/2026": "Memorial Day", - "05/10/2026": "Mothers Day", - "06/21/2026": "Fathers Day", - "09/07/2026": "Labor Day", - "10/12/2026": "Columbus Day", - "11/26/2026": "Thanksgiving Day", - "01/18/2027": "MLK Day", - "02/10/2027": "Ash Wednesday", - "02/15/2027": "Presidents Day", - "03/21/2027": "Palm Sunday", - "03/26/2027": "Good Friday", - "03/28/2027": "Easter Sunday", - "04/19/2027": "Memorial Day", - "05/09/2027": "Mothers Day", - "06/20/2027": "Fathers Day", - "09/06/2027": "Labor Day", - "10/11/2027": "Columbus Day", - "11/25/2027": "Thanksgiving Day", - "01/17/2028": "MLK Day", - "02/21/2028": "Presidents Day", - "03/01/2028": "Ash Wednesday", - "04/09/2028": "Palm Sunday", - "04/14/2028": "Good Friday", - "04/16/2028": "Easter Sunday", - "04/17/2028": "Memorial Day", - "05/14/2028": "Mothers Day", - "06/18/2028": "Fathers Day", - "09/04/2028": "Labor Day", - "10/09/2028": "Columbus Day", - "11/23/2028": "Thanksgiving Day", - "01/15/2029": "MLK Day", - "02/14/2029": "Ash Wednesday", - "02/19/2029": "Presidents Day", - "03/25/2029": "Palm Sunday", - "03/30/2029": "Good Friday", - "04/01/2029": "Easter Sunday", - "04/23/2029": "Memorial Day", - "05/13/2029": "Mothers Day", - "06/17/2029": "Fathers Day", - "09/03/2029": "Labor Day", - "10/08/2029": "Columbus Day", - "11/22/2029": "Thanksgiving Day", - "01/21/2030": "MLK Day", - "02/18/2030": "Presidents Day", - "03/06/2030": "Ash Wednesday", - "04/14/2030": "Palm Sunday", - "04/19/2030": "Good Friday", - "04/21/2030": "Easter Sunday", - "04/22/2030": "Memorial Day", - "05/12/2030": "Mothers Day", - "06/16/2030": "Fathers Day", - "09/02/2030": "Labor Day", - "10/14/2030": "Columbus Day", - "11/28/2030": "Thanksgiving Day" - } + "dynamic": {} } } diff --git a/config/packages/README.md b/config/packages/README.md index a8253ee3..e988d4cd 100755 --- a/config/packages/README.md +++ b/config/packages/README.md @@ -56,7 +56,7 @@ The kiosk package also exposes `sensor.kiosk_recent_phone_notifications`: a rest | [![YAML source: climate](https://img.shields.io/static/v1?label=YAML&message=climate&color=lightgrey&logo=github&logoColor=181717)](climate.yaml) | Nest climate schedules plus 550-hour downstairs/450-hour upstairs/9-month AC filter reminders, native per-unit NFC tag change capture, per-unit change counts/cadence, and 360-day condenser-line cleanout reminders with snooze and changed/cleaned actions. [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/nScl2JxYSB8) | `input_datetime.*_filter_snooze_until`, `sensor.*_ac_runtime_since_last_filter_change`, `sensor.*_ac_filter_days_since_last_change`, `sensor.*_ac_filter_change_count`, `sensor.hvac_condenser_lines_days_since_cleaned`, `script.notify_engine_two_button`, `mobile_app_notification_action`, `tag` | | [![YAML source: garadget](https://img.shields.io/static/v1?label=YAML&message=garadget&color=lightgrey&logo=github&logoColor=181717)](garadget.yaml) | MQTT-based garage door control plus arrival helpers, entry prompts, camera context, and nighttime/away reminders with a shared one-hour phone and speech snooze. Garage-door snoozing resets after both doors close and does not survive restarts; the separate garbage-reminder snooze remains restart-safe. | `cover.large_garage_door`, `cover.small_garage_door`, `group.garage_doors`, `timer.garage_door_reminder_snooze`, `timer.garbage_reminder_snooze`, `script.open_large_garage_door_if_ready` | | [![YAML source: august](https://img.shields.io/static/v1?label=YAML&message=august&color=lightgrey&logo=github&logoColor=181717)](august.yaml) | Front-door August smart lock with Alexa Show camera pop-up when unlocked. | `lock.front_door`, media_player actions for front doorbell camera | -| [![YAML source: holiday](https://img.shields.io/static/v1?label=YAML&message=holiday&color=lightgrey&logo=github&logoColor=181717)](holiday.yaml) | REST-driven US holiday + flag sensors plus the inspectable exterior lighting mode. | `sensor.holiday`, `sensor.flag`, `sensor.holiday_lighting_mode`, `sensor.holiday_lighting_scene`, JSON feed at `config/www/json_data/holidays.json` | +| [![YAML source: holiday](https://img.shields.io/static/v1?label=YAML&message=holiday&color=lightgrey&logo=github&logoColor=181717)](holiday.yaml) | Perpetual local holiday/flag rules, household fixed dates and one-off JSON additions, nine local countdowns, and a midnight refresh. Lighting shares the countdowns, calculates Mardi Gras from Easter, and covers eight Hanukkah evenings subject to flag-day priority. Existing speech entity IDs stay stable. | `sensor.holiday`, `sensor.flag`, `sensor.*_countdown`, `sensor.holiday_lighting_mode`, `sensor.holiday_lighting_scene`, [local calculator](../shell_scripts/holiday_countdowns.py) | | [![YAML source: lightning](https://img.shields.io/static/v1?label=YAML&message=lightning&color=lightgrey&logo=github&logoColor=181717)](lightning.yaml) | Blitzortung lightning monitoring with guarded distance wording, snoozeable push actions, single-owner safety speech, and a 60-minute duplicate-trigger cooldown. | `sensor.blitzortung_lightning_counter`, `sensor.blitzortung_lightning_distance`, `input_boolean.snooze_lightning`, `script.speech_engine` | | [![YAML source: logbook_activity_feed](https://img.shields.io/static/v1?label=YAML&message=logbook_activity_feed&color=lightgrey&logo=github&logoColor=181717)](logbook_activity_feed.yaml) | Dummy `sensor.activity_feed` + helper to write clean Activity entries (Issue #1550). | `sensor.activity_feed`, `script.send_to_logbook` | | [![YAML source: mariadb_monitoring](https://img.shields.io/static/v1?label=YAML&message=mariadb_monitoring&color=lightgrey&logo=github&logoColor=181717)](mariadb_monitoring.yaml) | MariaDB health sensors and Lovelace dashboard snippet for recorder stats. | `sensor.mariadb_status`, `sensor.database_size` | diff --git a/config/packages/holiday.yaml b/config/packages/holiday.yaml index ea4f01da..0cef5339 100755 --- a/config/packages/holiday.yaml +++ b/config/packages/holiday.yaml @@ -7,7 +7,10 @@ # Related Issue: 1774 # Centralizes holiday calendar data and the active exterior lighting mode. # ------------------------------------------------------------------- -# Notes: /local/json_data is served from config/www/json_data and drives lighting mode coverage. +# Notes: holiday_countdowns.py calculates floating dates and all nine countdowns locally. +# Notes: config/www/json_data holds fixed household dates and optional one-off additions. +# Notes: Existing IDs stay stable; calendars/countdowns refresh at startup and local midnight. +# Notes: Hanukkah lighting spans eight evenings, beginning before 25 Kislev; flag days retain priority. ###################################################################### # Video breakdown: https://www.vcloudinfo.com/2019/02/breaking-down-the-flag-sensor-in-home-assistant.html # Modified for my own fun stuff! @@ -24,124 +27,100 @@ homeassistant: icon: mdi:flag friendly_name: Flag Day -############################################################################### -# Sensor updates once every 4 hours (14400 seconds) & runs 6 times in 24 hours -# -# First it checks for holiday in static section, if that doesn't exist, -# it checks in the dynamic section. If neither exists, the value will be empty. -############################################################################### -sensor: - - platform: rest - resource: http://localhost:8123/local/json_data/holidays.json - name: Holiday - scan_interval: 14400 - value_template: > - {% set holiday_data = value_json.MAJOR_US if value_json is defined and value_json.MAJOR_US is defined else {} %} - {% set static_days = holiday_data.static if holiday_data.static is defined else {} %} - {% set dynamic_days = holiday_data.dynamic if holiday_data.dynamic is defined else {} %} - {% set today = now().month ~ '/' ~ now().day %} - {% set today_full = now().strftime('%m/%d/%Y') %} - {% set today_full_alt = now().month ~ '/' ~ now().day ~ '/' ~ now().year %} - {% if today in static_days %} - {{ static_days[today] }} - {% elif today_full in dynamic_days %} - {{ dynamic_days[today_full] }} - {% elif today_full_alt in dynamic_days %} - {{ dynamic_days[today_full_alt] }} - {% endif %} - json_attributes: - - MAJOR_US - -################################################################################ -# Sensor Uses Flag data generated by AI -################################################################################ - - platform: rest - resource: http://localhost:8123/local/json_data/flag_days.json - name: Flag - scan_interval: 14400 - value_template: >- - {% set now_string = now().month ~ '/' ~ now().day %} - {% set now_full_string = now().strftime('%m/%d/%Y') %} - {% set now_full_alt_string = now().month ~ '/' ~ now().day ~ '/' ~ now().year %} - {% set flag_data = value_json.Flag_Days_US if value_json is defined and value_json.Flag_Days_US is defined else {} %} - {% set static_days = flag_data.static if flag_data.static is defined else {} %} - {% set dynamic_days = flag_data.dynamic if flag_data.dynamic is defined else {} %} - {% if now_string in static_days %} - True - {% elif now_full_string in dynamic_days or now_full_alt_string in dynamic_days %} - True - {% else %} - False - {% endif %} - json_attributes: - - Flag_Days_US - -################################################################################ -# Countdown Sensor using WolfRam Alpha Natural language queries -################################################################################ - - - platform: rest - name: Halloween Countdown - resource: !secret wolframalpha_halloween_api - value_template: "{{ (value|replace(' days', '')) | int }}" - unit_of_measurement: Days - scan_interval: 43200 - - - platform: rest - name: Christmas Countdown - resource: !secret wolframalpha_xmas_api - value_template: "{{ (value|replace(' days', '')) | int }}" - unit_of_measurement: Days - scan_interval: 43200 - - - platform: rest - name: Easter Countdown - resource: !secret wolframalpha_easter_api - value_template: "{{ (value|replace(' days', '')) | int }}" - unit_of_measurement: Days - scan_interval: 43200 - - - platform: rest - name: Mothers Countdown - resource: !secret wolframalpha_mothersday_api - value_template: "{{ (value|replace(' days', '')) | int }}" - unit_of_measurement: Days - scan_interval: 43200 - - - platform: rest - name: Fathers Countdown - resource: !secret wolframalpha_fathersday_api - value_template: "{{ (value|replace(' days', '')) | int }}" - unit_of_measurement: Days - scan_interval: 43200 - - - platform: rest - name: Chanukkah Countdown - resource: !secret wolframalpha_chanukkah_api - value_template: "{{ (value|replace(' days', '')) | int }}" - unit_of_measurement: Days - scan_interval: 43200 - - - platform: rest - name: Labor Day Countdown - resource: !secret wolframalpha_labor_api - value_template: "{{ (value|replace(' days', '')) | int }}" - unit_of_measurement: Days - scan_interval: 43200 - - - platform: rest - name: Memorial Day Countdown - resource: !secret wolframalpha_memorial_api - value_template: "{{ (value|replace(' days', '')) | int }}" - unit_of_measurement: Days - scan_interval: 43200 - - - platform: rest - name: Thanksgiving Day Countdown - resource: !secret wolframalpha_thanksgiving_api - value_template: "{{ (value|replace(' days', '')) | int }}" - unit_of_measurement: Days - scan_interval: 43200 +command_line: + - sensor: + name: Holiday + unique_id: holiday_calendar + command: "python3 /config/shell_scripts/holiday_countdowns.py holiday" + value_template: "{{ value_json.state }}" + json_attributes: + - MAJOR_US + scan_interval: 86400 + - sensor: + name: Flag + unique_id: flag_calendar + command: "python3 /config/shell_scripts/holiday_countdowns.py flag" + value_template: "{{ value_json.state }}" + json_attributes: + - Flag_Days_US + scan_interval: 86400 + - sensor: + name: Halloween Countdown + unique_id: halloween_countdown + command: "python3 /config/shell_scripts/holiday_countdowns.py halloween" + unit_of_measurement: Days + scan_interval: 86400 + - sensor: + name: Christmas Countdown + unique_id: christmas_countdown + command: "python3 /config/shell_scripts/holiday_countdowns.py christmas" + unit_of_measurement: Days + scan_interval: 86400 + - sensor: + name: Easter Countdown + unique_id: easter_countdown + command: "python3 /config/shell_scripts/holiday_countdowns.py easter" + unit_of_measurement: Days + scan_interval: 86400 + - sensor: + name: Mothers Countdown + unique_id: mothers_countdown + command: "python3 /config/shell_scripts/holiday_countdowns.py mothers" + unit_of_measurement: Days + scan_interval: 86400 + - sensor: + name: Fathers Countdown + unique_id: fathers_countdown + command: "python3 /config/shell_scripts/holiday_countdowns.py fathers" + unit_of_measurement: Days + scan_interval: 86400 + - sensor: + name: Chanukkah Countdown + unique_id: chanukkah_countdown + command: "python3 /config/shell_scripts/holiday_countdowns.py chanukkah" + unit_of_measurement: Days + scan_interval: 86400 + - sensor: + name: Labor Day Countdown + unique_id: labor_day_countdown + command: "python3 /config/shell_scripts/holiday_countdowns.py labor_day" + unit_of_measurement: Days + scan_interval: 86400 + - sensor: + name: Memorial Day Countdown + unique_id: memorial_day_countdown + command: "python3 /config/shell_scripts/holiday_countdowns.py memorial_day" + unit_of_measurement: Days + scan_interval: 86400 + - sensor: + name: Thanksgiving Day Countdown + unique_id: thanksgiving_day_countdown + command: "python3 /config/shell_scripts/holiday_countdowns.py thanksgiving_day" + unit_of_measurement: Days + scan_interval: 86400 + +automation: + - alias: Holiday Countdown Midnight Refresh + id: holiday_countdown_midnight_refresh + mode: single + triggers: + - trigger: time + at: "00:00:05" + actions: + - action: homeassistant.update_entity + target: + entity_id: + - sensor.holiday + - sensor.flag + - sensor.halloween_countdown + - sensor.christmas_countdown + - sensor.easter_countdown + - sensor.mothers_countdown + - sensor.fathers_countdown + - sensor.chanukkah_countdown + - sensor.labor_day_countdown + - sensor.memorial_day_countdown + - sensor.thanksgiving_day_countdown template: - sensor: @@ -149,75 +128,60 @@ template: unique_id: holiday_lighting_mode icon: mdi:string-lights state: >- - {%- set today = today_at('00:00') -%} + {%- set today = now().date() -%} {%- set mmdd = now().strftime('%m%d') | int -%} {%- set holiday_data = state_attr('sensor.holiday', 'MAJOR_US') or {} -%} - {%- set flag_data = state_attr('sensor.flag', 'Flag_Days_US') or {} -%} {%- set holiday_dynamic = holiday_data.get('dynamic', {}) if holiday_data is mapping else {} -%} - {%- set flag_dynamic = flag_data.get('dynamic', {}) if flag_data is mapping else {} -%} - {%- set days_to = namespace(easter=9999, mothers=9999, fathers=9999, memorial=9999, labor=9999, thanksgiving=9999) -%} + {%- set dates = namespace(mardi_gras=false, hanukkah=false) -%} {%- set mode = namespace(value='standard') -%} - {%- for date_text, name in holiday_dynamic.items() -%} - {%- set event_date = strptime(date_text, '%m/%d/%Y') -%} - {%- set days = ((as_timestamp(event_date) - as_timestamp(today)) / 86400) | int -%} - {%- if days >= 0 and name == 'Easter Sunday' and days < days_to.easter -%} - {%- set days_to.easter = days -%} - {%- elif days >= 0 and name == 'Mothers Day' and days < days_to.mothers -%} - {%- set days_to.mothers = days -%} - {%- elif days >= 0 and name == 'Fathers Day' and days < days_to.fathers -%} - {%- set days_to.fathers = days -%} - {%- elif days >= 0 and name == 'Thanksgiving Day' and days < days_to.thanksgiving -%} - {%- set days_to.thanksgiving = days -%} - {%- endif -%} - {%- endfor -%} - {%- for date_text, name in flag_dynamic.items() -%} - {%- set event_date = strptime(date_text, '%m/%d/%Y') -%} - {%- set days = ((as_timestamp(event_date) - as_timestamp(today)) / 86400) | int -%} - {%- if days >= 0 and name == 'Memorial Day' and days < days_to.memorial -%} - {%- set days_to.memorial = days -%} - {%- elif days >= 0 and name == 'Labor Day' and days < days_to.labor -%} - {%- set days_to.labor = days -%} + {%- for date_text, names in holiday_dynamic.items() -%} + {%- set event_date = strptime(date_text, '%m/%d/%Y', none) -%} + {%- if event_date is not none -%} + {%- set days = (event_date.date() - today).days -%} + {%- if 'Mardi Gras' in names.split('; ') and days == 0 -%} + {%- set dates.mardi_gras = true -%} + {%- elif 'Chanukkah' in names.split('; ') and -6 <= days <= 1 -%} + {%- set dates.hanukkah = true -%} + {%- endif -%} {%- endif -%} {%- endfor -%} - {%- set christmas = strptime(now().year ~ '-12-25', '%Y-%m-%d') -%} - {%- set christmas_days = ((as_timestamp(christmas) - as_timestamp(today)) / 86400) | int -%} {%- if is_state('sensor.flag', 'True') -%} {%- set mode.value = 'RWB' -%} {%- elif mmdd == 101 -%} {%- set mode.value = 'new_years_day' -%} + {%- elif dates.mardi_gras -%} + {%- set mode.value = 'mardi_gras' -%} {%- elif mmdd >= 210 and mmdd <= 214 -%} {%- set mode.value = 'valentine' -%} - {%- elif mmdd == 305 -%} - {%- set mode.value = 'mardi_gras' -%} {%- elif mmdd == 314 -%} {%- set mode.value = 'pi' -%} {%- elif mmdd >= 315 and mmdd <= 317 -%} {%- set mode.value = 'st_patty' -%} - {%- elif days_to.easter < 4 -%} + {%- elif 0 <= states('sensor.easter_countdown') | int(9999) < 4 -%} {%- set mode.value = 'easter' -%} {%- elif mmdd == 504 -%} {%- set mode.value = 'starwars' -%} {%- elif mmdd == 505 -%} {%- set mode.value = 'cinco_de_mayo' -%} - {%- elif days_to.mothers < 4 -%} + {%- elif 0 <= states('sensor.mothers_countdown') | int(9999) < 4 -%} {%- set mode.value = 'mothers_day' -%} - {%- elif days_to.fathers < 4 -%} + {%- elif 0 <= states('sensor.fathers_countdown') | int(9999) < 4 -%} {%- set mode.value = 'fathers_day' -%} - {%- elif days_to.memorial < 3 -%} + {%- elif 0 <= states('sensor.memorial_day_countdown') | int(9999) < 3 -%} {%- set mode.value = 'RWB' -%} {%- elif mmdd == 704 -%} {%- set mode.value = 'RWB' -%} - {%- elif days_to.labor < 3 -%} + {%- elif 0 <= states('sensor.labor_day_countdown') | int(9999) < 3 -%} {%- set mode.value = 'RWB' -%} {%- elif mmdd >= 1001 and mmdd <= 1031 -%} {%- set mode.value = 'halloween' -%} {%- elif mmdd == 1111 -%} {%- set mode.value = 'veterans' -%} - {%- elif days_to.thanksgiving < 4 -%} + {%- elif 0 <= states('sensor.thanksgiving_day_countdown') | int(9999) < 4 -%} {%- set mode.value = 'thanksgiving' -%} - {%- elif states('sensor.chanukkah_countdown') | int(9999) <= 1 -%} + {%- elif dates.hanukkah -%} {%- set mode.value = 'hanukkah' -%} - {%- elif christmas_days >= 0 and christmas_days <= 25 -%} + {%- elif 0 <= states('sensor.christmas_countdown') | int(9999) <= 25 -%} {%- set mode.value = 'christmas' -%} {%- elif mmdd == 1231 -%} {%- set mode.value = 'new_years_day' -%} @@ -238,4 +202,4 @@ template: mode: "{{ states('sensor.holiday_lighting_mode') }}" holiday: "{{ states('sensor.holiday') }}" flag_day: "{{ states('sensor.flag') }}" - source: config/www/json_data holiday and flag calendars + source: local calendar rules plus config/www/json_data household dates diff --git a/config/shell_scripts/README.md b/config/shell_scripts/README.md index 556243d6..b079805c 100755 --- a/config/shell_scripts/README.md +++ b/config/shell_scripts/README.md @@ -27,6 +27,7 @@ Longer-running shell helpers referenced by automations, packages, or cron. Anyth | File | Why it matters | | --- | --- | | [HAUpdate.sh](HAUpdate.sh) | One-command Home Assistant update helper. | +| [holiday_countdowns.py](holiday_countdowns.py) | Local countdowns and perpetual holiday/flag calendars using weekday rules, python-dateutil for Gregorian Easter, and hdate for 25 Kislev. Reads fixed household dates and optional one-off `dynamic` additions from `www/json_data`; preserves colliding event names. Uses the container's local timezone and installed libraries; no API key or network call. Actual dates are used, rather than substitute workdays. Preview with `python3 holiday_countdowns.py chanukkah --date 2026-10-15`. | | [apt_pending_check.sh](apt_pending_check.sh) | On-demand, read-only APT pending-count reporter for the Docker host maintenance dashboard. | | [apt_weekly.sh](apt_weekly.sh) | Daily APT check-then-update workflow that posts webhook status and can schedule reboot when needed. | | [apt_reboot_report.sh](apt_reboot_report.sh) | Boot-time status reporter that reuses the registered APT webhook, retries while HA starts, and confirms docker_10 again after a startup-settle delay so its co-hosted HA reboot state clears reliably. | @@ -36,6 +37,8 @@ Longer-running shell helpers referenced by automations, packages, or cron. Anyth | [![YAML source: basketball](https://img.shields.io/static/v1?label=YAML&message=basketball&color=lightgrey&logo=github&logoColor=181717)](basketball.yaml) | ESPN stat scraping helper used by sensors. | | [Jinja Code.py](Jinja Code.py) | Reference Jinja snippets for templating. | +The holiday calculator uses actual calendar dates, rather than substitute weekdays for work holidays. In `www/json_data/holidays.json` and `www/json_data/flag_days.json`, `static` maps `M/D` to fixed household dates; `dynamic` accepts optional one-off `MM/DD/YYYY` additions. Floating events come from the calculator and appear in the existing sensor attributes. The older `config/json_data` copies are not read at runtime. Calendar, lighting-boundary, and speech checks run in the existing holiday validation gate. + ### Tips - The TeslaMate snapshot helper reads its private Grafana render URL and authentication data from local secrets. Grafana must have an image renderer configured. The `Tesla Visited Map` Local file camera points to `image/teslamate_visited.png` under the Home Assistant configuration directory. The hourly sensor checks the cache and refreshes after 06:00 America/New_York; its timestamp and `stale`/`last_error` attributes expose refresh health. Cached images and credentials remain outside source control. diff --git a/config/shell_scripts/holiday_countdowns.py b/config/shell_scripts/holiday_countdowns.py new file mode 100644 index 00000000..c9425591 --- /dev/null +++ b/config/shell_scripts/holiday_countdowns.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Calculate household holiday calendars and countdowns without network requests. + +Uses python-dateutil and hdate from the Home Assistant container. The container's +local timezone supplies today's date; --date supports operator previews. +Reads fixed dates and one-off additions from www/json_data; floating dates never +need an annual table update. Chanukkah targets the first daytime date (25 Kislev). +Its first candle-lighting evening is the preceding Gregorian date. +""" + +from __future__ import annotations + +import argparse +import calendar +import json +from datetime import date, datetime, timedelta +from pathlib import Path + +from dateutil.easter import easter + + +def nth_weekday(year: int, month: int, weekday: int, occurrence: int) -> date: + """Return an occurrence of a weekday, with Monday numbered zero.""" + first = date(year, month, 1) + return first + timedelta(days=(weekday - first.weekday()) % 7 + 7 * (occurrence - 1)) + + +def memorial_day(year: int) -> date: + """Return the last Monday in May, rather than an observed substitute date.""" + last = date(year, 5, calendar.monthrange(year, 5)[1]) + return last - timedelta(days=last.weekday()) + + +GREGORIAN_RULES = { + "halloween": lambda year: date(year, 10, 31), + "christmas": lambda year: date(year, 12, 25), + "easter": easter, + "mothers": lambda year: nth_weekday(year, 5, 6, 2), + "fathers": lambda year: nth_weekday(year, 6, 6, 3), + "labor_day": lambda year: nth_weekday(year, 9, 0, 1), + "memorial_day": memorial_day, + "thanksgiving_day": lambda year: nth_weekday(year, 11, 3, 4), +} + +# Actual calendar dates, not substitute weekdays used for employee time off. +HOLIDAY_RULES = { + "MLK Day": lambda year: nth_weekday(year, 1, 0, 3), + "Presidents Day": lambda year: nth_weekday(year, 2, 0, 3), + "Mardi Gras": lambda year: easter(year) - timedelta(days=47), + "Ash Wednesday": lambda year: easter(year) - timedelta(days=46), + "Palm Sunday": lambda year: easter(year) - timedelta(days=7), + "Good Friday": lambda year: easter(year) - timedelta(days=2), + "Easter Sunday": GREGORIAN_RULES["easter"], + "Mothers Day": GREGORIAN_RULES["mothers"], + "Fathers Day": GREGORIAN_RULES["fathers"], + "Memorial Day": GREGORIAN_RULES["memorial_day"], + "Labor Day": GREGORIAN_RULES["labor_day"], + "Columbus Day": lambda year: nth_weekday(year, 10, 0, 2), + "Thanksgiving Day": GREGORIAN_RULES["thanksgiving_day"], +} +FLAG_RULES = { + "Martin Luther King Jr. Day": HOLIDAY_RULES["MLK Day"], + "Washington's Birthday (Presidents Day)": HOLIDAY_RULES["Presidents Day"], + "Armed Forces Day": lambda year: nth_weekday(year, 5, 5, 3), + "Memorial Day": GREGORIAN_RULES["memorial_day"], + "Labor Day": GREGORIAN_RULES["labor_day"], + "National POW/MIA Recognition Day": lambda year: nth_weekday(year, 9, 4, 3), + "Columbus Day": HOLIDAY_RULES["Columbus Day"], + "Election Day": lambda year: nth_weekday(year, 11, 0, 1) + timedelta(days=1), +} + + +def chanukkah_in_year(year: int) -> date: + """Return 25 Kislev falling in the requested Gregorian year.""" + from hdate.hebrew_date import HebrewDate, Months + + hebrew_year = HebrewDate.from_gdate(date(year, 1, 1)).year + target = HebrewDate(hebrew_year, Months.KISLEV, 25).to_gdate() + if target.year < year: + target = HebrewDate(hebrew_year + 1, Months.KISLEV, 25).to_gdate() + return target + + +def calendar_data(kind: str, today: date, data_dir: Path | None = None) -> dict: + """Merge perpetual rules with household fixed dates and one-off additions. + + Attribute names stay compatible with the former REST sensors. Three years + cover next-year lookahead and Hanukkah evenings that cross New Year's Day. + Multiple events sharing a date are retained rather than silently overwritten. + """ + data_dir = data_dir or Path(__file__).resolve().parents[1] / "www" / "json_data" + filename, attribute, rules = ( + ("holidays.json", "MAJOR_US", HOLIDAY_RULES) + if kind == "holiday" + else ("flag_days.json", "Flag_Days_US", FLAG_RULES) + ) + source = json.loads((data_dir / filename).read_text(encoding="utf-8"))[attribute] + events: dict[date, list[str]] = {} + for year in range(today.year - 1, today.year + 2): + for name, rule in rules.items(): + events.setdefault(rule(year), []).append(name) + if kind == "holiday": + events.setdefault(chanukkah_in_year(year), []).append("Chanukkah") + elif year % 4 == 1: + events.setdefault(date(year, 1, 20), []).append("Inauguration Day") + for key, name in source.get("dynamic", {}).items(): + month, day, year = map(int, key.split("/")) + names = events.setdefault(date(year, month, day), []) + if name not in names: + names.append(name) + dynamic = {day.strftime("%m/%d/%Y"): "; ".join(names) for day, names in sorted(events.items())} + fixed_name = source["static"].get(f"{today.month}/{today.day}", "") + event_name = dynamic.get(today.strftime("%m/%d/%Y"), "") + state = (fixed_name or event_name) if kind == "holiday" else str(bool(fixed_name or event_name)) + return {"state": state, attribute: {"static": source["static"], "dynamic": dynamic}} + + +def next_holiday(holiday: str, today: date) -> date: + """Find the next holiday date, including today when it is the holiday.""" + if holiday == "chanukkah": + target = chanukkah_in_year(today.year) + if target < today: + target = chanukkah_in_year(today.year + 1) + return target + + rule = GREGORIAN_RULES[holiday] + target = rule(today.year) + return rule(today.year + 1) if target < today else target + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("holiday", choices=sorted([*GREGORIAN_RULES, "chanukkah", "holiday", "flag"])) + parser.add_argument("--date", type=date.fromisoformat, help="Preview an ISO date") + args = parser.parse_args() + today = args.date or datetime.now().astimezone().date() + if args.holiday in ("holiday", "flag"): + print(json.dumps(calendar_data(args.holiday, today))) + else: + print((next_holiday(args.holiday, today) - today).days) + + +if __name__ == "__main__": + main() diff --git a/config/templates/speech/briefing.yaml b/config/templates/speech/briefing.yaml index 6a3c2561..3501af4e 100755 --- a/config/templates/speech/briefing.yaml +++ b/config/templates/speech/briefing.yaml @@ -10,6 +10,7 @@ # Generates macro prompts for weather, reminders, and AI-driven speech routines. # ------------------------------------------------------------------- # Weather, responsibilities, holidays, air quality, and fact prompts parsed by speech_processing/speech_engine. +# Notes: Holiday countdown zero means today, one means tomorrow; unavailable dates stay silent. # Notes: The nearest Airly CAQI is the primary local reading and stays silent # below medium (50). Pirate Weather US AQI is a safety backstop at 101+. # Notes: Person state `home` means someone is physically at Bear Stone. @@ -292,34 +293,37 @@ {%- endmacro -%} {%- macro holiday() -%} - {% if states('sensor.holiday') != '' %} + {% if has_value('sensor.holiday') and states('sensor.holiday') | trim != '' %} Today is {{ states('sensor.holiday') }}. {% endif %} {%- endmacro -%} {# YOUTUBE VIDEO ********* https://www.vcloudinfo.com/2019/11/adding-days-until-sensor-to-my-home-assistant-speech-routines.html #} {%- macro days_until() -%} - {%- if states('sensor.mothers_countdown') | int(9999)< 20 -%} - and don't forget, there is only {{ states('sensor.mothers_countdown') }} days until Mothers day! - {%- elif states('sensor.fathers_countdown') | int(9999)< 20 -%} - and don't forget, there are {{ states('sensor.fathers_countdown') }} days until Fathers day! - {%- elif states('sensor.easter_countdown') | int(9999)< 15 -%} - and don't forget, there are {{ states('sensor.easter_countdown') }} colorful days until Easter Sunday! - {%- elif states('sensor.thanksgiving_day_countdown') | int(9999)< 10 and states('sensor.thanksgiving_day_countdown') | int(9999)> 0 -%} - and don't forget, there are {{ states('sensor.thanksgiving_day_countdown') }} thankful days until Thanksgiving - {%- elif states('sensor.thanksgiving_day_countdown') | int(9999)< 1 -%} - and don't forget, Thanksgiving is tomorrow! - {%- elif states('sensor.halloween_countdown') | int(9999)< 30 and states('sensor.halloween_countdown') | int(9999)> 0 -%} - and don't forget, there are {{ states('sensor.halloween_countdown') }} Spooky days until Halloween! - {%- elif states('sensor.halloween_countdown') | int(9999)< 1 -%} - and don't forget, Tomorrow is Halloween! - {%- elif states('sensor.chanukkah_countdown') | int(9999)< 15 -%} - and don't forget, there are {{ states('sensor.chanukkah_countdown') }} days until Chanukkah! - {%- elif states('sensor.christmas_countdown') | int(9999)< 30 and states('sensor.christmas_countdown') | int(9999)> 0 -%} - and don't forget, there are {{ states('sensor.christmas_countdown') }} Merry days until Christmas! - {%- elif states('sensor.christmas_countdown') | int(9999)< 1 -%} - and don't forget, Santa Claus is coming tomorrow! - {% endif %} + {%- set reminder = namespace(message='') -%} + {%- for entity, name, limit, adjective in [ + ('sensor.mothers_countdown', 'Mothers Day', 20, ''), + ('sensor.fathers_countdown', 'Fathers Day', 20, ''), + ('sensor.easter_countdown', 'Easter Sunday', 15, 'colorful '), + ('sensor.thanksgiving_day_countdown', 'Thanksgiving', 10, 'thankful '), + ('sensor.halloween_countdown', 'Halloween', 30, 'Spooky '), + ('sensor.chanukkah_countdown', 'Chanukkah', 15, ''), + ('sensor.christmas_countdown', 'Christmas', 30, 'Merry ') + ] -%} + {%- set days = states(entity) | int(9999) -%} + {%- if reminder.message == '' and 0 <= days < limit -%} + {%- if days == 0 -%} + {%- set reminder.message = 'Today is ' ~ name ~ '!' -%} + {%- elif days == 1 -%} + {%- set reminder.message = name ~ ' is tomorrow!' -%} + {%- else -%} + {%- set reminder.message = 'there are ' ~ days ~ ' ' ~ adjective ~ 'days until ' ~ name ~ '!' -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {%- if reminder.message != '' %} + and don't forget, {{ reminder.message }} + {% endif -%} {%- endmacro -%} {# friendly duration/location helper #} diff --git a/config/travis_secrets.yaml b/config/travis_secrets.yaml index 43316307..159e048b 100755 --- a/config/travis_secrets.yaml +++ b/config/travis_secrets.yaml @@ -78,15 +78,6 @@ cloud_user_pool: 'SuperSecretPlace' cloud_relayer: 'SuperSecretWebThingy' vantage_api: LONGAPIKEYFORFREE minecraft: private_server.com -wolframalpha_halloween_api: https://api.wolframalpha.com/v2/result?appid=JIUY8U-4V8KY45VT1&i=How%20many%20days%20until%20halloween -wolframalpha_xmas_api: https://api.wolframalpha.com/v2/result?appid=JIUY8U-4V8KY45VT1&i=How%20many%20days%20until%20christmas -wolframalpha_easter_api: https://api.wolframalpha.com/v2/result?appid=JIUY8U-4V8KY45VT1&i=How%20many%20days%20until%20easter -wolframalpha_mothersday_api: https://api.wolframalpha.com/v2/result?appid=JIUY8U-4V8KY45VT1&i=How%20many%20days%20until%20mothers%20day -wolframalpha_fathersday_api: https://api.wolframalpha.com/v2/result?appid=JIUY8U-4V8KY45VT1&i=How%20many%20days%20until%20fathers%20day -wolframalpha_chanukkah_api: https://api.wolframalpha.com/v2/result?appid=JIUY8U-4V8KY45VT1&i=How%20many%20days%20until%20chanukkah -wolframalpha_labor_api: https://api.wolframalpha.com/v2/result?appid=JIUY8U-4V8KY45VT1&i=How%20many%20days%20until%labor%20day -wolframalpha_memorial_api: https://api.wolframalpha.com/v2/result?appid=JIUY8U-4V8KY45VT1&i=How%20many%20days%20until%20memorial -wolframalpha_thanksgiving_api: https://api.wolframalpha.com/v2/result?appid=JIUY8U-4V8KY45VT1&i=How%20many%20days%20until%20thanksgiving bearclaw_status_url: http://192.168.10.13:8124/api/bearclaw/status bgw_probe_status_url: http://127.0.0.1:8181/api/v1/status bearclaw_duplicati_verify_url: http://192.168.10.13:8124/api/admin/actions/duplicati-verify diff --git a/config/www/json_data/flag_days.json b/config/www/json_data/flag_days.json index 05972e3d..31862a88 100644 --- a/config/www/json_data/flag_days.json +++ b/config/www/json_data/flag_days.json @@ -12,71 +12,6 @@ "11/11": "Veterans Day", "12/7": "Pearl Harbor Remembrance Day" }, - "dynamic": { - "01/20/2025": "Inauguration Day", - "01/16/2023": "Martin Luther King Jr. Day", - "01/15/2024": "Martin Luther King Jr. Day", - "01/19/2026": "Martin Luther King Jr. Day", - "01/18/2027": "Martin Luther King Jr. Day", - "01/17/2028": "Martin Luther King Jr. Day", - "01/15/2029": "Martin Luther King Jr. Day", - "01/21/2030": "Martin Luther King Jr. Day", - "02/20/2023": "Washington's Birthday (Presidents Day)", - "02/19/2024": "Washington's Birthday (Presidents Day)", - "02/17/2025": "Washington's Birthday (Presidents Day)", - "02/16/2026": "Washington's Birthday (Presidents Day)", - "02/15/2027": "Washington's Birthday (Presidents Day)", - "02/21/2028": "Washington's Birthday (Presidents Day)", - "02/19/2029": "Washington's Birthday (Presidents Day)", - "02/18/2030": "Washington's Birthday (Presidents Day)", - "05/20/2023": "Armed Forces Day", - "05/18/2024": "Armed Forces Day", - "05/17/2025": "Armed Forces Day", - "05/16/2026": "Armed Forces Day", - "05/15/2027": "Armed Forces Day", - "05/20/2028": "Armed Forces Day", - "05/19/2029": "Armed Forces Day", - "05/18/2030": "Armed Forces Day", - "05/29/2023": "Memorial Day", - "05/27/2024": "Memorial Day", - "05/26/2025": "Memorial Day", - "05/25/2026": "Memorial Day", - "05/31/2027": "Memorial Day", - "05/29/2028": "Memorial Day", - "05/28/2029": "Memorial Day", - "05/27/2030": "Memorial Day", - "09/04/2023": "Labor Day", - "09/02/2024": "Labor Day", - "09/01/2025": "Labor Day", - "09/07/2026": "Labor Day", - "09/06/2027": "Labor Day", - "09/04/2028": "Labor Day", - "09/03/2029": "Labor Day", - "09/02/2030": "Labor Day", - "09/15/2023": "National POW/MIA Recognition Day", - "09/20/2024": "National POW/MIA Recognition Day", - "09/19/2025": "National POW/MIA Recognition Day", - "09/18/2026": "National POW/MIA Recognition Day", - "09/17/2027": "National POW/MIA Recognition Day", - "09/15/2028": "National POW/MIA Recognition Day", - "09/21/2029": "National POW/MIA Recognition Day", - "09/20/2030": "National POW/MIA Recognition Day", - "10/09/2023": "Columbus Day", - "10/14/2024": "Columbus Day", - "10/13/2025": "Columbus Day", - "10/12/2026": "Columbus Day", - "10/11/2027": "Columbus Day", - "10/09/2028": "Columbus Day", - "10/08/2029": "Columbus Day", - "10/14/2030": "Columbus Day", - "11/07/2023": "Election Day", - "11/05/2024": "Election Day", - "11/04/2025": "Election Day", - "11/03/2026": "Election Day", - "11/02/2027": "Election Day", - "11/07/2028": "Election Day", - "11/06/2029": "Election Day", - "11/05/2030": "Election Day" - } + "dynamic": {} } } diff --git a/config/www/json_data/holidays.json b/config/www/json_data/holidays.json index 6a3992c8..2d990f05 100644 --- a/config/www/json_data/holidays.json +++ b/config/www/json_data/holidays.json @@ -25,92 +25,6 @@ "12/25": "Christmas Day", "12/31": "New Years Eve" }, - "dynamic": { - "11/23/2023": "Thanksgiving Day", - "01/15/2024": "MLK Day", - "02/14/2024": "Ash Wednesday", - "02/19/2024": "Presidents Day", - "03/24/2024": "Palm Sunday", - "03/29/2024": "Good Friday", - "03/31/2024": "Easter Sunday", - "04/22/2024": "Memorial Day", - "05/12/2024": "Mothers Day", - "06/16/2024": "Fathers Day", - "09/02/2024": "Labor Day", - "10/14/2024": "Columbus Day", - "11/28/2024": "Thanksgiving Day", - "01/20/2025": "MLK Day", - "02/17/2025": "Presidents Day", - "03/05/2025": "Ash Wednesday", - "04/13/2025": "Palm Sunday", - "04/18/2025": "Good Friday", - "04/20/2025": "Easter Sunday", - "04/21/2025": "Memorial Day", - "05/11/2025": "Mothers Day", - "06/15/2025": "Fathers Day", - "09/01/2025": "Labor Day", - "10/13/2025": "Columbus Day", - "11/27/2025": "Thanksgiving Day", - "01/19/2026": "MLK Day", - "02/16/2026": "Presidents Day", - "02/18/2026": "Ash Wednesday", - "03/29/2026": "Palm Sunday", - "04/03/2026": "Good Friday", - "04/05/2026": "Easter Sunday", - "04/20/2026": "Memorial Day", - "05/10/2026": "Mothers Day", - "06/21/2026": "Fathers Day", - "09/07/2026": "Labor Day", - "10/12/2026": "Columbus Day", - "11/26/2026": "Thanksgiving Day", - "01/18/2027": "MLK Day", - "02/10/2027": "Ash Wednesday", - "02/15/2027": "Presidents Day", - "03/21/2027": "Palm Sunday", - "03/26/2027": "Good Friday", - "03/28/2027": "Easter Sunday", - "04/19/2027": "Memorial Day", - "05/09/2027": "Mothers Day", - "06/20/2027": "Fathers Day", - "09/06/2027": "Labor Day", - "10/11/2027": "Columbus Day", - "11/25/2027": "Thanksgiving Day", - "01/17/2028": "MLK Day", - "02/21/2028": "Presidents Day", - "03/01/2028": "Ash Wednesday", - "04/09/2028": "Palm Sunday", - "04/14/2028": "Good Friday", - "04/16/2028": "Easter Sunday", - "04/17/2028": "Memorial Day", - "05/14/2028": "Mothers Day", - "06/18/2028": "Fathers Day", - "09/04/2028": "Labor Day", - "10/09/2028": "Columbus Day", - "11/23/2028": "Thanksgiving Day", - "01/15/2029": "MLK Day", - "02/14/2029": "Ash Wednesday", - "02/19/2029": "Presidents Day", - "03/25/2029": "Palm Sunday", - "03/30/2029": "Good Friday", - "04/01/2029": "Easter Sunday", - "04/23/2029": "Memorial Day", - "05/13/2029": "Mothers Day", - "06/17/2029": "Fathers Day", - "09/03/2029": "Labor Day", - "10/08/2029": "Columbus Day", - "11/22/2029": "Thanksgiving Day", - "01/21/2030": "MLK Day", - "02/18/2030": "Presidents Day", - "03/06/2030": "Ash Wednesday", - "04/14/2030": "Palm Sunday", - "04/19/2030": "Good Friday", - "04/21/2030": "Easter Sunday", - "04/22/2030": "Memorial Day", - "05/12/2030": "Mothers Day", - "06/16/2030": "Fathers Day", - "09/02/2030": "Labor Day", - "10/14/2030": "Columbus Day", - "11/28/2030": "Thanksgiving Day" - } + "dynamic": {} } } diff --git a/requirements-dev.txt b/requirements-dev.txt index f62ce0c5..5caeb2af 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,4 @@ PyYAML==6.0.3 +python-dateutil==2.9.0.post0 +hdate==1.2.2 +Jinja2==3.1.6