From ef2396f0b862dd51eeb843c471e9542b97639cc5 Mon Sep 17 00:00:00 2001 From: Carlo Costanzo Date: Wed, 11 Mar 2026 11:24:28 -0400 Subject: [PATCH] Update Home Assistant version to 2026.3.1 and enhance Docker infrastructure configurations - Incremented Home Assistant version in the configuration file. - Refined Docker container handling in templates and automations to support multiple container states. - Improved logic for determining effective states of Docker containers, including handling of alternate status entities. - Enhanced Tugtainer update automation to dispatch Joanna for available updates with a 24-hour cooldown and improved logging. - Updated README and package documentation to reflect recent changes and new features. --- .../homeassistant-yaml-dry-verifier/SKILL.md | 1 + config/.HA_VERSION | 2 +- .../partials/docker_containers_sections.yaml | 42 -- .../templates/button_card_templates.yaml | 449 ++++++++++++++---- config/packages/README.md | 2 +- config/packages/docker_infrastructure.yaml | 363 ++++++++++---- config/packages/tugtainer_updates.yaml | 68 +++ 7 files changed, 680 insertions(+), 247 deletions(-) diff --git a/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md b/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md index 7e0fee9a..d43c62e6 100644 --- a/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md +++ b/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md @@ -51,6 +51,7 @@ python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.p - Repeated actions/sequence: move to a reusable `script.*`, pass variables. - Repeated conditions: extract to template binary sensors or helper entities. - Repeated triggers: consolidate where behavior is equivalent, or split by intent if readability improves. +- For cooldown/throttle behavior, prefer automation-local `this.attributes.last_triggered` with custom event handoff before adding new helper entities, unless shared persistent state is required across automations. 5. Validate after edits: - Re-run this verifier. diff --git a/config/.HA_VERSION b/config/.HA_VERSION index 80add063..d7d4db6c 100755 --- a/config/.HA_VERSION +++ b/config/.HA_VERSION @@ -1 +1 @@ -2026.3.0 \ No newline at end of file +2026.3.1 \ No newline at end of file diff --git a/config/dashboards/infrastructure/partials/docker_containers_sections.yaml b/config/dashboards/infrastructure/partials/docker_containers_sections.yaml index a17700a6..1cc86964 100644 --- a/config/dashboards/infrastructure/partials/docker_containers_sections.yaml +++ b/config/dashboards/infrastructure/partials/docker_containers_sections.yaml @@ -103,44 +103,6 @@ action: navigate navigation_path: '#infra-docker-14' -- type: grid - column_span: 4 - columns: 4 - square: false - cards: - - type: custom:button-card - template: bearstone_infra_apt_prune_tile - name: docker_10 - entity: sensor.docker_10_apt_status - variables: - last_update_sensor: sensor.docker_10_apt_last_update - prune_button: button.carlo_hass_prune_unused_images - name: docker_10 - - type: custom:button-card - template: bearstone_infra_apt_prune_tile - name: docker_17 - entity: sensor.docker_17_apt_status - variables: - last_update_sensor: sensor.docker_17_apt_last_update - prune_button: button.docker17_prune_unused_images - name: docker_17 - - type: custom:button-card - template: bearstone_infra_apt_prune_tile - name: docker_69 - entity: sensor.docker_69_apt_status - variables: - last_update_sensor: sensor.docker_69_apt_last_update - prune_button: button.docker69_prune_unused_images - name: docker_69 - - type: custom:button-card - template: bearstone_infra_apt_prune_tile - name: docker_14 - entity: sensor.docker_14_apt_status - variables: - last_update_sensor: sensor.docker_14_apt_last_update - prune_button: button.docker2_prune_unused_images - name: docker_14 - - type: grid column_span: 4 columns: 1 @@ -240,7 +202,3 @@ sort: method: name -- !include /config/dashboards/infrastructure/popups/docker_10_maintenance.yaml -- !include /config/dashboards/infrastructure/popups/docker_17_maintenance.yaml -- !include /config/dashboards/infrastructure/popups/docker_69_maintenance.yaml -- !include /config/dashboards/infrastructure/popups/docker_14_maintenance.yaml diff --git a/config/dashboards/infrastructure/templates/button_card_templates.yaml b/config/dashboards/infrastructure/templates/button_card_templates.yaml index b82a936c..26c99747 100644 --- a/config/dashboards/infrastructure/templates/button_card_templates.yaml +++ b/config/dashboards/infrastructure/templates/button_card_templates.yaml @@ -186,13 +186,32 @@ bearstone_infra_container_row: let key = ''; if (ent.startsWith('binary_sensor.') && ent.endsWith('_status')) { key = ent.replace('binary_sensor.', '').replace(/_status$/, ''); + } else if (ent.startsWith('binary_sensor.') && ent.endsWith('_status_2')) { + key = ent.replace('binary_sensor.', '').replace(/_status_2$/, ''); } else if (ent.startsWith('switch.') && ent.endsWith('_container')) { key = ent.replace('switch.', '').replace(/_container$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container_2')) { + key = ent.replace('switch.', '').replace(/_container_2$/, ''); } - return key ? `button.${key}_restart_container` : ''; + if (!key) return ''; + const restartCandidates = [ + `button.${key}_restart_container`, + `button.${key}_restart_container_2`, + ]; + for (const candidate of restartCandidates) { + if (states[candidate]) return candidate; + } + return restartCandidates[0]; ]]] confirmation: - text: '[[[ return "Restart container " + entity.attributes.friendly_name + "?" ]]]' + text: > + [[[ + const friendly = + (entity && entity.attributes && entity.attributes.friendly_name) + ? String(entity.attributes.friendly_name) + : ((entity && entity.entity_id) ? String(entity.entity_id) : 'container'); + return "Restart container " + friendly + "?"; + ]]] icon: mdi:docker name: > [[[ @@ -204,8 +223,12 @@ bearstone_infra_container_row: let key = ''; if (ent.startsWith('binary_sensor.') && ent.endsWith('_status')) { key = ent.replace('binary_sensor.', '').replace(/_status$/, ''); + } else if (ent.startsWith('binary_sensor.') && ent.endsWith('_status_2')) { + key = ent.replace('binary_sensor.', '').replace(/_status_2$/, ''); } else if (ent.startsWith('switch.') && ent.endsWith('_container')) { key = ent.replace('switch.', '').replace(/_container$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container_2')) { + key = ent.replace('switch.', '').replace(/_container_2$/, ''); } if (friendly && friendly !== 'Container') { return friendly.replace(/\s+Container$/, ''); @@ -216,20 +239,65 @@ bearstone_infra_container_row: image: > [[[ const ent = (entity && entity.entity_id) ? String(entity.entity_id) : ''; - const stateNow = String(entity && entity.state !== undefined ? entity.state : '').toLowerCase(); const telemetryDegraded = states['binary_sensor.docker_container_telemetry_degraded']?.state === 'on'; let key = ''; if (ent.startsWith('binary_sensor.') && ent.endsWith('_status')) { key = ent.replace('binary_sensor.', '').replace(/_status$/, ''); + } else if (ent.startsWith('binary_sensor.') && ent.endsWith('_status_2')) { + key = ent.replace('binary_sensor.', '').replace(/_status_2$/, ''); } else if (ent.startsWith('switch.') && ent.endsWith('_container')) { key = ent.replace('switch.', '').replace(/_container$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container_2')) { + key = ent.replace('switch.', '').replace(/_container_2$/, ''); + } + const stateCandidates = key ? [ + `binary_sensor.${key}_status`, + `binary_sensor.${key}_status_2`, + `sensor.${key}_state`, + `sensor.${key}_state_2`, + `switch.${key}_container`, + `switch.${key}_container_2`, + ] : []; + const isUnknownLike = (v) => !v || ['unknown', 'unavailable', 'none', ''].includes(String(v).toLowerCase()); + let resolvedState = String(entity && entity.state !== undefined ? entity.state : '').toLowerCase(); + for (const candidate of stateCandidates) { + const candidateState = states[candidate]?.state; + if (!isUnknownLike(candidateState)) { + resolvedState = String(candidateState).toLowerCase(); + break; + } + } + if (isUnknownLike(resolvedState)) { + for (const candidate of stateCandidates) { + const candidateState = states[candidate]?.state; + if (candidateState !== undefined) { + resolvedState = String(candidateState).toLowerCase(); + break; + } + } } - const imageEntity = variables.image_sensor - ? variables.image_sensor - : (key ? `sensor.${key}_image` : ''); - const imageValue = states[imageEntity]?.state; - if (!imageValue || ['unknown', 'unavailable', 'none', ''].includes(String(imageValue).toLowerCase())) { - if (telemetryDegraded && ['unknown', 'unavailable', ''].includes(stateNow)) { + const imageCandidates = variables.image_sensor + ? [variables.image_sensor] + : (key ? [`sensor.${key}_image`, `sensor.${key}_image_2`] : []); + let imageValue; + for (const candidate of imageCandidates) { + const candidateState = states[candidate]?.state; + if (!isUnknownLike(candidateState)) { + imageValue = candidateState; + break; + } + } + if (imageValue === undefined) { + for (const candidate of imageCandidates) { + const candidateState = states[candidate]?.state; + if (candidateState !== undefined) { + imageValue = candidateState; + break; + } + } + } + if (isUnknownLike(imageValue)) { + if (telemetryDegraded && ['unknown', 'unavailable', ''].includes(resolvedState)) { return 'telemetry: delayed'; } return 'image: n/a'; @@ -238,13 +306,49 @@ bearstone_infra_container_row: ]]] status: > [[[ - const s = String(entity.state || '').toLowerCase(); + const ent = (entity && entity.entity_id) ? String(entity.entity_id) : ''; + let key = ''; + if (ent.startsWith('binary_sensor.') && ent.endsWith('_status')) { + key = ent.replace('binary_sensor.', '').replace(/_status$/, ''); + } else if (ent.startsWith('binary_sensor.') && ent.endsWith('_status_2')) { + key = ent.replace('binary_sensor.', '').replace(/_status_2$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container')) { + key = ent.replace('switch.', '').replace(/_container$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container_2')) { + key = ent.replace('switch.', '').replace(/_container_2$/, ''); + } + const candidates = key ? [ + `binary_sensor.${key}_status`, + `binary_sensor.${key}_status_2`, + `sensor.${key}_state`, + `sensor.${key}_state_2`, + `switch.${key}_container`, + `switch.${key}_container_2`, + ] : []; + const isUnknownLike = (v) => !v || ['unknown', 'unavailable', ''].includes(String(v).toLowerCase()); + let s = String(entity && entity.state !== undefined ? entity.state : '').toLowerCase(); + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (!isUnknownLike(candidateState)) { + s = String(candidateState).toLowerCase(); + break; + } + } + if (isUnknownLike(s)) { + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (candidateState !== undefined) { + s = String(candidateState).toLowerCase(); + break; + } + } + } const telemetryDegraded = states['binary_sensor.docker_container_telemetry_degraded']?.state === 'on'; if (s === 'on' || s === 'running') return 'RUNNING'; - if (s === 'off' || s === 'stopped') return 'STOPPED'; + if (s === 'off' || s === 'stopped' || s === 'exited' || s === 'dead') return 'STOPPED'; if (s === 'unavailable') return telemetryDegraded ? 'STALE' : 'OFFLINE'; if (s === 'unknown' || s === '') return telemetryDegraded ? 'STALE' : 'UNKNOWN'; - return String(entity.state).toUpperCase(); + return String(s).toUpperCase(); ]]] styles: grid: @@ -258,6 +362,50 @@ bearstone_infra_container_row: - overflow: hidden - text-overflow: ellipsis - white-space: nowrap + icon: + - color: > + [[[ + const ent = (entity && entity.entity_id) ? String(entity.entity_id) : ''; + let key = ''; + if (ent.startsWith('binary_sensor.') && ent.endsWith('_status')) { + key = ent.replace('binary_sensor.', '').replace(/_status$/, ''); + } else if (ent.startsWith('binary_sensor.') && ent.endsWith('_status_2')) { + key = ent.replace('binary_sensor.', '').replace(/_status_2$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container')) { + key = ent.replace('switch.', '').replace(/_container$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container_2')) { + key = ent.replace('switch.', '').replace(/_container_2$/, ''); + } + const candidates = key ? [ + `binary_sensor.${key}_status`, + `binary_sensor.${key}_status_2`, + `sensor.${key}_state`, + `sensor.${key}_state_2`, + `switch.${key}_container`, + `switch.${key}_container_2`, + ] : []; + const isUnknownLike = (v) => !v || ['unknown', 'unavailable', ''].includes(String(v).toLowerCase()); + let s = String(entity && entity.state !== undefined ? entity.state : '').toLowerCase(); + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (!isUnknownLike(candidateState)) { + s = String(candidateState).toLowerCase(); + break; + } + } + if (isUnknownLike(s)) { + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (candidateState !== undefined) { + s = String(candidateState).toLowerCase(); + break; + } + } + } + if (s === 'on' || s === 'running') return 'rgba(46,125,50,1)'; + if (s === 'off' || s === 'stopped' || s === 'exited' || s === 'dead') return 'rgba(198,40,40,1)'; + return 'rgba(230,81,0,1)'; + ]]] custom_fields: image: - grid-area: image @@ -278,112 +426,203 @@ bearstone_infra_container_row: - letter-spacing: 0.04em - padding: 4px 10px - border-radius: 999px - - background: rgba(0,0,0,0.06) - - color: var(--secondary-text-color) + - background: > + [[[ + const ent = (entity && entity.entity_id) ? String(entity.entity_id) : ''; + let key = ''; + if (ent.startsWith('binary_sensor.') && ent.endsWith('_status')) { + key = ent.replace('binary_sensor.', '').replace(/_status$/, ''); + } else if (ent.startsWith('binary_sensor.') && ent.endsWith('_status_2')) { + key = ent.replace('binary_sensor.', '').replace(/_status_2$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container')) { + key = ent.replace('switch.', '').replace(/_container$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container_2')) { + key = ent.replace('switch.', '').replace(/_container_2$/, ''); + } + const candidates = key ? [ + `binary_sensor.${key}_status`, + `binary_sensor.${key}_status_2`, + `sensor.${key}_state`, + `sensor.${key}_state_2`, + `switch.${key}_container`, + `switch.${key}_container_2`, + ] : []; + const isUnknownLike = (v) => !v || ['unknown', 'unavailable', ''].includes(String(v).toLowerCase()); + let s = String(entity && entity.state !== undefined ? entity.state : '').toLowerCase(); + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (!isUnknownLike(candidateState)) { + s = String(candidateState).toLowerCase(); + break; + } + } + if (isUnknownLike(s)) { + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (candidateState !== undefined) { + s = String(candidateState).toLowerCase(); + break; + } + } + } + if (s === 'on' || s === 'running') return 'rgba(46,125,50,0.12)'; + if (s === 'off' || s === 'stopped' || s === 'exited' || s === 'dead') return 'rgba(198,40,40,0.10)'; + return 'rgba(230,81,0,0.12)'; + ]]] + - color: > + [[[ + const ent = (entity && entity.entity_id) ? String(entity.entity_id) : ''; + let key = ''; + if (ent.startsWith('binary_sensor.') && ent.endsWith('_status')) { + key = ent.replace('binary_sensor.', '').replace(/_status$/, ''); + } else if (ent.startsWith('binary_sensor.') && ent.endsWith('_status_2')) { + key = ent.replace('binary_sensor.', '').replace(/_status_2$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container')) { + key = ent.replace('switch.', '').replace(/_container$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container_2')) { + key = ent.replace('switch.', '').replace(/_container_2$/, ''); + } + const candidates = key ? [ + `binary_sensor.${key}_status`, + `binary_sensor.${key}_status_2`, + `sensor.${key}_state`, + `sensor.${key}_state_2`, + `switch.${key}_container`, + `switch.${key}_container_2`, + ] : []; + const isUnknownLike = (v) => !v || ['unknown', 'unavailable', ''].includes(String(v).toLowerCase()); + let s = String(entity && entity.state !== undefined ? entity.state : '').toLowerCase(); + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (!isUnknownLike(candidateState)) { + s = String(candidateState).toLowerCase(); + break; + } + } + if (isUnknownLike(s)) { + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (candidateState !== undefined) { + s = String(candidateState).toLowerCase(); + break; + } + } + } + if (s === 'on' || s === 'running') return 'rgba(46,125,50,1)'; + if (s === 'off' || s === 'stopped' || s === 'exited' || s === 'dead') return 'rgba(198,40,40,1)'; + return 'rgba(230,81,0,1)'; + ]]] card: + - border-color: > + [[[ + const ent = (entity && entity.entity_id) ? String(entity.entity_id) : ''; + let key = ''; + if (ent.startsWith('binary_sensor.') && ent.endsWith('_status')) { + key = ent.replace('binary_sensor.', '').replace(/_status$/, ''); + } else if (ent.startsWith('binary_sensor.') && ent.endsWith('_status_2')) { + key = ent.replace('binary_sensor.', '').replace(/_status_2$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container')) { + key = ent.replace('switch.', '').replace(/_container$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container_2')) { + key = ent.replace('switch.', '').replace(/_container_2$/, ''); + } + const candidates = key ? [ + `binary_sensor.${key}_status`, + `binary_sensor.${key}_status_2`, + `sensor.${key}_state`, + `sensor.${key}_state_2`, + `switch.${key}_container`, + `switch.${key}_container_2`, + ] : []; + const isUnknownLike = (v) => !v || ['unknown', 'unavailable', ''].includes(String(v).toLowerCase()); + let s = String(entity && entity.state !== undefined ? entity.state : '').toLowerCase(); + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (!isUnknownLike(candidateState)) { + s = String(candidateState).toLowerCase(); + break; + } + } + if (isUnknownLike(s)) { + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (candidateState !== undefined) { + s = String(candidateState).toLowerCase(); + break; + } + } + } + if (s === 'on' || s === 'running') return 'rgba(67,160,71,0.45)'; + if (s === 'off' || s === 'stopped' || s === 'exited' || s === 'dead') return 'rgba(229,57,53,0.35)'; + return 'rgba(245,124,0,0.35)'; + ]]] + - background: > + [[[ + const ent = (entity && entity.entity_id) ? String(entity.entity_id) : ''; + let key = ''; + if (ent.startsWith('binary_sensor.') && ent.endsWith('_status')) { + key = ent.replace('binary_sensor.', '').replace(/_status$/, ''); + } else if (ent.startsWith('binary_sensor.') && ent.endsWith('_status_2')) { + key = ent.replace('binary_sensor.', '').replace(/_status_2$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container')) { + key = ent.replace('switch.', '').replace(/_container$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container_2')) { + key = ent.replace('switch.', '').replace(/_container_2$/, ''); + } + const candidates = key ? [ + `binary_sensor.${key}_status`, + `binary_sensor.${key}_status_2`, + `sensor.${key}_state`, + `sensor.${key}_state_2`, + `switch.${key}_container`, + `switch.${key}_container_2`, + ] : []; + const isUnknownLike = (v) => !v || ['unknown', 'unavailable', ''].includes(String(v).toLowerCase()); + let s = String(entity && entity.state !== undefined ? entity.state : '').toLowerCase(); + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (!isUnknownLike(candidateState)) { + s = String(candidateState).toLowerCase(); + break; + } + } + if (isUnknownLike(s)) { + for (const candidate of candidates) { + const candidateState = states[candidate]?.state; + if (candidateState !== undefined) { + s = String(candidateState).toLowerCase(); + break; + } + } + } + if (s === 'on' || s === 'running') return 'rgba(232,245,233,0.85)'; + if (s === 'off' || s === 'stopped' || s === 'exited' || s === 'dead') return 'rgba(255,235,238,0.85)'; + return 'rgba(255,243,224,0.85)'; + ]]] - display: > [[[ const ent = (entity && entity.entity_id) ? String(entity.entity_id) : ''; let key = ''; if (ent.startsWith('binary_sensor.') && ent.endsWith('_status')) { key = ent.replace('binary_sensor.', '').replace(/_status$/, ''); + } else if (ent.startsWith('binary_sensor.') && ent.endsWith('_status_2')) { + key = ent.replace('binary_sensor.', '').replace(/_status_2$/, ''); } else if (ent.startsWith('switch.') && ent.endsWith('_container')) { key = ent.replace('switch.', '').replace(/_container$/, ''); + } else if (ent.startsWith('switch.') && ent.endsWith('_container_2')) { + key = ent.replace('switch.', '').replace(/_container_2$/, ''); } const switchEntity = key ? `switch.${key}_container` : ''; + const switchEntityAlt = key ? `switch.${key}_container_2` : ''; const monitored = states['group.docker_monitored_containers']?.attributes?.entity_id || []; - const restart = key ? `button.${key}_restart_container` : ''; - return (restart && states[restart] && monitored.includes(switchEntity)) ? 'block' : 'none'; + const restartCandidates = key ? [ + `button.${key}_restart_container`, + `button.${key}_restart_container_2`, + ] : []; + const hasRestart = restartCandidates.some((candidate) => states[candidate]); + const isMonitored = monitored.includes(switchEntity) || monitored.includes(switchEntityAlt); + return (hasRestart && isMonitored) ? 'block' : 'none'; ]]] - state: - - value: 'on' - styles: - card: - - border-color: rgba(67,160,71,0.45) - - background: rgba(232,245,233,0.85) - icon: - - color: rgba(46,125,50,1) - custom_fields: - status: - - background: rgba(46,125,50,0.12) - - color: rgba(46,125,50,1) - - value: 'off' - styles: - card: - - border-color: rgba(229,57,53,0.35) - - background: rgba(255,235,238,0.85) - icon: - - color: rgba(198,40,40,1) - custom_fields: - status: - - background: rgba(198,40,40,0.10) - - color: rgba(198,40,40,1) - - value: Running - styles: - card: - - border-color: rgba(67,160,71,0.45) - - background: rgba(232,245,233,0.85) - icon: - - color: rgba(46,125,50,1) - custom_fields: - status: - - background: rgba(46,125,50,0.12) - - color: rgba(46,125,50,1) - - value: running - styles: - card: - - border-color: rgba(67,160,71,0.45) - - background: rgba(232,245,233,0.85) - icon: - - color: rgba(46,125,50,1) - custom_fields: - status: - - background: rgba(46,125,50,0.12) - - color: rgba(46,125,50,1) - - value: Stopped - styles: - card: - - border-color: rgba(229,57,53,0.35) - - background: rgba(255,235,238,0.85) - icon: - - color: rgba(198,40,40,1) - custom_fields: - status: - - background: rgba(198,40,40,0.10) - - color: rgba(198,40,40,1) - - value: stopped - styles: - card: - - border-color: rgba(229,57,53,0.35) - - background: rgba(255,235,238,0.85) - icon: - - color: rgba(198,40,40,1) - custom_fields: - status: - - background: rgba(198,40,40,0.10) - - color: rgba(198,40,40,1) - - value: unavailable - styles: - card: - - border-color: rgba(245,124,0,0.35) - - background: rgba(255,243,224,0.85) - icon: - - color: rgba(230,81,0,1) - custom_fields: - status: - - background: rgba(230,81,0,0.12) - - color: rgba(230,81,0,1) - - value: unknown - styles: - card: - - border-color: rgba(245,124,0,0.35) - - background: rgba(255,243,224,0.85) - icon: - - color: rgba(230,81,0,1) - custom_fields: - status: - - background: rgba(230,81,0,0.12) - - color: rgba(230,81,0,1) bearstone_infra_panel_header: show_icon: false diff --git a/config/packages/README.md b/config/packages/README.md index 8df47ee9..0554a203 100755 --- a/config/packages/README.md +++ b/config/packages/README.md @@ -51,7 +51,7 @@ Live collection of plug-and-play Home Assistant packages. Each YAML file in this | [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` | +| [tugtainer_updates.yaml](tugtainer_updates.yaml) | Tugtainer container update notifications via webhook + persistent alerts, plus event-based Joanna dispatch when reports include `### Available:` (24h cooldown via automation `last_triggered`, no new helpers). | `persistent_notification.create`, `event: tugtainer_available_detected`, `script.joanna_dispatch`, `input_datetime.tugtainer_last_update` | | [bearclaw.yaml](bearclaw.yaml) | Joanna/BearClaw bridge automations that forward Telegram commands to codex_appliance, relay replies back, and write JOANNA webhook reply summaries to Activity feed. | `rest_command.bearclaw_*`, `automation.bearclaw_*`, `script.send_to_logbook`, 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` | diff --git a/config/packages/docker_infrastructure.yaml b/config/packages/docker_infrastructure.yaml index 1e08ce2e..d5b8220c 100644 --- a/config/packages/docker_infrastructure.yaml +++ b/config/packages/docker_infrastructure.yaml @@ -215,21 +215,37 @@ template: {% set ns = namespace(keys=[], unavailable=0) %} {% set monitored = state_attr('group.docker_monitored_containers', 'entity_id') | default([], true) %} {% for switch_entity in monitored %} - {% set key = switch_entity | replace('switch.', '') | regex_replace('_container$', '') %} + {% set key = switch_entity | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} {% if key not in ns.keys %} {% set ns.keys = ns.keys + [key] %} {% endif %} {% endfor %} {% for key in ns.keys %} {% set status_entity = 'binary_sensor.' ~ key ~ '_status' %} + {% set status_entity_alt = status_entity ~ '_2' %} + {% set state_entity = 'sensor.' ~ key ~ '_state' %} + {% set state_entity_alt = state_entity ~ '_2' %} {% set switch_entity = 'switch.' ~ key ~ '_container' %} - {% if expand(status_entity) | count > 0 %} - {% set effective_state = states(status_entity) | lower %} - {% elif expand(switch_entity) | count > 0 %} - {% set effective_state = states(switch_entity) | lower %} - {% else %} - {% set effective_state = 'unknown' %} + {% set switch_entity_alt = switch_entity ~ '_2' %} + {% set resolver = namespace(state='unknown', chosen=false) %} + {% for candidate in [status_entity, status_entity_alt, state_entity, state_entity_alt, switch_entity, switch_entity_alt] %} + {% if not resolver.chosen and expand(candidate) | count > 0 %} + {% set candidate_state = states(candidate) | lower %} + {% if candidate_state not in ['unknown', 'unavailable', ''] %} + {% set resolver.state = candidate_state %} + {% set resolver.chosen = true %} + {% endif %} + {% endif %} + {% endfor %} + {% if not resolver.chosen %} + {% for candidate in [status_entity, status_entity_alt, state_entity, state_entity_alt, switch_entity, switch_entity_alt] %} + {% if not resolver.chosen and expand(candidate) | count > 0 %} + {% set resolver.state = states(candidate) | lower %} + {% set resolver.chosen = true %} + {% endif %} + {% endfor %} {% endif %} + {% set effective_state = resolver.state %} {% if effective_state == 'unavailable' %} {% set ns.unavailable = ns.unavailable + 1 %} {% endif %} @@ -244,21 +260,37 @@ template: {% set monitored = state_attr('group.docker_monitored_containers', 'entity_id') | default([], true) %} {% set telemetry_degraded = is_state('binary_sensor.docker_container_telemetry_degraded', 'on') %} {% for switch_entity in monitored %} - {% set key = switch_entity | replace('switch.', '') | regex_replace('_container$', '') %} + {% set key = switch_entity | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} {% if key not in ns.keys %} {% set ns.keys = ns.keys + [key] %} {% endif %} {% endfor %} {% for key in ns.keys | sort %} {% set status_entity = 'binary_sensor.' ~ key ~ '_status' %} + {% set status_entity_alt = status_entity ~ '_2' %} + {% set state_entity = 'sensor.' ~ key ~ '_state' %} + {% set state_entity_alt = state_entity ~ '_2' %} {% set switch_entity = 'switch.' ~ key ~ '_container' %} - {% if expand(status_entity) | count > 0 %} - {% set effective_state = states(status_entity) | lower %} - {% elif expand(switch_entity) | count > 0 %} - {% set effective_state = states(switch_entity) | lower %} - {% else %} - {% set effective_state = 'unknown' %} + {% set switch_entity_alt = switch_entity ~ '_2' %} + {% set resolver = namespace(state='unknown', chosen=false) %} + {% for candidate in [status_entity, status_entity_alt, state_entity, state_entity_alt, switch_entity, switch_entity_alt] %} + {% if not resolver.chosen and expand(candidate) | count > 0 %} + {% set candidate_state = states(candidate) | lower %} + {% if candidate_state not in ['unknown', 'unavailable', ''] %} + {% set resolver.state = candidate_state %} + {% set resolver.chosen = true %} + {% endif %} + {% endif %} + {% endfor %} + {% if not resolver.chosen %} + {% for candidate in [status_entity, status_entity_alt, state_entity, state_entity_alt, switch_entity, switch_entity_alt] %} + {% if not resolver.chosen and expand(candidate) | count > 0 %} + {% set resolver.state = states(candidate) | lower %} + {% set resolver.chosen = true %} + {% endif %} + {% endfor %} {% endif %} + {% set effective_state = resolver.state %} {% if effective_state in ['off', 'stopped'] %} {% set ns.down = ns.down + [key] %} {% elif not telemetry_degraded and effective_state in ['unknown', 'unavailable'] %} @@ -323,7 +355,7 @@ template: icon: mdi:bell-sleep state: >- {% set stamp = states('input_datetime.docker_container_alerts_snooze_until') %} - {% set until_ts = as_datetime(stamp) %} + {% set until_ts = as_local(as_datetime(stamp)) if stamp not in ['unknown', 'unavailable', 'none', ''] else none %} {{ until_ts is not none and now() < until_ts }} script: @@ -340,34 +372,52 @@ script: delay_minutes: description: "Optional delay before evaluation (used for create path)" example: 5 + log_result: + description: "Whether to write activity log entries for create/clear actions" + example: true sequence: - variables: - down_states: ['off', 'stopped', 'unknown', 'unavailable'] + down_states: ['off', 'stopped', 'exited', 'dead', 'unknown', 'unavailable'] src_entity: "{{ entity_id | default('', true) }}" op: "{{ operation | default('create', true) | lower }}" wait_minutes: "{{ delay_minutes | default(0) | int(0) }}" + log_enabled: "{{ log_result | default(true) | bool }}" container_key: >- {% if src_entity.startswith('binary_sensor.') %} - {{ src_entity | replace('binary_sensor.', '') | regex_replace('_status$', '') }} + {{ src_entity | replace('binary_sensor.', '') | regex_replace('_status(?:_2)?$', '') }} {% elif src_entity.startswith('switch.') %} - {{ src_entity | replace('switch.', '') | regex_replace('_container$', '') }} + {{ src_entity | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') }} + {% elif src_entity.startswith('sensor.') %} + {{ src_entity | replace('sensor.', '') | regex_replace('_state(?:_2)?$', '') }} {% else %} {{ src_entity }} {% endif %} switch_entity: "switch.{{ container_key }}_container" - restart_entity: "button.{{ container_key }}_restart_container" + switch_entity_alt: "switch.{{ container_key }}_container_2" status_entity: "binary_sensor.{{ container_key }}_status" + status_entity_alt: "binary_sensor.{{ container_key }}_status_2" + state_entity: "sensor.{{ container_key }}_state" + state_entity_alt: "sensor.{{ container_key }}_state_2" monitored_switches: "{{ state_attr('group.docker_monitored_containers', 'entity_id') | default([], true) }}" - tracked_container: "{{ switch_entity in monitored_switches }}" + tracked_container: "{{ switch_entity in monitored_switches or switch_entity_alt in monitored_switches }}" effective_entity: >- {% if expand(status_entity) | count > 0 %} {{ status_entity }} + {% elif expand(status_entity_alt) | count > 0 %} + {{ status_entity_alt }} + {% elif expand(state_entity) | count > 0 %} + {{ state_entity }} + {% elif expand(state_entity_alt) | count > 0 %} + {{ state_entity_alt }} {% elif expand(switch_entity) | count > 0 %} {{ switch_entity }} + {% elif expand(switch_entity_alt) | count > 0 %} + {{ switch_entity_alt }} {% else %} {{ src_entity }} {% endif %} issue_id: "docker_container_{{ container_key }}_offline" + spook_issue_id: "user_docker_container_{{ container_key }}_offline" - condition: template value_template: "{{ tracked_container and op in ['create', 'clear'] }}" - choose: @@ -379,7 +429,27 @@ script: - delay: minutes: "{{ wait_minutes }}" - variables: - effective_state: "{{ states(effective_entity) | lower }}" + effective_state: >- + {% set candidates = [status_entity, status_entity_alt, state_entity, state_entity_alt, switch_entity, switch_entity_alt] %} + {% set resolver = namespace(state='unknown', chosen=false) %} + {% for candidate in candidates %} + {% if not resolver.chosen and expand(candidate) | count > 0 %} + {% set candidate_state = states(candidate) | lower %} + {% if candidate_state not in ['unknown', 'unavailable', ''] %} + {% set resolver.state = candidate_state %} + {% set resolver.chosen = true %} + {% endif %} + {% endif %} + {% endfor %} + {% if not resolver.chosen %} + {% for candidate in candidates %} + {% if not resolver.chosen and expand(candidate) | count > 0 %} + {% set resolver.state = states(candidate) | lower %} + {% set resolver.chosen = true %} + {% endif %} + {% endfor %} + {% endif %} + {{ resolver.state }} telemetry_degraded: "{{ is_state('binary_sensor.docker_container_telemetry_degraded', 'on') }}" container_name: "{{ state_attr(effective_entity, 'friendly_name') | default(container_key, true) }}" - condition: template @@ -398,14 +468,37 @@ script: Effective entity: {{ effective_entity }}. severity: warning persistent: true - - service: script.send_to_logbook - data: - topic: "DOCKER" - message: "{{ container_name }} is {{ effective_state }} for over 5 minutes." + - choose: + - conditions: "{{ log_enabled }}" + sequence: + - service: script.send_to_logbook + data: + topic: "DOCKER" + message: "{{ container_name }} is {{ effective_state }} for over 5 minutes." - conditions: "{{ op == 'clear' }}" sequence: - variables: - effective_state: "{{ states(effective_entity) | lower }}" + effective_state: >- + {% set candidates = [status_entity, status_entity_alt, state_entity, state_entity_alt, switch_entity, switch_entity_alt] %} + {% set resolver = namespace(state='unknown', chosen=false) %} + {% for candidate in candidates %} + {% if not resolver.chosen and expand(candidate) | count > 0 %} + {% set candidate_state = states(candidate) | lower %} + {% if candidate_state not in ['unknown', 'unavailable', ''] %} + {% set resolver.state = candidate_state %} + {% set resolver.chosen = true %} + {% endif %} + {% endif %} + {% endfor %} + {% if not resolver.chosen %} + {% for candidate in candidates %} + {% if not resolver.chosen and expand(candidate) | count > 0 %} + {% set resolver.state = states(candidate) | lower %} + {% set resolver.chosen = true %} + {% endif %} + {% endfor %} + {% endif %} + {{ resolver.state }} container_name: "{{ state_attr(effective_entity, 'friendly_name') | default(container_key, true) }}" - condition: template value_template: "{{ effective_state not in down_states }}" @@ -413,10 +506,17 @@ script: continue_on_error: true data: issue_id: "{{ issue_id }}" - - service: script.send_to_logbook + - service: repairs.remove + continue_on_error: true data: - topic: "DOCKER" - message: "{{ container_name }} recovered ({{ effective_state }})." + issue_id: "{{ spook_issue_id }}" + - choose: + - conditions: "{{ log_enabled }}" + sequence: + - service: script.send_to_logbook + data: + topic: "DOCKER" + message: "{{ container_name }} recovered ({{ effective_state }})." docker_stack_repairs_sync: alias: Docker Stack Repairs Sync @@ -431,12 +531,16 @@ script: delay_minutes: description: "Optional delay before evaluation (used for create path)" example: 2 + log_result: + description: "Whether to write activity log entries for create/clear actions" + example: true sequence: - variables: down_states: ['off', 'unknown', 'unavailable'] src_entity: "{{ entity_id | default('', true) }}" op: "{{ operation | default('create', true) | lower }}" wait_minutes: "{{ delay_minutes | default(0) | int(0) }}" + log_enabled: "{{ log_result | default(true) | bool }}" stack_key: "{{ src_entity | replace('binary_sensor.', '') | regex_replace('_stack_status$', '') }}" stack_count_entity: "sensor.{{ stack_key }}_stack_containers_count" tracked_stack: "{{ expand(stack_count_entity) | count > 0 }}" @@ -467,10 +571,13 @@ script: Effective entity: {{ src_entity }}. severity: warning persistent: true - - service: script.send_to_logbook - data: - topic: "DOCKER" - message: "{{ stack_name }} stack is {{ effective_state }} for over 2 minutes." + - choose: + - conditions: "{{ log_enabled }}" + sequence: + - service: script.send_to_logbook + data: + topic: "DOCKER" + message: "{{ stack_name }} stack is {{ effective_state }} for over 2 minutes." - conditions: "{{ op == 'clear' }}" sequence: - variables: @@ -482,10 +589,13 @@ script: continue_on_error: true data: issue_id: "{{ issue_id }}" - - service: script.send_to_logbook - data: - topic: "DOCKER" - message: "{{ stack_name }} stack recovered ({{ effective_state }})." + - choose: + - conditions: "{{ log_enabled }}" + sequence: + - service: script.send_to_logbook + data: + topic: "DOCKER" + message: "{{ stack_name }} stack recovered ({{ effective_state }})." automation: - alias: "APT Update Report - Docker Hosts" @@ -582,87 +692,128 @@ automation: topic: "APT" message: "{{ log_message }}" - - alias: "Docker Container State Sync - Repairs (Dynamic)" - id: docker_container_state_sync_repairs_dynamic - description: "Detect dynamic container state transitions and delegate Repairs sync to script helper." + - alias: "Docker State Sync - Repairs (Dynamic)" + id: docker_state_sync_repairs_dynamic + description: "Detect Docker container/stack state transitions and delegate Repairs sync." mode: parallel trigger: - platform: event event_type: state_changed condition: - - condition: template - value_template: >- - {% set ent = trigger.event.data.entity_id | default('') %} - {{ ent.startswith('switch.') and ent.endswith('_container') or - ent.startswith('binary_sensor.') and ent.endswith('_status') }} - condition: template value_template: "{{ trigger.event.data.old_state is not none and trigger.event.data.new_state is not none }}" - condition: template value_template: "{{ trigger.event.data.old_state.state != trigger.event.data.new_state.state }}" action: - variables: - down_states: ['off', 'stopped', 'unknown', 'unavailable'] - entity_id: "{{ trigger.event.data.entity_id }}" + entity_id: "{{ trigger.event.data.entity_id | default('') }}" old_state: "{{ trigger.event.data.old_state.state | lower }}" new_state: "{{ trigger.event.data.new_state.state | lower }}" + monitored: "{{ state_attr('group.docker_monitored_containers', 'entity_id') | default([], true) }}" - choose: - - conditions: >- - {{ new_state in down_states and old_state not in down_states and - not (is_state('binary_sensor.docker_container_telemetry_degraded', 'on') and - new_state in ['unknown', 'unavailable']) }} + - conditions: + - condition: template + value_template: >- + {% set ent = entity_id %} + {% if ent.startswith('switch.') and (ent.endswith('_container') or ent.endswith('_container_2')) %} + {{ ent in monitored }} + {% elif ent.startswith('binary_sensor.') and (ent.endswith('_status') or ent.endswith('_status_2')) %} + {% set key = ent | replace('binary_sensor.', '') | regex_replace('_status(?:_2)?$', '') %} + {{ ('switch.' ~ key ~ '_container') in monitored or ('switch.' ~ key ~ '_container_2') in monitored }} + {% elif ent.startswith('sensor.') and (ent.endswith('_state') or ent.endswith('_state_2')) %} + {% set key = ent | replace('sensor.', '') | regex_replace('_state(?:_2)?$', '') %} + {{ ('switch.' ~ key ~ '_container') in monitored or ('switch.' ~ key ~ '_container_2') in monitored }} + {% else %} + false + {% endif %} sequence: - - service: script.docker_container_repairs_sync - data: - entity_id: "{{ entity_id }}" - operation: "create" - delay_minutes: 5 - - conditions: "{{ old_state in down_states and new_state not in down_states }}" + - variables: + down_states: ['off', 'stopped', 'exited', 'dead', 'unknown', 'unavailable'] + - choose: + - conditions: >- + {{ new_state in down_states and old_state not in down_states and + not (is_state('binary_sensor.docker_container_telemetry_degraded', 'on') and + new_state in ['unknown', 'unavailable']) }} + sequence: + - service: script.docker_container_repairs_sync + data: + entity_id: "{{ entity_id }}" + operation: "create" + delay_minutes: 5 + - conditions: "{{ old_state in down_states and new_state not in down_states }}" + sequence: + - service: script.docker_container_repairs_sync + data: + entity_id: "{{ entity_id }}" + operation: "clear" + - conditions: + - condition: template + value_template: >- + {% set ent = entity_id %} + {% if ent.startswith('binary_sensor.') and ent.endswith('_stack_status') %} + {% set stack_key = ent | replace('binary_sensor.', '') | regex_replace('_stack_status$', '') %} + {{ expand('sensor.' ~ stack_key ~ '_stack_containers_count') | count > 0 }} + {% else %} + false + {% endif %} sequence: - - service: script.docker_container_repairs_sync - data: - entity_id: "{{ entity_id }}" - operation: "clear" + - variables: + down_states: ['off', 'unknown', 'unavailable'] + - choose: + - conditions: "{{ new_state in down_states and old_state not in down_states }}" + sequence: + - service: script.docker_stack_repairs_sync + data: + entity_id: "{{ entity_id }}" + operation: "create" + delay_minutes: 2 + - conditions: "{{ old_state in down_states and new_state not in down_states }}" + sequence: + - service: script.docker_stack_repairs_sync + data: + entity_id: "{{ entity_id }}" + operation: "clear" - - alias: "Docker Stack State Sync - Repairs (Dynamic)" - id: docker_stack_state_sync_repairs_dynamic - description: "Detect Portainer stack status transitions and delegate Repairs sync." - mode: parallel + - alias: "Docker Repairs Reconcile" + id: docker_repairs_reconcile + description: "Reconcile stale container and stack Repairs issues on startup and every 55 minutes." + mode: queued trigger: - - platform: event - event_type: state_changed - condition: - - condition: template - value_template: >- - {% set ent = trigger.event.data.entity_id | default('') %} - {{ ent.startswith('binary_sensor.') and ent.endswith('_stack_status') }} - - condition: template - value_template: "{{ trigger.event.data.old_state is not none and trigger.event.data.new_state is not none }}" - - condition: template - value_template: "{{ trigger.event.data.old_state.state != trigger.event.data.new_state.state }}" - - condition: template - value_template: >- - {% set stack_key = trigger.event.data.entity_id | replace('binary_sensor.', '') | regex_replace('_stack_status$', '') %} - {{ expand('sensor.' ~ stack_key ~ '_stack_containers_count') | count > 0 }} + - platform: homeassistant + event: start + - platform: time_pattern + minutes: "/55" action: - variables: - down_states: ['off', 'unknown', 'unavailable'] - entity_id: "{{ trigger.event.data.entity_id }}" - old_state: "{{ trigger.event.data.old_state.state | lower }}" - new_state: "{{ trigger.event.data.new_state.state | lower }}" - - choose: - - conditions: "{{ new_state in down_states and old_state not in down_states }}" - sequence: - - service: script.docker_stack_repairs_sync - data: - entity_id: "{{ entity_id }}" - operation: "create" - delay_minutes: 2 - - conditions: "{{ old_state in down_states and new_state not in down_states }}" - sequence: - - service: script.docker_stack_repairs_sync - data: - entity_id: "{{ entity_id }}" - operation: "clear" + monitored_switches: "{{ state_attr('group.docker_monitored_containers', 'entity_id') | default([], true) }}" + - repeat: + for_each: "{{ monitored_switches }}" + sequence: + - service: script.docker_container_repairs_sync + data: + entity_id: "{{ repeat.item }}" + operation: "clear" + log_result: false + - variables: + stack_status_entities: >- + {% set ns = namespace(items=[]) %} + {% for item in states.binary_sensor %} + {% if item.entity_id is search('^binary_sensor\\..*_stack_status$') %} + {% set stack_key = item.entity_id | replace('binary_sensor.', '') | regex_replace('_stack_status$', '') %} + {% if expand('sensor.' ~ stack_key ~ '_stack_containers_count') | count > 0 %} + {% set ns.items = ns.items + [item.entity_id] %} + {% endif %} + {% endif %} + {% endfor %} + {{ ns.items }} + - repeat: + for_each: "{{ stack_status_entities }}" + sequence: + - service: script.docker_stack_repairs_sync + data: + entity_id: "{{ repeat.item }}" + operation: "clear" + log_result: false - alias: "Docker Containers Maintenance Prompt" id: docker_containers_maintenance_prompt @@ -736,6 +887,22 @@ automation: Maintenance snooze declined with {{ states('sensor.docker_containers_down_count') }} containers down ({{ states('sensor.docker_containers_down_list') }}). + - alias: "Docker Telemetry Template Refresh" + id: docker_telemetry_template_refresh + description: "Refresh dynamic docker telemetry templates that derive entity IDs at runtime." + mode: single + trigger: + - platform: time_pattern + minutes: "/1" + action: + - service: homeassistant.update_entity + target: + entity_id: + - sensor.docker_monitored_unavailable_count + - sensor.docker_containers_down_list + - sensor.docker_containers_down_count + - binary_sensor.docker_container_telemetry_degraded + - alias: "Docker Weekly Prune Unused Images" id: docker_weekly_prune_unused_images description: "Run weekly unguarded prune actions across Docker hosts." diff --git a/config/packages/tugtainer_updates.yaml b/config/packages/tugtainer_updates.yaml index 9146320a..d76a9fe2 100644 --- a/config/packages/tugtainer_updates.yaml +++ b/config/packages/tugtainer_updates.yaml @@ -9,6 +9,8 @@ # ------------------------------------------------------------------- # Notes: Expects JSON with title/message/type from the Tugtainer template. # Notes: Creates persistent notifications and stamps last-update time. +# Notes: Fires `tugtainer_available_detected` when report contains `### Available:`. +# Notes: Joanna dispatch cooldown is automation-local (24h) using last_triggered. # Notes: Blog post https://www.vcloudinfo.com/2026/02/tugtainer-docker-updates-home-assistant-notifications.html ###################################################################### @@ -34,6 +36,7 @@ automation: title: "{{ payload.title | default('Tugtainer update') }}" message: "{{ payload.message | default('Update event received') }}" event_type: "{{ payload.type | default('info') }}" + has_available_section: "{{ '### available:' in (message | lower) }}" full_message: >- {{ message }}{% if event_type %} ({{ event_type | upper }}){% endif %} action: @@ -46,3 +49,68 @@ automation: data: title: "{{ title }}" message: "{{ full_message }}" + - choose: + - conditions: + - condition: template + value_template: "{{ has_available_section }}" + sequence: + - event: tugtainer_available_detected + event_data: + title: "{{ title }}" + event_type: "{{ event_type }}" + message: "{{ message }}" + + - alias: "Tugtainer - Dispatch Joanna For Available Updates" + id: tugtainer_dispatch_joanna_for_available_updates + description: "Dispatch Joanna on Available updates with a 24-hour cooldown and no helper entities." + mode: single + trigger: + - platform: event + event_type: tugtainer_available_detected + variables: + title: "{{ trigger.event.data.title | default('Tugtainer update') }}" + message: "{{ trigger.event.data.message | default('Update event received') }}" + event_type: "{{ trigger.event.data.event_type | default('info') }}" + trigger_context: "HA automation tugtainer_dispatch_joanna_for_available_updates (Tugtainer - Dispatch Joanna For Available Updates)" + now_ts: "{{ as_timestamp(now()) | int(0) }}" + last_triggered_ts: >- + {% if this.attributes.last_triggered %} + {{ as_timestamp(this.attributes.last_triggered) | int(0) }} + {% else %} + 0 + {% endif %} + elapsed_seconds: "{{ (now_ts - last_triggered_ts) | int(0) if last_triggered_ts > 0 else 999999 }}" + cooldown_ok: "{{ last_triggered_ts == 0 or elapsed_seconds >= 86400 }}" + remaining_seconds: "{{ [86400 - elapsed_seconds, 0] | max }}" + action: + - choose: + - conditions: + - condition: template + value_template: "{{ cooldown_ok }}" + sequence: + - service: script.send_to_logbook + data: + topic: "DOCKER" + message: >- + Tugtainer reported Available container updates. Joanna dispatch requested. + - service: script.joanna_dispatch + data: + trigger_context: "{{ trigger_context }}" + source: "home_assistant_automation.tugtainer_dispatch_joanna_for_available_updates" + summary: "Tugtainer reported Available container updates requiring manual action" + entity_ids: + - "input_datetime.tugtainer_last_update" + diagnostics: >- + title={{ title }}, + event_type={{ event_type }}, + message={{ message }} + request: >- + Review the Tugtainer report and update all containers listed under the + Available section. Report what was updated and any failures. + default: + - service: script.send_to_logbook + data: + topic: "DOCKER" + message: >- + Tugtainer Available update dispatch suppressed (24h cooldown active; + {{ remaining_seconds }} seconds remaining).