From 93b28be846b67ce204b90be7b713617941437d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 21 Aug 2026 18:23:53 +0700 Subject: [PATCH] Update the blueprints to include the current time context and add a tool for blueprint validation --- README.md | 7 +- README_vi.md | 7 +- calendar_events_lookup_full_llm.yaml | 117 ++++++-- camera_snapshot_full_llm.yaml | 5 +- create_calendar_event_full_llm.yaml | 41 ++- date_lookup_and_conversion_full_llm.yaml | 5 +- device_location_lookup_full_llm.yaml | 74 +++--- devices_schedules_controller_full_llm.yaml | 5 +- memory_tool_full_llm.yaml | 5 +- pyproject.toml | 2 +- tools/validate_blueprints.py | 295 +++++++++++++++++++++ traffic_fine_lookup_full_llm.yaml | 5 +- weather_forecast_full_llm.yaml | 110 +++++--- 13 files changed, 560 insertions(+), 118 deletions(-) create mode 100644 tools/validate_blueprints.py diff --git a/README.md b/README.md index 4bda736..83a2b1d 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # Unique Home Assistant Blueprints & Tutorials -**[ 🇺🇸 English | [🇻🇳 Tiếng Việt](README_vi.md) ]** +**🇺🇸 English | [🇻🇳 Tiếng Việt](README_vi.md)** > [!TIP] > **[Blueprints Updater](https://github.com/luuquangvu/blueprints-updater)**: A highly useful integration that automatically updates blueprints in this collection. When you have many blueprints from different sources, keeping track of updates can be challenging - this integration handles it for you automatically. -**Google has recently significantly cut back on the free Gemini API, making it almost impossible to meet the usage needs of Home Assistant. You can find [a completely free alternative solution here](https://github.com/luuquangvu/ha-addons).** + + +> [!NOTE] +> **Google has recently significantly cut back on the free Gemini API, making it almost impossible to meet the usage needs of Home Assistant. You can find [a completely free alternative solution here](https://github.com/luuquangvu/ha-addons).** _All blueprints in this collection are fine-tuned to work best with **Gemini Flash** models. Other models may require minor adjustments to behave as expected._ diff --git a/README_vi.md b/README_vi.md index 1fb634d..d5a710c 100644 --- a/README_vi.md +++ b/README_vi.md @@ -1,11 +1,14 @@ # Bộ sưu tập Blueprint và Hướng dẫn độc đáo cho Home Assistant -**[ [🇺🇸 English](README.md) | 🇻🇳 Tiếng Việt ]** +**[🇺🇸 English](README.md) | 🇻🇳 Tiếng Việt** > [!TIP] > **[Blueprints Updater](https://github.com/luuquangvu/blueprints-updater)**: Một tích hợp cực kỳ hữu ích giúp tự động cập nhật các blueprint trong bộ sưu tập này. Khi bạn cài đặt nhiều blueprint từ nhiều nguồn khác nhau, việc theo dõi cập nhật từ các tác giả trở nên khó khăn - tích hợp này sẽ giải quyết vấn đề đó cho bạn hoàn toàn tự động. -**Gần đây, Google đã cắt giảm đáng kể API Gemini miễn phí, khiến nó gần như không thể đáp ứng nhu cầu sử dụng của Home Assistant. Các bạn có thể tham khảo [một giải pháp thay thế hoàn toàn miễn phí tại đây](https://github.com/luuquangvu/ha-addons).** + + +> [!NOTE] +> **Gần đây, Google đã cắt giảm đáng kể API Gemini miễn phí, khiến nó gần như không thể đáp ứng nhu cầu sử dụng của Home Assistant. Các bạn có thể tham khảo [một giải pháp thay thế hoàn toàn miễn phí tại đây](https://github.com/luuquangvu/ha-addons).** _Tất cả blueprint trong bộ sưu tập này được tinh chỉnh để hoạt động tối ưu với các mô hình (model) **Gemini Flash**. Các mô hình ngôn ngữ khác có thể cần điều chỉnh nhỏ để đạt hiệu quả tương tự._ diff --git a/calendar_events_lookup_full_llm.yaml b/calendar_events_lookup_full_llm.yaml index c6ce282..cfdcd55 100644 --- a/calendar_events_lookup_full_llm.yaml +++ b/calendar_events_lookup_full_llm.yaml @@ -51,7 +51,7 @@ blueprint: text: multiline: true default: |- - daily or hourly + daily or hourly. Default daily time_period_length_prompt: name: Time Period Length Prompt description: The prompt used by the AI to identify the period length [Human-only] @@ -59,7 +59,7 @@ blueprint: text: multiline: true default: |- - Length in days (daily) or hours (hourly) + Length in days (daily) or hours (hourly). Default 1 date_prompt: name: Date Prompt description: The prompt used by the AI to identify the starting date [Human-only] @@ -75,12 +75,12 @@ blueprint: text: multiline: true default: |- - Start time (HH:MM:SS) + Start time (HH:MM:SS). Default 00:00:00 for daily, current time for hourly mode: parallel max_exceeded: silent description: Retrieves upcoming or past calendar events for a specific period variables: - version: 20260818 + version: 20260821 fields: time_period_type: name: Time Period Type @@ -90,7 +90,7 @@ fields: options: - daily - hourly - required: true + default: daily time_period_length: name: Time Period Length description: !input time_period_length_prompt @@ -98,31 +98,48 @@ fields: number: min: 1 max: 120 - required: true + default: 1 start_date: name: Start Date description: !input date_prompt selector: date: - required: true start_time: name: Start Time description: !input time_prompt selector: time: - required: true sequence: - variables: - start_date: "{{ start_date | as_datetime(default='') }}" - start_time: "{{ start_time | default('00:00:00') | as_timedelta | default('00:00:00', true) }}" + _period_type: "{{ time_period_type if (time_period_type | default) in ['daily', 'hourly'] else 'daily' }}" + _add_length: "{{ (time_period_length | default(1) | float(1)) | abs }}" + _start_date_raw: "{{ start_date | default('', true) | trim }}" + _start_time_raw: "{{ start_time | default('', true) | trim }}" + _start_date_val: >- + {% if _start_date_raw != '' %} + {% set parsed = _start_date_raw | as_datetime(default=none) %} + {{ (parsed.date() | string) if parsed is not none else _start_date_raw }} + {% else %} + {{ now().strftime('%Y-%m-%d') }} + {% endif %} + _start_time_val: >- + {% if _start_time_raw != '' %} + {{ _start_time_raw }} + {% elif _period_type == 'hourly' and (_start_date_val == now().strftime('%Y-%m-%d')) %} + {{ now().strftime('%H:%M:%S') }} + {% else %} + 00:00:00 + {% endif %} start: >- - {{ ((start_date | as_datetime + as_timedelta(start_time)) | as_local) if start_date else 'n/a' }} + {% set dt_str = _start_date_val ~ ' ' ~ _start_time_val %} + {% set dt = dt_str | as_datetime(default=none) %} + {{ (dt | as_local).isoformat() if dt is not none else 'n/a' }} end: >- {% if start != 'n/a' %} - {% set start = as_datetime(start) %} - {% set add = (time_period_length | default(1) | float(1)) | abs %} - {% set type = time_period_type if (time_period_type | default) in ['daily', 'hourly'] else 'daily' %} - {{ (start + timedelta(days = add if type == 'daily' else 0, hours = add if type == 'hourly' else 0)) | as_local }} + {% set start_dt = as_datetime(start) %} + {{ (start_dt + timedelta(days = _add_length if _period_type == 'daily' else 0, hours = _add_length if _period_type == 'hourly' else 0)) | as_local | string }} + {% else %} + n/a {% endif %} - alias: Check if variables were set correctly if: @@ -147,13 +164,67 @@ sequence: start_date_time: "{{ start }}" end_date_time: "{{ end }}" - variables: - response: - events: >- - {{ - response.values() - | map(attribute='events') - | sum(start=[]) - | sort(attribute='start') - }} + response: >- + {% set raw_events = response.values() | map(attribute='events') | sum(start=[]) | sort(attribute='start') %} + {% set enriched = namespace(events=[]) %} + {% for ev in raw_events %} + {% set s_str = ev.start | string %} + {% set is_all_day = ('T' not in s_str) and (' ' not in s_str) %} + {% set s_dt = (ev.start | as_datetime | as_local) if (ev.start | as_datetime(default=none)) is not none else none %} + {% set e_dt = (ev.end | as_datetime | as_local) if (ev.end | as_datetime(default=none)) is not none else none %} + {% if s_dt is not none %} + {% set rel = ('in ' ~ time_until(s_dt)) if s_dt >= now() else (time_since(s_dt) ~ ' ago') %} + {% else %} + {% set rel = '' %} + {% endif %} + {% if is_all_day %} + {% if s_dt is not none and e_dt is not none %} + {% set d_days = (e_dt.date() - s_dt.date()).days %} + {% set dur = 'all day' if d_days <= 1 else (d_days ~ ' days') %} + {% else %} + {% set dur = 'all day' %} + {% endif %} + {% else %} + {% if s_dt is not none and e_dt is not none %} + {% set dur_sec = (e_dt - s_dt).total_seconds() | int(0) %} + {% set dur_h = dur_sec // 3600 %} + {% set dur_m = (dur_sec % 3600) // 60 %} + {% if dur_h > 0 and dur_m > 0 %} + {% set dur = dur_h ~ ' hr ' ~ dur_m ~ ' min' %} + {% elif dur_h > 0 %} + {% set dur = dur_h ~ (' hr' if dur_h == 1 else ' hrs') %} + {% elif dur_m > 0 %} + {% set dur = dur_m ~ ' min' %} + {% else %} + {% set dur = dur_sec ~ ' sec' %} + {% endif %} + {% else %} + {% set dur = '' %} + {% endif %} + {% endif %} + {% set item = dict(ev, + weekday=(s_dt.strftime('%A') if s_dt is not none else ''), + is_all_day=is_all_day, + relative_time=rel, + duration=dur + ) %} + {% set enriched.events = enriched.events + [item] %} + {% endfor %} + {{ + dict( + current_context=dict( + current_time=now().strftime('%A, %Y-%m-%d %H:%M:%S %z'), + timezone=now().tzinfo | string + ), + queried_period=dict( + start=start, + end=end, + type=_period_type, + length=_add_length + ), + events_count=enriched.events | length, + events=enriched.events + ) + }} - stop: "" response_variable: response diff --git a/camera_snapshot_full_llm.yaml b/camera_snapshot_full_llm.yaml index c805b92..bbb5416 100644 --- a/camera_snapshot_full_llm.yaml +++ b/camera_snapshot_full_llm.yaml @@ -114,7 +114,7 @@ mode: parallel max_exceeded: silent description: Captures a camera snapshot or records a video and returns the local file path variables: - version: 20260818 + version: 20260821 fields: camera_name: name: Camera Name @@ -278,5 +278,8 @@ sequence: - variables: response: error: "Unsupported capture type: {{ capture_type }}" + - variables: + response: >- + {{ dict(response, captured_at=now().strftime('%Y-%m-%d %H:%M:%S %z')) if response is mapping else response }} - stop: "" response_variable: response diff --git a/create_calendar_event_full_llm.yaml b/create_calendar_event_full_llm.yaml index ddd8ad0..0fd286d 100644 --- a/create_calendar_event_full_llm.yaml +++ b/create_calendar_event_full_llm.yaml @@ -87,7 +87,7 @@ blueprint: text: multiline: true default: |- - End date/time (YYYY-MM-DD HH:MM:SS) + End date/time (YYYY-MM-DD HH:MM:SS). Optional, default start + 1h start_date_prompt: name: Start Date Prompt description: The starting date for all-day events (YYYY-MM-DD), provided by the AI @@ -103,13 +103,13 @@ blueprint: text: multiline: true default: |- - End date (YYYY-MM-DD) + End date (YYYY-MM-DD). Optional, default start date mode: queued max: !input simultaneous max_exceeded: silent description: Adds a new event to a specified calendar variables: - version: 20260409 + version: 20260821 fields: summary: selector: @@ -148,9 +148,31 @@ sequence: summary: "{{ summary | default('') | trim }}" description: "{{ description | default('') | trim }}" start_date_time: "{{ start_date_time | default('') | trim }}" - end_date_time: "{{ end_date_time | default('') | trim }}" start_date: "{{ start_date | default('') | trim }}" - end_date: "{{ end_date | default('') | trim }}" + end_date_time: >- + {% if end_date_time | default('') | trim != '' %} + {{ end_date_time | trim }} + {% elif start_date_time | default('') | trim != '' %} + {% set sdt = start_date_time | as_datetime(default=none) %} + {{ ((sdt + timedelta(hours=1)) | as_local).isoformat() if sdt is not none else '' }} + {% else %} + '' + {% endif %} + end_date: >- + {% set sd = start_date | as_datetime(default=none) %} + {% set ed_raw = end_date | default('', true) | trim %} + {% if ed_raw != '' %} + {% set ed = ed_raw | as_datetime(default=none) %} + {% if sd is not none and ed is not none and sd.date() == ed.date() %} + {{ ((ed + timedelta(days=1)) | as_local).date() | string }} + {% else %} + {{ ed_raw }} + {% endif %} + {% elif sd is not none %} + {{ ((sd + timedelta(days=1)) | as_local).date() | string }} + {% else %} + '' + {% endif %} calendar: !input calendar - alias: Check if variables were set correctly if: @@ -304,7 +326,14 @@ sequence: variables: response: message: >- - Event created: {{ summary }} ({{ (start_date | as_datetime).date() }} to {{ (end_date | as_datetime).date() }}) in {{ state_attr(calendar, 'friendly_name') or calendar }}. + {% set s_date = (start_date | as_datetime).date() %} + {% set e_date = (end_date | as_datetime).date() %} + {% set diff_days = (e_date - s_date).days %} + {% if diff_days <= 1 %} + Event created: {{ summary }} (all day on {{ s_date }}) in {{ state_attr(calendar, 'friendly_name') or calendar }}. + {% else %} + Event created: {{ summary }} ({{ s_date }} to {{ (e_date - timedelta(days=1)) }}) in {{ state_attr(calendar, 'friendly_name') or calendar }}. + {% endif %} - stop: "" response_variable: response default: diff --git a/date_lookup_and_conversion_full_llm.yaml b/date_lookup_and_conversion_full_llm.yaml index 4a5dbec..b593e84 100644 --- a/date_lookup_and_conversion_full_llm.yaml +++ b/date_lookup_and_conversion_full_llm.yaml @@ -56,7 +56,7 @@ blueprint: text: multiline: true default: |- - Date (YYYY-MM-DD). If no year, use current + Date (YYYY-MM-DD). Optional, defaults to today if omitted. leap_month_prompt: name: Leap Month Prompt description: Indicates whether the Lunar date is in a leap month, determined by the AI @@ -69,7 +69,7 @@ mode: parallel max_exceeded: silent description: Converts dates between Solar and Lunar calendars variables: - version: 20260409 + version: 20260821 fields: conversion_type: name: Conversion Type @@ -85,7 +85,6 @@ fields: description: !input date_prompt selector: date: - required: true leap_month: name: Leap Month description: !input leap_month_prompt diff --git a/device_location_lookup_full_llm.yaml b/device_location_lookup_full_llm.yaml index cf81d88..12dd7a7 100644 --- a/device_location_lookup_full_llm.yaml +++ b/device_location_lookup_full_llm.yaml @@ -87,7 +87,7 @@ mode: parallel max_exceeded: silent description: Locates devices (phones, tags, etc.) within the home variables: - version: 20260818 + version: 20260821 fields: entities: name: Entities @@ -122,40 +122,48 @@ sequence: response_variable: response - variables: response: - devices: |- + current_time: "{{ now().strftime('%Y-%m-%d %H:%M:%S %z') }}" + devices: >- {% set mobile_devices = integration_entities('mobile_app') | select('match','device_tracker\.') | map('regex_replace', 'device_tracker\.','') | list -%} + {% set device_list = namespace(items=[]) %} {% for entity in entities.split(';') -%} - {% set device = namespace(friendly_name=none, entity_id=none, notify_id=none, is_mobile=false, at_home=false) -%} - {% set device.friendly_name = entity.strip() -%} - {% set bermuda_matches = integration_entities('bermuda') | select('match','^device_tracker\.') | select('is_state_attr', 'friendly_name', device.friendly_name) | list -%} - {% set mobile_matches = integration_entities('mobile_app') | select('match','^device_tracker\.') | select('is_state_attr', 'friendly_name', device.friendly_name) | list -%} - {% set alias_matches = state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^device_tracker\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', device.friendly_name) | map(attribute='entity_id') | list -%} - {% if bermuda_matches -%} - {% set device.entity_id = bermuda_matches[0] -%} - {% elif mobile_matches -%} - {% set device.entity_id = mobile_matches[0] -%} - {% set device.is_mobile = true -%} - {% set device.notify_id = device.entity_id.split('device_tracker.')[1] -%} - {% elif alias_matches -%} - {% set device.entity_id = alias_matches[0] -%} - {% endif -%} - {% if device.entity_id and not device.is_mobile -%} - {% set parts = device.entity_id.split('device_tracker.')[1].split('_') -%} - {% for i in range(1, (parts | length) + 1) -%} - {% if (parts[:i] | join('_')) in mobile_devices -%} - {% set device.is_mobile = true -%} - {% set device.notify_id = parts[:i] | join('_') -%} - {% endif -%} - {% endfor -%} - {% endif -%} - {% if device.entity_id -%} - {% set device.at_home = is_state(device.entity_id, 'home') -%} - {% endif -%} - - friendly_name: {{ device.friendly_name }} - at_home: {{ device.at_home }} - area: {{ state_attr(device.entity_id, 'area') if device.entity_id else none }} - can_ring: {{ device.is_mobile and device.at_home }} - ring_id: {{ ('notify.mobile_app_' ~ device.notify_id) if (device.is_mobile and device.at_home) else none }} + {% set device = namespace(friendly_name=none, entity_id=none, notify_id=none, is_mobile=false, at_home=false) -%} + {% set device.friendly_name = entity.strip() -%} + {% set bermuda_matches = integration_entities('bermuda') | select('match','^device_tracker\.') | select('is_state_attr', 'friendly_name', device.friendly_name) | list -%} + {% set mobile_matches = integration_entities('mobile_app') | select('match','^device_tracker\.') | select('is_state_attr', 'friendly_name', device.friendly_name) | list -%} + {% set alias_matches = state_attr(entity_aliases, 'entities') | default([], true) | selectattr('entity_id', 'defined') | selectattr('entity_id', 'match', '^device_tracker\.') | selectattr('aliases', 'defined') | selectattr('aliases', 'contains', device.friendly_name) | map(attribute='entity_id') | list -%} + {% if bermuda_matches -%} + {% set device.entity_id = bermuda_matches[0] -%} + {% elif mobile_matches -%} + {% set device.entity_id = mobile_matches[0] -%} + {% set device.is_mobile = true -%} + {% set device.notify_id = device.entity_id.split('device_tracker.')[1] -%} + {% elif alias_matches -%} + {% set device.entity_id = alias_matches[0] -%} + {% endif -%} + {% if device.entity_id and not device.is_mobile -%} + {% set parts = device.entity_id.split('device_tracker.')[1].split('_') -%} + {% for i in range(1, (parts | length) + 1) -%} + {% if (parts[:i] | join('_')) in mobile_devices -%} + {% set device.is_mobile = true -%} + {% set device.notify_id = parts[:i] | join('_') -%} + {% endif -%} + {% endfor -%} + {% endif -%} + {% if device.entity_id -%} + {% set device.at_home = is_state(device.entity_id, 'home') -%} + {% endif -%} + {% set last_up = states[device.entity_id].last_updated if (device.entity_id and states[device.entity_id] is defined) else none %} + {% set item = dict( + friendly_name=device.friendly_name, + at_home=device.at_home, + area=state_attr(device.entity_id, 'area') if device.entity_id else none, + last_seen_relative=(time_since(last_up) ~ ' ago') if last_up is not none else none, + can_ring=device.is_mobile and device.at_home, + ring_id=('notify.mobile_app_' ~ device.notify_id) if (device.is_mobile and device.at_home) else none + ) %} + {% set device_list.items = device_list.items + [item] %} {% endfor -%} + {{ device_list.items }} - stop: "" response_variable: response diff --git a/devices_schedules_controller_full_llm.yaml b/devices_schedules_controller_full_llm.yaml index d819eb0..5d18f78 100644 --- a/devices_schedules_controller_full_llm.yaml +++ b/devices_schedules_controller_full_llm.yaml @@ -149,7 +149,7 @@ mode: parallel max_exceeded: silent description: Manages device timers (start, cancel, pause, resume, list) variables: - version: 20260409 + version: 20260821 fields: mode: name: Mode @@ -757,5 +757,8 @@ sequence: - stop: "Unsupported mode requested: {{ _mode }}" response_variable: response + - variables: + response: >- + {{ dict(response, current_time=now().strftime('%Y-%m-%d %H:%M:%S %z')) if response is mapping else response }} - stop: "" response_variable: response diff --git a/memory_tool_full_llm.yaml b/memory_tool_full_llm.yaml index be41462..55d6cd1 100644 --- a/memory_tool_full_llm.yaml +++ b/memory_tool_full_llm.yaml @@ -189,7 +189,7 @@ mode: parallel max_exceeded: silent description: Saves, retrieves, searches, or deletes persistent notes and facts reliably variables: - version: 20260414 + version: 20260821 sequence: - variables: op: "{{ operation | default('set') }}" @@ -300,5 +300,8 @@ sequence: status: "error" error: "invalid_operation" message: "Invalid operation. Use one of: set, get, search, forget." + - variables: + response: >- + {{ dict(response, current_time=now().strftime('%Y-%m-%d %H:%M:%S %z')) if response is mapping else response }} - stop: "" response_variable: response diff --git a/pyproject.toml b/pyproject.toml index 2e7fece..1ecf7a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dev = [ "beautifulsoup4", "curl-cffi", "google-api-python-client", - "homeassistant>=2026.6.0", + "homeassistant>=2026.8.0", "httpx[http2]", "ruff", "ty", diff --git a/tools/validate_blueprints.py b/tools/validate_blueprints.py new file mode 100644 index 0000000..5c32339 --- /dev/null +++ b/tools/validate_blueprints.py @@ -0,0 +1,295 @@ +"""Home Assistant Blueprint & Jinja2 Syntax Validator. + +Directly leverages Home Assistant Core's native validation engines: +1. Native YAML loader (`homeassistant.util.yaml.loader.load_yaml`) for custom tags. +2. Official domain schemas (`AUTOMATION_BLUEPRINT_SCHEMA`, `TEMPLATE_BLUEPRINT_SCHEMA`, `BLUEPRINT_SCHEMA`). +3. Official selector validation (`homeassistant.helpers.selector.validate_selector`). +4. Official template engine (`homeassistant.helpers.template.TemplateEnvironment`) for all HA filters/tests. +5. Input reference cross-validation against defined blueprint inputs. +""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +import jinja2 +import voluptuous as vol +from homeassistant.components.automation.config import AUTOMATION_BLUEPRINT_SCHEMA +from homeassistant.components.blueprint.errors import InvalidBlueprint +from homeassistant.components.blueprint.models import Blueprint +from homeassistant.components.blueprint.schemas import BLUEPRINT_SCHEMA +from homeassistant.helpers.selector import validate_selector +from homeassistant.helpers.template import TemplateEnvironment +from homeassistant.util.yaml.loader import load_yaml +from homeassistant.util.yaml.objects import Input + +try: + from homeassistant.components.template.config import TEMPLATE_BLUEPRINT_SCHEMA +except ImportError: + TEMPLATE_BLUEPRINT_SCHEMA = None + + +def get_blueprint_schema(domain: str) -> vol.Schema | vol.All: + """Return the appropriate Home Assistant blueprint schema for a given domain.""" + if domain == "automation": + return AUTOMATION_BLUEPRINT_SCHEMA + if domain == "template": + if isinstance(TEMPLATE_BLUEPRINT_SCHEMA, vol.Schema): + return TEMPLATE_BLUEPRINT_SCHEMA + return BLUEPRINT_SCHEMA + return BLUEPRINT_SCHEMA + + +def extract_defined_inputs(input_dict: Any) -> set[str]: + """Extract all input keys defined in blueprint.input (including nested sections).""" + keys: set[str] = set() + if not isinstance(input_dict, Mapping): + return keys + + for k, v in input_dict.items(): + if isinstance(k, str): + keys.add(k) + if isinstance(v, Mapping) and "input" in v and isinstance(v["input"], Mapping): + keys.update(extract_defined_inputs(v["input"])) + return keys + + +def validate_selectors_in_inputs(input_dict: Any, path: str = "blueprint.input") -> list[str]: + """Validate all selector definitions within blueprint inputs.""" + errors: list[str] = [] + if not isinstance(input_dict, Mapping): + return errors + + for k, v in input_dict.items(): + curr_path = f"{path}.{k}" + if isinstance(v, Mapping): + if "input" in v and isinstance(v["input"], Mapping): + errors.extend(validate_selectors_in_inputs(v["input"], curr_path)) + elif "selector" in v: + sel = v.get("selector") + if isinstance(sel, dict) and sel: + try: + validate_selector(sel) + except vol.Invalid as e: + errors.append(f"Invalid selector at '{curr_path}.selector': {e}") + except Exception as e: + errors.append(f"Selector validation error at '{curr_path}.selector': {e}") + return errors + + +def extract_used_inputs(obj: Any) -> list[str]: + """Recursively find all !input references in the parsed structure.""" + used: list[str] = [] + if isinstance(obj, Input): + used.append(obj.name) + elif isinstance(obj, Mapping): + for k, v in obj.items(): + if isinstance(k, Input): + used.append(k.name) + elif not isinstance(k, (str, bytes, bytearray)): + used.extend(extract_used_inputs(k)) + used.extend(extract_used_inputs(v)) + elif isinstance(obj, Sequence) and not isinstance(obj, (str, bytes, bytearray)): + for item in obj: + used.extend(extract_used_inputs(item)) + return used + + +def validate_jinja_in_obj( + obj: Any, + env: TemplateEnvironment, + path: str = "root", + *, + skip_blueprint_metadata: bool = False, +) -> list[str]: + """Recursively inspect and parse all Jinja2 template expressions using Home Assistant's TemplateEnvironment.""" + errors: list[str] = [] + + if isinstance(obj, str): + if "{{" in obj or "{%" in obj or "{#" in obj: + try: + env.parse(obj) + except jinja2.TemplateSyntaxError as e: + errors.append(f"Jinja2 syntax error in '{path}' at line {e.lineno}: {e.message}") + except Exception as e: + errors.append(f"Jinja2 parse error in '{path}': {e}") + elif isinstance(obj, Mapping): + for k, v in obj.items(): + if skip_blueprint_metadata and k == "blueprint": + continue + child_path = f"{path}.{k}" if path != "root" else str(k) + if isinstance(k, str) and ("{{" in k or "{%" in k or "{#" in k): + try: + env.parse(k) + except jinja2.TemplateSyntaxError as e: + errors.append(f"Jinja2 syntax error in key '{child_path}' at line {e.lineno}: {e.message}") + except Exception as e: + errors.append(f"Jinja2 parse error in key '{child_path}': {e}") + errors.extend(validate_jinja_in_obj(v, env, child_path)) + elif isinstance(obj, Sequence) and not isinstance(obj, (str, bytes, bytearray)): + for idx, item in enumerate(obj): + errors.extend(validate_jinja_in_obj(item, env, f"{path}[{idx}]")) + + return errors + + +def validate_blueprint_file( + file_path: Path, jinja_env: TemplateEnvironment, verbose: bool = False +) -> tuple[bool, list[str], list[str]]: + """Validate a single blueprint file using Home Assistant Core schemas and template environment.""" + errors: list[str] = [] + warnings: list[str] = [] + + # 1. Native Home Assistant YAML Loading + try: + data = load_yaml(str(file_path)) + except Exception as e: + return False, [f"YAML loading error: {e}"], [] + + if not isinstance(data, dict): + return False, ["Invalid blueprint: root must be a YAML mapping (dictionary)"], [] + + # Check if this file is a blueprint + if "blueprint" not in data: + warnings.append("Not a blueprint (missing 'blueprint:' key)") + return True, errors, warnings + + blueprint_meta = data.get("blueprint") + if not isinstance(blueprint_meta, dict): + errors.append("Invalid blueprint metadata: 'blueprint:' must be a dictionary") + return False, errors, warnings + + domain = blueprint_meta.get("domain", "automation") + schema = get_blueprint_schema(domain) + + # 2. Official Home Assistant Blueprint Schema & Model Validation + try: + bp = Blueprint(data, expected_domain=domain, schema=schema) + if bp_errors := bp.validate(): + errors.extend([f"Blueprint validation error: {err}" for err in bp_errors]) + except InvalidBlueprint as e: + errors.append(f"Invalid blueprint: {e}") + except Exception as e: + errors.append(f"Home Assistant Blueprint schema error: {e}") + + # 3. Input Reference Validation + defined_inputs = extract_defined_inputs(blueprint_meta.get("input", {})) + used_inputs = extract_used_inputs(data) + errors.extend(f"Undefined input referenced: '!input {used}'" for used in used_inputs if used not in defined_inputs) + # 4. Selector Configuration Validation + selector_errors = validate_selectors_in_inputs(blueprint_meta.get("input", {})) + errors.extend(selector_errors) + + # 5. Official Home Assistant Jinja2 Syntax Validation + jinja_errors = validate_jinja_in_obj(data, jinja_env, skip_blueprint_metadata=True) + errors.extend(jinja_errors) + + # 6. Structure Validation based on domain + if domain == "automation": + if "trigger" not in data and "triggers" not in data: + warnings.append("Automation blueprint has no 'trigger' or 'triggers' section") + if "action" not in data and "actions" not in data and "sequence" not in data: + warnings.append("Automation blueprint has no 'action', 'actions', or 'sequence' section") + elif domain == "script": + if "sequence" not in data: + errors.append("Script blueprint is missing required 'sequence:' section") + + is_valid = not errors + return is_valid, errors, warnings + + +def main() -> int: + """CLI entrypoint for blueprint validator.""" + parser = argparse.ArgumentParser(description="Verify Home Assistant Blueprint and Jinja2 syntax across YAML files.") + parser.add_argument( + "paths", + nargs="*", + type=Path, + default=[], + help="Specific blueprint files or directories to validate. If omitted, scans root directory.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + + args = parser.parse_args() + jinja_env = TemplateEnvironment(None) + + root_dir = Path(__file__).resolve().parent.parent + target_files: list[Path] = [] + if args.paths: + for p in args.paths: + if p.is_dir(): + for ext in ("*.yaml", "*.yml"): + target_files.extend( + candidate + for candidate in p.rglob(ext) + if not any(part.startswith(".") for part in candidate.relative_to(p).parts[:-1]) + ) + elif p.is_file(): + target_files.append(p) + else: + print(f"Error: Path '{p}' not found.") + return 1 + else: + for ext in ("*.yaml", "*.yml"): + target_files.extend( + candidate + for candidate in root_dir.rglob(ext) + if not any(part.startswith(".") for part in candidate.relative_to(root_dir).parts[:-1]) + ) + target_files = sorted(set(target_files)) + + if not target_files: + print("No YAML files found to validate.") + return 0 + + print("=======================================================") + print(" Home Assistant Blueprint & Jinja2 Syntax Validator ") + print("=======================================================") + print(f"Scanning {len(target_files)} YAML files...\n") + + total_valid = 0 + total_invalid = 0 + total_skipped = 0 + + for file_path in target_files: + rel_path = file_path.relative_to(root_dir) if file_path.is_relative_to(root_dir) else file_path + is_valid, errors, warnings = validate_blueprint_file(file_path, jinja_env, args.verbose) + + if "Not a blueprint (missing 'blueprint:' key)" in warnings: + if args.verbose: + print(f" [SKIP] {rel_path} (not a blueprint)") + total_skipped += 1 + continue + + if is_valid: + total_valid += 1 + print(f" [PASS] {rel_path}") + if warnings and args.verbose: + for w in warnings: + print(f" Warning: {w}") + else: + total_invalid += 1 + print(f" [FAIL] {rel_path}") + for err in errors: + print(f" [ERROR] {err}") + for w in warnings: + print(f" [WARN] {w}") + + print("\n-------------------------------------------------------") + print(f"Results: {total_valid} passed, {total_invalid} failed, {total_skipped} skipped") + print("-------------------------------------------------------\n") + + return 0 if total_invalid == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/traffic_fine_lookup_full_llm.yaml b/traffic_fine_lookup_full_llm.yaml index 3cef9c1..9ab1d9a 100644 --- a/traffic_fine_lookup_full_llm.yaml +++ b/traffic_fine_lookup_full_llm.yaml @@ -62,7 +62,7 @@ mode: parallel max_exceeded: silent description: Queries traffic violation databases using a license plate and vehicle type variables: - version: 20260409 + version: 20260821 fields: license_plate: name: License Plate @@ -105,5 +105,8 @@ sequence: data: license_plate: "{{ license_plate }}" vehicle_type: "{{ vehicle_type }}" + - variables: + response: >- + {{ dict(response, checked_at=now().strftime('%Y-%m-%d %H:%M:%S %z')) if response is mapping else response }} - stop: "" response_variable: response diff --git a/weather_forecast_full_llm.yaml b/weather_forecast_full_llm.yaml index 5614a78..f36bcbd 100644 --- a/weather_forecast_full_llm.yaml +++ b/weather_forecast_full_llm.yaml @@ -73,7 +73,7 @@ blueprint: multiline: true multiple: false default: |- - daily or hourly + daily or hourly. Default daily time_period_length_prompt: name: Time period length prompt description: The prompt used by the AI to identify the period length @@ -82,7 +82,7 @@ blueprint: multiline: true multiple: false default: |- - Length in days (daily) or hours (hourly) + Length in days (daily) or hours (hourly). Default 1 date_prompt: name: Date prompt description: The prompt used by the AI to identify the starting date @@ -100,12 +100,12 @@ blueprint: multiline: true multiple: false default: |- - Start time (HH:MM:SS) + Start time (HH:MM:SS). Default 00:00:00 for daily, current time for hourly mode: parallel max_exceeded: silent description: Retrieves weather forecast for hourly or daily periods variables: - version: 20260818 + version: 20260821 fields: time_period_type: name: Time Period Type @@ -115,7 +115,7 @@ fields: options: - daily - hourly - required: true + default: daily time_period_length: name: Time Period Length description: !input time_period_length_prompt @@ -123,19 +123,17 @@ fields: number: min: 1 max: 48 - required: true + default: 1 start_date: name: Start Date description: !input date_prompt selector: date: - required: true start_time: name: Start Time description: !input time_prompt selector: time: - required: true sequence: - variables: weather_entity: !input weather_entity @@ -145,34 +143,44 @@ sequence: {% set h = ['hourly'] if sf | bitwise_and(2) else [] %} {% set td = ['twice_daily'] if sf | bitwise_and(4) else [] %} {{ d + h + td }} + _period_type: "{{ time_period_type if (time_period_type | default) in ['daily', 'hourly', 'twice_daily'] else 'daily' }}" time_period_type: > {{ 'twice_daily' - if time_period_type not in supported - and time_period_type == 'daily' - and 'twice_daily' in supported - else time_period_type + if _period_type not in supported + and _period_type == 'daily' + and 'twice_daily' in supported + else _period_type }} - start_date: "{{ start_date | as_datetime(default='') }}" - start_time: - "{{ start_time | default('00:00:00') | as_timedelta | default('00:00:00', - true) }}" - start: - "{{ (start_date | as_datetime + as_timedelta(start_time)) | as_local if - start_date else 'NA' }}" - end: "{% if start != 'NA' %} - - {% set start = as_datetime(start) %} - - {% set add = time_period_length | default(1) | int(1) %} - - {% set type = time_period_type | default if time_period_type | default in ['daily', 'hourly'] - else 'daily' %} - - {{ (start + timedelta(days = add if type == 'daily' else 0, hours = add if type == 'hourly' - else 0)).isoformat() }} - - {% endif %}" + _add_length: "{{ (time_period_length | default(1) | int(1)) | abs }}" + _start_date_raw: "{{ start_date | default('', true) | trim }}" + _start_time_raw: "{{ start_time | default('', true) | trim }}" + _start_date_val: >- + {% if _start_date_raw != '' %} + {% set parsed = _start_date_raw | as_datetime(default=none) %} + {{ (parsed.date() | string) if parsed is not none else _start_date_raw }} + {% else %} + {{ now().strftime('%Y-%m-%d') }} + {% endif %} + _start_time_val: >- + {% if _start_time_raw != '' %} + {{ _start_time_raw }} + {% elif time_period_type == 'hourly' and (_start_date_val == now().strftime('%Y-%m-%d')) %} + {{ now().strftime('%H:%M:%S') }} + {% else %} + 00:00:00 + {% endif %} + start: >- + {% set dt_str = _start_date_val ~ ' ' ~ _start_time_val %} + {% set dt = dt_str | as_datetime(default=none) %} + {{ (dt | as_local).isoformat() if dt is not none else 'NA' }} + end: >- + {% if start != 'NA' %} + {% set start_dt = as_datetime(start) %} + {{ (start_dt + timedelta(days = _add_length if time_period_type in ['daily', 'twice_daily'] else 0, hours = _add_length if time_period_type == 'hourly' else 0)) | as_local | string }} + {% else %} + NA + {% endif %} - alias: Check if variables were set correctly if: - condition: template @@ -209,23 +217,29 @@ sequence: response_variable: weather_response - variables: calc_average: !input calc_average - selected_data: - forecast: "{% set ns = namespace(forecast=[]) %} - - {% for item in weather_response[weather_entity].forecast %} - + selected_data: >- + {% set ns = namespace(forecast=[]) %} + {% for item in weather_response[weather_entity].forecast %} {% if start | as_datetime <= item.datetime | as_datetime | as_local < end | as_datetime %} - - {% set ns.forecast = ns.forecast + [item] %} - + {% set ns.forecast = ns.forecast + [item] %} {% endif %} - - {% endfor %} {{ ns.forecast }}" + {% endfor %} + {{ + dict( + current_context=dict( + current_time=now().strftime('%A, %Y-%m-%d %H:%M:%S %z'), + timezone=now().tzinfo | string + ), + start_of_period=start, + end_of_period=end, + forecast=ns.forecast + ) + }} averaged: averaged_weather_data_for_period: "{% set ns = namespace(combined={}) %} - {% set weather_data = selected_data.forecast %} + {% set weather_data = selected_data.forecast if selected_data is mapping and selected_data.forecast is defined else [] %} {% set first_forecast = weather_data[0] if weather_data else {} %} @@ -243,7 +257,15 @@ sequence: {% endfor %} - {{ dict(start_of_period=start, end_of_period=end, **ns.combined) }}" + {{ dict( + current_context=dict( + current_time=now().strftime('%A, %Y-%m-%d %H:%M:%S %z'), + timezone=now().tzinfo | string + ), + start_of_period=start, + end_of_period=end, + **ns.combined + ) }}" response: "{{ averaged if calc_average else selected_data }}" - stop: "" response_variable: response