From ae2850fe543ff9f40f4b26c5c417d2fcfc08d79d Mon Sep 17 00:00:00 2001 From: Carlo Costanzo Date: Wed, 4 Mar 2026 16:16:40 -0500 Subject: [PATCH] Enhance Home Assistant YAML Dry Verifier and related automations - Added a mandatory resolution policy to the YAML verifier documentation, emphasizing the need for immediate resolution of findings. - Introduced a new `CENTRAL_SCRIPT` finding type to identify scripts defined in `config/packages` but called from multiple YAML files. - Updated the verifier script to collect and report on central script usage, including recommendations for moving definitions to appropriate locations. - Refactored various automations to utilize the new `script.joanna_dispatch` for improved context handling and remediation requests. - Enhanced existing automations with additional conditions and variables for better control and monitoring of actions. - Updated README files to reflect new features and improvements across packages. --- .../homeassistant-yaml-dry-verifier/SKILL.md | 20 ++ .../scripts/verify_ha_yaml_dry.py | 99 +++++- config/packages/README.md | 3 +- config/packages/bearclaw.yaml | 39 ++- config/packages/climate.yaml | 68 +++-- config/packages/mqtt_status.yaml | 22 +- config/packages/onenote_indexer.yaml | 41 +-- config/packages/powerwall.yaml | 288 ++++++++++-------- config/packages/wireless.yaml | 54 +++- config/script/README.md | 17 ++ config/script/joanna_dispatch.yaml | 66 ++++ 11 files changed, 518 insertions(+), 199 deletions(-) create mode 100644 config/script/joanna_dispatch.yaml diff --git a/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md b/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md index 075c9089..7e0fee9a 100644 --- a/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md +++ b/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md @@ -7,6 +7,13 @@ description: "Verify Home Assistant YAML for DRY and efficiency issues by detect Use this skill to lint Home Assistant YAML for repeat logic before or after edits, then refactor repeated blocks into reusable helpers. +## Mandatory Resolution Policy + +- If the verifier reports findings for files touched in the current task, do not stop at reporting. +- Resolve the findings in the same task by refactoring YAML to remove duplication. +- Re-run the verifier after refactoring and iterate until targeted findings are cleared. +- If a finding cannot be safely resolved, explicitly document the blocker and the smallest safe follow-up. + ## Quick Start 1. Run the verifier script on the file(s) you edited. @@ -38,6 +45,7 @@ python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.p - `FULL_BLOCK`: repeated full trigger/condition/action/sequence blocks. - `ENTRY`: repeated individual entries inside those blocks. - `INTRA`: duplicate entries inside a single block. +- `CENTRAL_SCRIPT`: script is defined in `config/packages` but called from 2+ YAML files. 4. Refactor with intent: - Repeated actions/sequence: move to a reusable `script.*`, pass variables. @@ -48,6 +56,12 @@ python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.p - Re-run this verifier. - Run Home Assistant config validation before reload/restart. +6. Enforce closure: +- Treat unresolved `FULL_BLOCK`/`ENTRY` findings in touched files as incomplete work unless a blocker is documented. +- Prefer consolidating duplicated automation triggers/conditions/actions into shared logic or a single branching automation. +- Treat unresolved `CENTRAL_SCRIPT` findings in touched scope as incomplete unless documented as deferred-with-blocker. +- Move shared package scripts to `config/script/.yaml` when they are used cross-file. + ## Dashboard Designer Integration When dashboard or automation work includes YAML edits beyond card layout, use this verifier after generation to catch duplicated logic that may have been introduced during fast refactors. @@ -58,7 +72,13 @@ Always report: - Total files scanned. - Parse errors (if any). - Duplicate groups by kind (`trigger`, `condition`, `action`, `sequence`). +- Central script placement findings (`CENTRAL_SCRIPT`) with definition + caller files. - Concrete refactor recommendation per group. +- Resolution status for each finding (`resolved`, `deferred-with-blocker`). + +Strict behavior: +- `--strict` returns non-zero for any reported finding (`FULL_BLOCK`, `ENTRY`, `INTRA`, `CENTRAL_SCRIPT`). +- Without `--strict`, findings are reported but exit remains zero unless parse errors occur. ## References diff --git a/codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py b/codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py index 475f11d8..6f501091 100644 --- a/codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py +++ b/codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py @@ -14,6 +14,7 @@ from __future__ import annotations import argparse import json import os +import re import sys from collections import defaultdict from dataclasses import dataclass @@ -82,6 +83,13 @@ class ParseError: error: str +@dataclass(frozen=True) +class CentralScriptFinding: + script_id: str + definition_files: tuple[str, ...] + caller_files: tuple[str, ...] + + def _discover_yaml_files(paths: Iterable[str]) -> list[Path]: found: set[Path] = set() for raw in paths: @@ -270,6 +278,41 @@ def _render_occurrences(occurrences: list[Occurrence], max_rows: int = 6) -> str return "\n".join(lines) +def _normalize_path(path: str) -> str: + return path.replace("\\", "/").lower() + + +def _infer_script_id(candidate: Candidate) -> str | None: + if candidate.kind != "script": + return None + marker = ".script." + if marker in candidate.path: + return candidate.path.split(marker, 1)[1] + if "/config/script/" in _normalize_path(candidate.file_path): + if candidate.path.startswith("$."): + return candidate.path[2:] + match = re.match(r"^\$doc\[\d+\]\.(.+)$", candidate.path) + if match: + return match.group(1) + return None + + +def _collect_script_service_calls(node: Any, script_ids: set[str]) -> None: + if isinstance(node, dict): + for key, value in node.items(): + if key in {"service", "action"} and isinstance(value, str): + service_name = value.strip() + if service_name.startswith("script."): + script_id = service_name.split(".", 1)[1].strip() + if script_id: + script_ids.add(script_id) + _collect_script_service_calls(value, script_ids) + return + if isinstance(node, list): + for item in node: + _collect_script_service_calls(item, script_ids) + + def main(argv: list[str]) -> int: ap = argparse.ArgumentParser(description="Detect duplicated Home Assistant YAML structures.") ap.add_argument("paths", nargs="+", help="YAML file(s) or directory path(s) to scan") @@ -289,6 +332,7 @@ def main(argv: list[str]) -> int: parse_errors: list[ParseError] = [] candidates: list[Candidate] = [] + script_calls_by_id: dict[str, set[str]] = defaultdict(set) for path in files: try: @@ -297,8 +341,12 @@ def main(argv: list[str]) -> int: parse_errors.append(ParseError(file_path=str(path), error=str(exc))) continue + script_calls_in_file: set[str] = set() for doc_idx, doc in enumerate(docs): candidates.extend(_extract_candidates_from_doc(doc, str(path), doc_idx)) + _collect_script_service_calls(doc, script_calls_in_file) + for script_id in script_calls_in_file: + script_calls_by_id[script_id].add(str(path)) full_index: dict[tuple[str, str, str], list[Occurrence]] = defaultdict(list) entry_index: dict[tuple[str, str, str], list[Occurrence]] = defaultdict(list) @@ -354,6 +402,32 @@ def main(argv: list[str]) -> int: full_groups = _filter_groups(full_index) entry_groups = _filter_groups(entry_index) intra_duplicate_notes = sorted(set(intra_duplicate_notes)) + script_definitions_by_id: dict[str, set[str]] = defaultdict(set) + + for candidate in candidates: + script_id = _infer_script_id(candidate) + if script_id: + script_definitions_by_id[script_id].add(candidate.file_path) + + central_script_findings: list[CentralScriptFinding] = [] + for script_id, definition_files in script_definitions_by_id.items(): + normalized_definitions = {_normalize_path(path): path for path in definition_files} + if not any("/config/packages/" in n for n in normalized_definitions): + continue + if any("/config/script/" in n for n in normalized_definitions): + continue + caller_files = sorted(script_calls_by_id.get(script_id, set())) + if len(caller_files) < 2: + continue + central_script_findings.append( + CentralScriptFinding( + script_id=script_id, + definition_files=tuple(sorted(definition_files)), + caller_files=tuple(caller_files), + ) + ) + + central_script_findings.sort(key=lambda item: (-len(item.caller_files), item.script_id)) print(f"Scanned files: {len(files)}") print(f"Parsed candidates: {len(candidates)}") @@ -361,6 +435,7 @@ def main(argv: list[str]) -> int: print(f"Duplicate full-block groups: {len(full_groups)}") print(f"Duplicate entry groups: {len(entry_groups)}") print(f"Intra-block duplicates: {len(intra_duplicate_notes)}") + print(f"Central-script findings: {len(central_script_findings)}") if parse_errors: print("\nParse errors:") @@ -386,8 +461,28 @@ def main(argv: list[str]) -> int: for idx, note in enumerate(intra_duplicate_notes[: args.max_groups], start=1): print(f"{idx}. {note}") - duplicate_count = len(full_groups) + len(entry_groups) + len(intra_duplicate_notes) - if args.strict and duplicate_count > 0: + if central_script_findings: + print("\nCENTRAL_SCRIPT findings:") + for idx, finding in enumerate(central_script_findings[: args.max_groups], start=1): + print( + f"{idx}. script.{finding.script_id} is package-defined and called from " + f"{len(finding.caller_files)} files" + ) + for definition_file in finding.definition_files: + print(f" - definition: {definition_file}") + for caller_file in finding.caller_files[:6]: + print(f" - caller: {caller_file}") + if len(finding.caller_files) > 6: + print(f" - ... {len(finding.caller_files) - 6} more callers") + print(f" suggestion: Move definition to config/script/{finding.script_id}.yaml") + + finding_count = ( + len(full_groups) + + len(entry_groups) + + len(intra_duplicate_notes) + + len(central_script_findings) + ) + if args.strict and finding_count > 0: return 1 if parse_errors: return 2 diff --git a/config/packages/README.md b/config/packages/README.md index 02441df0..e3bea090 100755 --- a/config/packages/README.md +++ b/config/packages/README.md @@ -48,9 +48,10 @@ Live collection of plug-and-play Home Assistant packages. Each YAML file in this | [docker_infrastructure.yaml](docker_infrastructure.yaml) | Docker host patching telemetry (docker_10/14/17/69) + host-side auto-reboots + container-down Repairs alerts, with degraded-telemetry guardrails when Portainer data drops. | `sensor.docker_*_apt_status`, `binary_sensor.docker_container_telemetry_degraded`, `repairs.create`, `repairs.remove` | | [infrastructure_observability.yaml](infrastructure_observability.yaml) | Normalized WAN/DNS/backup/domain/cert health sensors used by the Infrastructure Home + Website Health dashboards. | `binary_sensor.infra_*`, `sensor.infra_*`, `script.send_to_logbook` | | [onenote_indexer.yaml](onenote_indexer.yaml) | OneNote indexer health/status monitoring for Joanna, failure-repair automation, and a daily duplicate-delete maintenance request. | `sensor.onenote_indexer_last_job_status`, `binary_sensor.onenote_indexer_last_job_successful` | +| [mqtt_status.yaml](mqtt_status.yaml) | Command-line MQTT broker reachability probe with Spook Repairs escalation and Joanna troubleshooting dispatch on outage. | `binary_sensor.mqtt_status_raw`, `binary_sensor.mqtt_broker_problem`, `repairs.create`, `rest_command.bearclaw_command` | | [mariadb.yaml](mariadb.yaml) | MariaDB recorder health and capacity SQL sensors. | `sensor.mariadb_status`, `sensor.database_size` | | [tugtainer_updates.yaml](tugtainer_updates.yaml) | Tugtainer container update notifications via webhook + persistent alerts. | `persistent_notification.create`, `input_datetime.tugtainer_last_update` | -| [bearclaw.yaml](bearclaw.yaml) | Joanna/BearClaw bridge automations that forward Telegram commands to codex_appliance and relay replies back. | `rest_command.bearclaw_*`, `automation.bearclaw_*`, webhook relay | +| [bearclaw.yaml](bearclaw.yaml) | Joanna/BearClaw bridge automations that forward Telegram commands to codex_appliance and relay replies back, with Telegram user/chat allowlist enforcement via secrets CSV. | `rest_command.bearclaw_*`, `automation.bearclaw_*`, webhook relay | | [telegram_bot.yaml](telegram_bot.yaml) | Telegram script wrappers used by BearClaw and other ops flows (UI integration remains the source for bot config). | `script.joanna_send_telegram`, `telegram_bot.send_message` | | [phynplus.yaml](phynplus.yaml) | Phyn shutoff automations with push + Activity feed + Repairs issues for leak events. | `valve.phyn_shutoff_valve`, `binary_sensor.phyn_leak_test_running`, `repairs.create` | | [water_delivery.yaml](water_delivery.yaml) | ReadyRefresh delivery date helper with night-before + garage door Alexa reminders. | `input_datetime.water_delivery_date`, `notify.alexa_media_garage` | diff --git a/config/packages/bearclaw.yaml b/config/packages/bearclaw.yaml index 4e5c18c8..020f133e 100644 --- a/config/packages/bearclaw.yaml +++ b/config/packages/bearclaw.yaml @@ -9,7 +9,9 @@ # Notes: Keep BearClaw transport + bridge logic centralized in this package. # Notes: Most BearClaw decision logic runs in docker_17/codex_appliance (server.js). # Notes: GitHub capture behavior (issue creation/labels/research flow) belongs in codex_appliance, not HA YAML. +# Notes: Shared script helper `script.joanna_dispatch` lives in config/script/joanna_dispatch.yaml. # Notes: Telegram inline button callbacks are handled here and mapped to BearClaw commands. +# Notes: Inbound Telegram handling enforces user_id + chat_id allowlists from secrets CSV values. ###################################################################### rest_command: @@ -40,7 +42,6 @@ rest_command: "wake": {{ wake | default(false) | tojson }}, "source": "home_assistant" } - automation: - id: bearclaw_telegram_bear_command alias: BearClaw Telegram Bear Command @@ -51,9 +52,21 @@ automation: event_type: telegram_command event_data: command: /bear + variables: + allowed_user_ids_csv: !secret bearclaw_allowed_telegram_user_ids + allowed_chat_ids_csv: !secret bearclaw_allowed_telegram_chat_ids condition: - condition: template value_template: "{{ trigger.event.data.user_id is defined }}" + - condition: template + value_template: "{{ trigger.event.data.chat_id is defined }}" + - condition: template + value_template: >- + {% set allowed_users = (allowed_user_ids_csv | default('', true) | string).split(',') | map('trim') | reject('equalto', '') | list %} + {% set allowed_chats = (allowed_chat_ids_csv | default('', true) | string).split(',') | map('trim') | reject('equalto', '') | list %} + {% set incoming_user = trigger.event.data.user_id | default('') | string | trim %} + {% set incoming_chat = trigger.event.data.chat_id | default('') | string | trim %} + {{ allowed_users | count > 0 and allowed_chats | count > 0 and incoming_user in allowed_users and incoming_chat in allowed_chats }} action: - variables: command_text: "{{ (trigger.event.data.args | default([])) | join(' ') | trim }}" @@ -85,9 +98,21 @@ automation: trigger: - platform: event event_type: telegram_callback + variables: + allowed_user_ids_csv: !secret bearclaw_allowed_telegram_user_ids + allowed_chat_ids_csv: !secret bearclaw_allowed_telegram_chat_ids condition: - condition: template value_template: "{{ trigger.event.data.user_id is defined }}" + - condition: template + value_template: "{{ trigger.event.data.chat_id is defined }}" + - condition: template + value_template: >- + {% set allowed_users = (allowed_user_ids_csv | default('', true) | string).split(',') | map('trim') | reject('equalto', '') | list %} + {% set allowed_chats = (allowed_chat_ids_csv | default('', true) | string).split(',') | map('trim') | reject('equalto', '') | list %} + {% set incoming_user = trigger.event.data.user_id | default('') | string | trim %} + {% set incoming_chat = trigger.event.data.chat_id | default('') | string | trim %} + {{ allowed_users | count > 0 and allowed_chats | count > 0 and incoming_user in allowed_users and incoming_chat in allowed_chats }} - condition: template value_template: >- {% set cb = trigger.event.data.data | default('') %} @@ -192,9 +217,21 @@ automation: trigger: - platform: event event_type: telegram_text + variables: + allowed_user_ids_csv: !secret bearclaw_allowed_telegram_user_ids + allowed_chat_ids_csv: !secret bearclaw_allowed_telegram_chat_ids condition: - condition: template value_template: "{{ trigger.event.data.user_id is defined }}" + - condition: template + value_template: "{{ trigger.event.data.chat_id is defined }}" + - condition: template + value_template: >- + {% set allowed_users = (allowed_user_ids_csv | default('', true) | string).split(',') | map('trim') | reject('equalto', '') | list %} + {% set allowed_chats = (allowed_chat_ids_csv | default('', true) | string).split(',') | map('trim') | reject('equalto', '') | list %} + {% set incoming_user = trigger.event.data.user_id | default('') | string | trim %} + {% set incoming_chat = trigger.event.data.chat_id | default('') | string | trim %} + {{ allowed_users | count > 0 and allowed_chats | count > 0 and incoming_user in allowed_users and incoming_chat in allowed_chats }} - condition: template value_template: "{{ (trigger.event.data.text | default('') | trim) != '' }}" - condition: template diff --git a/config/packages/climate.yaml b/config/packages/climate.yaml index a7226248..ca370123 100644 --- a/config/packages/climate.yaml +++ b/config/packages/climate.yaml @@ -165,6 +165,7 @@ script: data: entity_id: climate.downstairs temperature: 80 + - conditions: - condition: and conditions: @@ -223,6 +224,34 @@ script: entity_id: climate.downstairs temperature: 80 + set_downstairs_daytime_target: + alias: Set Downstairs Daytime Target + mode: single + sequence: + - service: script.set_downstairs_target_temp_based_on_conditions + + set_downstairs_hvac_cool: + alias: Set Downstairs HVAC Cool + mode: single + sequence: + - service: climate.set_hvac_mode + data: + entity_id: climate.downstairs + hvac_mode: cool + + set_upstairs_cool_82: + alias: Set Upstairs Cool 82 + mode: single + sequence: + - service: climate.set_hvac_mode + data: + entity_id: climate.upstairs + hvac_mode: cool + - service: climate.set_temperature + data: + entity_id: climate.upstairs + temperature: 82 + ############################################################################## ### AUTOMATIONS - Thermostat schedules, guardrails, and presence/weather logic ### Some shutoff automations are also in the ALARM.yaml package when windows/doors are left open. @@ -299,9 +328,8 @@ automation: attribute: temperature below: 76 condition: - - condition: state - entity_id: binary_sensor.powerwall_grid_status - state: 'on' + - condition: template + value_template: "{{ is_state('binary_sensor.powerwall_grid_status', 'on') }}" action: - delay: "00:03:00" - service: climate.set_temperature @@ -352,9 +380,6 @@ automation: - platform: numeric_state entity_id: sensor.pirateweather_temperature above: 92 - - platform: state - entity_id: group.family - to: 'home' condition: - condition: and conditions: @@ -368,7 +393,7 @@ automation: entity_id: binary_sensor.powerwall_grid_status state: 'on' action: - - service: script.set_downstairs_target_temp_based_on_conditions + - service: script.set_downstairs_daytime_target # Set thermostats to eco mode when everyone is away - alias: 'Set Thermostats to Eco When Away' @@ -401,10 +426,7 @@ automation: topic: "CLIMATE" message: "Skipping downstairs cool mode (outside temp <75F)." default: - - service: climate.set_hvac_mode - data: - entity_id: climate.downstairs - hvac_mode: cool + - service: script.set_downstairs_hvac_cool - service: climate.set_temperature data: entity_id: climate.upstairs @@ -424,9 +446,8 @@ automation: - condition: state entity_id: group.family state: 'home' - - condition: state - entity_id: input_boolean.guest_mode - state: 'off' + - condition: template + value_template: "{{ is_state('input_boolean.guest_mode', 'off') }}" - condition: state entity_id: binary_sensor.powerwall_grid_status state: 'on' @@ -452,18 +473,10 @@ automation: - platform: time at: "03:00:00" condition: - - condition: state - entity_id: binary_sensor.powerwall_grid_status - state: 'on' + - condition: template + value_template: "{{ states('binary_sensor.powerwall_grid_status') == 'on' }}" action: - - service: climate.set_hvac_mode - data: - entity_id: climate.upstairs - hvac_mode: cool - - service: climate.set_temperature - data: - entity_id: climate.upstairs - temperature: 82 + - service: script.set_upstairs_cool_82 - alias: 'Humidity Control' id: AC_Humidity_Control @@ -485,9 +498,8 @@ automation: value_template: "{{ now().month in [10, 11, 12, 1, 2, 3] }}" - condition: template # Only run if AC is idle (prevents fighting other automations) value_template: "{{ state_attr('climate.downstairs', 'hvac_action') == 'idle' }}" - - condition: state # Never run if the grid is down and running on powerwall. - entity_id: binary_sensor.powerwall_grid_status - state: 'on' + - condition: template # Never run if the grid is down and running on powerwall. + value_template: "{{ states('binary_sensor.powerwall_grid_status')|lower == 'on' }}" action: - choose: - conditions: diff --git a/config/packages/mqtt_status.yaml b/config/packages/mqtt_status.yaml index cbeadab5..c2f11a15 100644 --- a/config/packages/mqtt_status.yaml +++ b/config/packages/mqtt_status.yaml @@ -43,10 +43,11 @@ automation: - variables: broker_endpoint: "192.168.10.10:1883" mqtt_raw_state: "{{ states('binary_sensor.mqtt_status_raw') }}" + issue_id: "mqtt_broker_unreachable" trigger_context: "HA automation mqtt_open_repair_on_failure (MQTT - Open Repair On Failure)" - service: repairs.create data: - issue_id: "mqtt_broker_unreachable" + issue_id: "{{ issue_id }}" title: "MQTT broker unreachable" severity: "warning" persistent: true @@ -60,16 +61,19 @@ automation: topic: "MQTT" message: >- MQTT broker appears down at {{ broker_endpoint }}. Spook repair opened and Joanna remediation requested. - - service: rest_command.bearclaw_command + - service: script.joanna_dispatch data: - text: >- - Trigger: {{ trigger_context }}. - MQTT broker health probe failed for endpoint {{ broker_endpoint }}. - Please troubleshoot and resolve if possible. - Current HA raw probe state: {{ mqtt_raw_state }}. - user: "carlo" + trigger_context: "{{ trigger_context }}" source: "home_assistant_automation.mqtt_open_repair_on_failure" - context: "{{ trigger_context }}" + summary: "MQTT broker endpoint is unreachable" + entity_ids: + - "binary_sensor.mqtt_status_raw" + - "binary_sensor.mqtt_broker_problem" + diagnostics: >- + issue_id={{ issue_id }}, + broker_endpoint={{ broker_endpoint }}, + mqtt_status_raw={{ mqtt_raw_state }} + request: "Troubleshoot and resolve MQTT broker reachability if possible." - id: mqtt_clear_repair_on_recovery alias: MQTT - Clear Repair On Recovery diff --git a/config/packages/onenote_indexer.yaml b/config/packages/onenote_indexer.yaml index dfab1426..2b876286 100644 --- a/config/packages/onenote_indexer.yaml +++ b/config/packages/onenote_indexer.yaml @@ -127,16 +127,18 @@ automation: data: topic: "ONENOTE" message: "Requesting daily duplicate cleanup + delete reconciliation run from Joanna." - - service: rest_command.bearclaw_command + - service: script.joanna_dispatch data: - text: >- - Trigger: {{ trigger_context }}. - Run OneNote indexer duplicate cleanup and apply deletions. - Include delete reconciliation so removed source pages are removed from the index. - Report pages/chunks and delete metrics before and after completion. - user: "carlo" + trigger_context: "{{ trigger_context }}" source: "home_assistant_automation.onenote_indexer_daily_delete_maintenance" - context: "{{ trigger_context }}" + summary: "Run daily OneNote indexer duplicate cleanup and deletion reconciliation" + entity_ids: + - "sensor.onenote_indexer_status_payload" + - "sensor.onenote_indexer_last_job_status" + diagnostics: "scheduled_time=03:15:00" + request: >- + Run OneNote duplicate cleanup and apply deletions. + Include delete reconciliation and report pages/chunks and delete metrics before and after completion. - id: onenote_indexer_failure_open_repair alias: OneNote Indexer - Open Repair On Failure @@ -153,10 +155,11 @@ automation: run_id: "{{ state_attr('sensor.onenote_indexer_last_job_status', 'last_run_id') | default('n/a') }}" last_error: "{{ state_attr('sensor.onenote_indexer_last_job_status', 'last_error') | default('n/a') }}" last_metrics: "{{ state_attr('sensor.onenote_indexer_last_job_status', 'last_metrics') | default({}) }}" + issue_id: "onenote_indexer_job_failed" trigger_context: "HA automation onenote_indexer_failure_open_repair (OneNote Indexer - Open Repair On Failure)" - service: repairs.create data: - issue_id: "onenote_indexer_job_failed" + issue_id: "{{ issue_id }}" title: "OneNote indexer job failed" severity: "warning" persistent: true @@ -172,19 +175,21 @@ automation: topic: "ONENOTE" message: >- OneNote indexer failed (run {{ run_id }}). Spook repair opened and Joanna remediation requested. - - service: rest_command.bearclaw_command + - service: script.joanna_dispatch data: - text: >- - Trigger: {{ trigger_context }}. - OneNote indexer containerhealth alert from Home Assistant. - Please troubleshoot and resolve if possible. + trigger_context: "{{ trigger_context }}" + source: "home_assistant_automation.onenote_indexer_failure_open_repair" + summary: "OneNote indexer job reported failure" + entity_ids: + - "binary_sensor.onenote_indexer_job_failed" + - "sensor.onenote_indexer_last_job_status" + diagnostics: >- + issue_id={{ issue_id }}, last_status={{ last_status }}, last_run_id={{ run_id }}, last_error={{ last_error }}, - last_metrics={{ last_metrics }}. - user: "carlo" - source: "home_assistant_automation.onenote_indexer_failure_open_repair" - context: "{{ trigger_context }}" + last_metrics={{ last_metrics }} + request: "Troubleshoot and resolve the indexer failure if possible." - id: onenote_indexer_failure_clear_repair alias: OneNote Indexer - Clear Repair On Recovery diff --git a/config/packages/powerwall.yaml b/config/packages/powerwall.yaml index e8b5a59b..7c188f0a 100755 --- a/config/packages/powerwall.yaml +++ b/config/packages/powerwall.yaml @@ -41,6 +41,18 @@ ###################################################################### #------------------------------------------- +script: + powerwall_turn_off_nonessential_lights: + alias: "Powerwall - Turn Off Non-Essential Lights" + sequence: + - service: homeassistant.turn_off + target: + entity_id: + - group.interior_lights + - group.exterior_lights + - switch.kitchen_accent_2 + - switch.master_bathroom_accent_2 + automation: - alias: Notify if Grid is down id: 56a32121-5725-4510-a1fa-10f69a5c82ef @@ -78,87 +90,61 @@ automation: to: 'off' action: - - service: homeassistant.turn_off - entity_id: - - group.interior_lights - - group.exterior_lights - - switch.kitchen_accent_2 - - switch.master_bathroom_accent_2 - - service: script.notify_engine - data: - title: "Electrical Grid Status {{ (trigger.to_state.state)|replace('True', 'up')|replace('False', 'down') }}." - value1: "Taking actions to turning off the House Lights to preserve Battery Power." - who: 'family' - group: 'information' + - repeat: + count: 3 + sequence: + - choose: + - conditions: + - condition: template + value_template: "{{ repeat.index == 2 }}" + sequence: + - delay: + minutes: 1 + - conditions: + - condition: template + value_template: "{{ repeat.index == 3 }}" + sequence: + - delay: + minutes: 3 + - service: script.powerwall_turn_off_nonessential_lights + - choose: + - conditions: + - condition: template + value_template: "{{ repeat.index == 1 }}" + sequence: + - service: script.notify_engine + data: + title: "Electrical Grid Status {{ (trigger.to_state.state)|replace('True', 'up')|replace('False', 'down') }}." + value1: "Taking actions to turning off the House Lights to preserve Battery Power." + who: 'family' + group: 'information' + - conditions: + - condition: template + value_template: "{{ repeat.index == 2 }}" + sequence: + - service: script.speech_engine + data: + value1: "Because of the Power Outage, the Lights will be recycled for 3 minutes. Lights may turn on and off during this time." + - conditions: + - condition: template + value_template: "{{ repeat.index == 3 }}" + sequence: + - service: script.speech_engine + data: + value1: "Automatic light recycling has been completed. Any abnormalities will have to be addressed in the Hue App most likely. " - - delay: - minutes: 1 - - service: homeassistant.turn_off - entity_id: - - group.interior_lights - - group.exterior_lights - - switch.kitchen_accent_2 - - switch.master_bathroom_accent_2 - - - service: script.speech_engine - data: - value1: "Because of the Power Outage, the Lights will be recycled for 3 minutes. Lights may turn on and off during this time." - - - delay: - minutes: 3 - - service: homeassistant.turn_off - entity_id: - - group.interior_lights - - group.exterior_lights - - switch.kitchen_accent_2 - - switch.master_bathroom_accent_2 - - - service: script.speech_engine - data: - value1: "Automatic light recycling has been completed. Any abnormalities will have to be addressed in the Hue App most likely. " - - - alias: Powerwall Low Charge Monitoring with Grid Status + - alias: "Powerwall Low Charge Monitoring and Recovery" id: fda6116b-b2a5-4198-a1ce-4cf4bb3254b2 mode: single trigger: - - platform: numeric_state + - id: low_24h + platform: numeric_state entity_id: sensor.powerwall_charge below: 60 for: hours: 24 - condition: - - condition: state - entity_id: binary_sensor.powerwall_grid_status - state: 'on' - action: - - service: script.send_to_logbook - data: - topic: "POWER" - message: "Powerwall charge below 60% for 24h (current: {{ states('sensor.powerwall_charge') }}%)." - - - service: repairs.create - data: - issue_id: "powerwall_low_charge_60_24h" - title: "Powerwall charge low for 24h" - severity: "warning" - persistent: true - description: >- - Powerwall has been below 60% for 24 hours while the grid is online. - - Current charge: {{ states('sensor.powerwall_charge') }}%. - - - service: script.notify_engine - data: - title: "Powerwall Low Charge Alert - Current Charge: {{ states('sensor.powerwall_charge') }}" - value1: "The Powerwall has been below 50% charge for more than 24 hours while the grid is online. This may indicate an issue." - who: 'parents' - group: 'information' - - - alias: "Powerwall Low Charge Resolved - Clear Repair Issue" - id: 5fd1f0b3-0e64-4a4b-bd7a-9f5d5e6b8f90 - mode: single - trigger: - - platform: numeric_state + - id: recovered + platform: numeric_state entity_id: sensor.powerwall_charge above: 60 for: @@ -168,14 +154,43 @@ automation: entity_id: binary_sensor.powerwall_grid_status state: 'on' action: - - service: repairs.remove - continue_on_error: true - data: - issue_id: "powerwall_low_charge_60_24h" - - service: script.send_to_logbook - data: - topic: "POWER" - message: "Powerwall charge recovered above 60%. Cleared repair issue." + - choose: + - conditions: + - condition: trigger + id: low_24h + sequence: + - service: script.send_to_logbook + data: + topic: "POWER" + message: "Powerwall charge below 60% for 24h (current: {{ states('sensor.powerwall_charge') }}%)." + - service: repairs.create + data: + issue_id: "powerwall_low_charge_60_24h" + title: "Powerwall charge low for 24h" + severity: "warning" + persistent: true + description: >- + Powerwall has been below 60% for 24 hours while the grid is online. + + Current charge: {{ states('sensor.powerwall_charge') }}%. + - service: script.notify_engine + data: + title: "Powerwall Low Charge Alert - Current Charge: {{ states('sensor.powerwall_charge') }}" + value1: "The Powerwall has been below 50% charge for more than 24 hours while the grid is online. This may indicate an issue." + who: 'parents' + group: 'information' + - conditions: + - condition: trigger + id: recovered + sequence: + - service: repairs.remove + continue_on_error: true + data: + issue_id: "powerwall_low_charge_60_24h" + - service: script.send_to_logbook + data: + topic: "POWER" + message: "Powerwall charge recovered above 60%. Cleared repair issue." - alias: "Shut down Docker hosts and camera PoE at 75% Powerwall" id: 25b3d3d8-92fa-454a-9f1c-6d3fd0f3af58 @@ -201,48 +216,25 @@ automation: - switch.poe_garage_port_5_poe - switch.poe_garage_port_6_poe - - alias: "Powerwall outage - Rheem WH off at night" + - alias: "Powerwall outage - Rheem WH mode control" id: d686f650-65ad-4cc6-8e27-8b5ee76b5338 - description: "During outages, turn off the water heater after sunset to protect battery" + description: "During outages, switch Rheem mode by time-of-day and battery reserve" mode: single trigger: - - platform: sun - event: sunset - - platform: state - entity_id: binary_sensor.powerwall_grid_status - to: 'off' - for: - minutes: 1 - condition: - - condition: state - entity_id: binary_sensor.powerwall_grid_status - state: 'off' - - condition: or - conditions: - - condition: sun - after: sunset - - condition: sun - before: sunrise - action: - - service: water_heater.set_operation_mode - target: - entity_id: water_heater.rheem_wh - data: - state: off - - - alias: "Powerwall outage - Rheem WH heat pump after sunrise and 50%" - id: 7b6e8bb0-7d0c-4e63-89cf-ff6e7811b579 - description: "During outages, restore water heater to heat pump once battery is healthy during daytime" - mode: single - trigger: - - platform: sun + - id: sunrise + platform: sun event: sunrise - - platform: numeric_state + - id: charge_above_50 + platform: numeric_state entity_id: sensor.powerwall_charge above: 50 for: minutes: 5 - - platform: state + - id: sunset + platform: sun + event: sunset + - id: grid_off_1m + platform: state entity_id: binary_sensor.powerwall_grid_status to: 'off' for: @@ -251,22 +243,42 @@ automation: - condition: state entity_id: binary_sensor.powerwall_grid_status state: 'off' - - condition: numeric_state - entity_id: sensor.powerwall_charge - above: 50 - - condition: sun - after: sunrise - before: sunset action: - - service: water_heater.set_operation_mode - target: - entity_id: water_heater.rheem_wh - data: - state: heat_pump + - choose: + - conditions: + - condition: template + value_template: "{{ trigger.id in ['sunset', 'grid_off_1m'] }}" + - condition: or + conditions: + - condition: sun + after: sunset + - condition: sun + before: sunrise + sequence: + - service: water_heater.set_operation_mode + target: + entity_id: water_heater.rheem_wh + data: + state: off + - conditions: + - condition: template + value_template: "{{ trigger.id in ['sunrise', 'charge_above_50', 'grid_off_1m'] }}" + - condition: numeric_state + entity_id: sensor.powerwall_charge + above: 50 + - condition: sun + after: sunrise + before: sunset + sequence: + - service: water_heater.set_operation_mode + target: + entity_id: water_heater.rheem_wh + data: + state: heat_pump - - alias: "Notify to restore PoE ports when grid returns" + - alias: "Restore PoE ports when grid returns" id: 1ae8b5c5-8627-4a44-8c8a-5bf8ca5e1bf5 - description: "Prompt to turn PoE ports back on after outage shutdown steps" + description: "Turn camera PoE ports back on after grid has been stable" mode: single trigger: - platform: state @@ -274,7 +286,11 @@ automation: from: 'off' to: 'on' for: - minutes: 10 + minutes: 60 + - platform: numeric_state + entity_id: sensor.powerwall_charge + above: 90 + condition: - condition: or conditions: @@ -290,11 +306,21 @@ automation: - condition: state entity_id: switch.poe_garage_port_6_poe state: 'off' + - condition: numeric_state + entity_id: sensor.powerwall_charge + above: 90 action: + - service: switch.turn_on + target: + entity_id: + - switch.poe_garage_port_3_poe + - switch.poe_garage_port_4_poe + - switch.poe_garage_port_5_poe + - switch.poe_garage_port_6_poe - service: script.notify_engine data: - title: "Grid restored - turn PoE ports back on" - value1: "Power is back. Remember to re-enable PoE ports 3-6 if cameras stayed offline." + title: "Grid restored - PoE ports re-enabled" + value1: "Power is stable. Camera PoE ports 3-6 were turned back on automatically." who: 'family' group: 'information' diff --git a/config/packages/wireless.yaml b/config/packages/wireless.yaml index 809dd52d..42f41171 100644 --- a/config/packages/wireless.yaml +++ b/config/packages/wireless.yaml @@ -1,12 +1,15 @@ -#------------------------------------------- -# @CCOSTAN -# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig -# Wireless AP Alerts - Unifi client monitoring and repair triggers. -#------------------------------------------- ###################################################################### -## Alert when APs have zero clients; create repair issues. +# @CCOSTAN - Follow Me on X +# For more info visit https://www.vcloudinfo.com/click-here +# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig +# ------------------------------------------------------------------- +# Wireless AP Alerts - Unifi client monitoring and repair triggers +# Opens/clears Spook repairs and dispatches Joanna when APs stay at 0 clients. +# ------------------------------------------------------------------- +# Notes: Discussion context: https://github.com/CCOSTAN/Home-AssistantConfig/issues/1534 +# Notes: Joanna remediation requests default to investigate + recommend (no auto reset/power-cycle). ###################################################################### -# Discussion: https://github.com/CCOSTAN/Home-AssistantConfig/issues/1534 + automation: - id: unifi_ap_no_clients_repair_combined alias: "Unifi AP Create Repair Issue after 5m of 0 Clients" @@ -50,6 +53,11 @@ automation: issue_id: > {{ ap_name | lower }}_ap_no_clients + client_sensor: "{{ trigger.entity_id }}" + client_count: "{{ states(trigger.entity_id) }}" + uptime_value: "{{ states(uptime_sensor) }}" + trigger_context: "HA automation unifi_ap_no_clients_repair_combined (Unifi AP Create Repair Issue after 5m of 0 Clients)" + action: - service: repairs.create data: @@ -59,10 +67,33 @@ automation: title: "{{ ap_name }} AP has 0 Wi-Fi Clients" description: > The {{ ap_name }} Unifi AP has reported 0 connected clients for - at least 5 minutes. - Current uptime: {{ states(uptime_sensor) }}. + at least 5 minutes. + Current uptime: {{ uptime_value }}. View and manage this AP here: https://unifi.ui.com + - service: script.send_to_logbook + data: + topic: "WIRELESS" + message: >- + {{ ap_name }} AP has 0 clients for 5 minutes. Repair {{ issue_id }} opened and Joanna investigation requested. + - service: script.joanna_dispatch + data: + trigger_context: "{{ trigger_context }}" + source: "home_assistant_automation.unifi_ap_no_clients_repair_combined" + summary: "{{ ap_name }} Unifi AP reported 0 clients for 5 minutes" + entity_ids: + - "{{ client_sensor }}" + - "{{ uptime_sensor }}" + diagnostics: >- + issue_id={{ issue_id }}, + ap_name={{ ap_name }}, + client_sensor={{ client_sensor }}, + client_count={{ client_count }}, + uptime_sensor={{ uptime_sensor }}, + uptime={{ uptime_value }} + request: >- + Investigate Unifi controller and AP health, then recommend remediation. + Do not run automated reset or power-cycle actions unless explicitly requested. - id: unifi_ap_no_clients_repair_resolved_combined alias: "Unifi AP Resolve Repair Issue When Clients Return" @@ -91,5 +122,10 @@ automation: action: - service: repairs.remove + continue_on_error: true data: issue_id: "{{ issue_id }}" + - service: script.send_to_logbook + data: + topic: "WIRELESS" + message: "{{ ap_name }} AP clients recovered above 0. Repair {{ issue_id }} cleared." diff --git a/config/script/README.md b/config/script/README.md index 886a8711..16d47d46 100755 --- a/config/script/README.md +++ b/config/script/README.md @@ -29,10 +29,27 @@ Reusable scripts that other automations call for notifications, lighting, and sa | --- | --- | | [notify_engine.yaml](notify_engine.yaml) | Single entrypoint for rich push notifications. | | [send_to_logbook.yaml](send_to_logbook.yaml) | Generic `logbook.log` helper for Activity feed entries (Issue #1550). | +| [joanna_dispatch.yaml](joanna_dispatch.yaml) | Shared BearClaw/Joanna dispatch schema for automation remediation requests. | | [speech_engine.yaml](speech_engine.yaml) | TTS/announcement orchestration with templated speech. | | [monthly_color_scene.yaml](monthly_color_scene.yaml) | Seasonal lighting scenes used across automations. | | [interior_off.yaml](interior_off.yaml) | One-call �all interior lights off� helper. | +### Joanna + BearClaw automated resolution flow +`script.joanna_dispatch` is the shared handoff contract from Home Assistant automations to BearClaw/Joanna. + +Why we use it: +- Keeps one message schema for remediation context (`trigger_context`, `source`, `summary`, `entity_ids`, `diagnostics`, `request`). +- Avoids repeating direct `rest_command.bearclaw_command` payload formatting in multiple packages. +- Makes resolution-trigger automations easier to review, update, and audit. + +Current automations that kick off automated resolutions (via `script.joanna_dispatch`): +| Automation ID | Alias | File | +| --- | --- | --- | +| `mqtt_open_repair_on_failure` | MQTT - Open Repair On Failure | [../packages/mqtt_status.yaml](../packages/mqtt_status.yaml) | +| `onenote_indexer_daily_delete_maintenance` | OneNote Indexer - Daily Delete Maintenance Request | [../packages/onenote_indexer.yaml](../packages/onenote_indexer.yaml) | +| `onenote_indexer_failure_open_repair` | OneNote Indexer - Open Repair On Failure | [../packages/onenote_indexer.yaml](../packages/onenote_indexer.yaml) | +| `unifi_ap_no_clients_repair_combined` | Unifi AP Create Repair Issue after 5m of 0 Clients | [../packages/wireless.yaml](../packages/wireless.yaml) | + ### Tips - Keep scripts generic�route data via `data:`/`variables:` and reuse everywhere. - If you copy a script, rename any `alias` and `id` fields to avoid duplicates. diff --git a/config/script/joanna_dispatch.yaml b/config/script/joanna_dispatch.yaml new file mode 100644 index 00000000..79517f6b --- /dev/null +++ b/config/script/joanna_dispatch.yaml @@ -0,0 +1,66 @@ +###################################################################### +# @CCOSTAN - Follow Me on X +# For more info visit https://www.vcloudinfo.com/click-here +# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig +# ------------------------------------------------------------------- +# Joanna Dispatch - Shared BearClaw dispatch helper for automations +# Normalizes remediation context and forwards requests via bearclaw_command. +# ------------------------------------------------------------------- +# Notes: Keep this helper generic so package automations can reuse one schema. +# Notes: Source defaults to home_assistant_automation.unknown when omitted. +###################################################################### + +joanna_dispatch: + alias: Joanna Dispatch + description: Standardized Joanna/BearClaw dispatch helper for automation-triggered requests. + mode: queued + fields: + trigger_context: + description: Automation and trigger context string. + source: + description: Source identifier sent to BearClaw. + summary: + description: Short summary of the condition requiring Joanna. + request: + description: What Joanna should do. + entity_ids: + description: Relevant entity IDs (list or comma-delimited string). + diagnostics: + description: Extra troubleshooting context. + user: + description: BearClaw user identity. + sequence: + - variables: + normalized_context: "{{ trigger_context | default('HA automation', true) }}" + normalized_source: "{{ source | default('home_assistant_automation.unknown', true) }}" + normalized_summary: "{{ summary | default('Home Assistant remediation request', true) }}" + normalized_request: >- + {{ request | default('Investigate and recommend remediation. Do not run automated resets or power-cycles unless explicitly requested.', true) }} + normalized_user: "{{ user | default('carlo', true) }}" + normalized_entity_ids: >- + {% if entity_ids is sequence and entity_ids is not string %} + {{ entity_ids | map('string') | join(', ') }} + {% elif entity_ids is string and entity_ids | trim != '' %} + {{ entity_ids | trim }} + {% else %} + n/a + {% endif %} + normalized_diagnostics: >- + {% if diagnostics is string %} + {{ diagnostics | trim if diagnostics | trim != '' else 'n/a' }} + {% elif diagnostics is mapping or (diagnostics is sequence and diagnostics is not string) %} + {{ diagnostics | tojson }} + {% else %} + n/a + {% endif %} + - service: rest_command.bearclaw_command + data: + text: >- + Trigger: {{ normalized_context }}. + Summary: {{ normalized_summary }}. + Entity IDs: {{ normalized_entity_ids }}. + Diagnostics: {{ normalized_diagnostics }}. + Request: {{ normalized_request }} + user: "{{ normalized_user }}" + source: "{{ normalized_source }}" + context: "{{ normalized_context }}"