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.
feature/powerwall-live-activity-1598
Carlo Costanzo 3 weeks ago
parent ca71ac2522
commit ae2850fe54

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

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

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

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

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

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

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

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

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

@ -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 <20>all interior lights off<66> 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<69>route data via `data:`/`variables:` and reuse everywhere.
- If you copy a script, rename any `alias` and `id` fields to avoid duplicates.

@ -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 }}"
Loading…
Cancel
Save

Powered by TurnKey Linux.