diff --git a/config/.HA_VERSION b/config/.HA_VERSION index 0e459405..19f660a1 100755 --- a/config/.HA_VERSION +++ b/config/.HA_VERSION @@ -1 +1 @@ -2026.3.4 \ No newline at end of file +2026.4.0 \ No newline at end of file diff --git a/config/packages/bearclaw.yaml b/config/packages/bearclaw.yaml index 6f8bd81d..03ef3578 100644 --- a/config/packages/bearclaw.yaml +++ b/config/packages/bearclaw.yaml @@ -15,6 +15,7 @@ # Notes: Reply webhook writes JOANNA activity entries to logbook for traceability. # Notes: Status telemetry polling expects !secret bearclaw_status_url (token header stays !secret bearclaw_token). # Notes: Telegram freeform input now includes LLM-first routing context to improve intent understanding before entity lookups. +# Notes: Command payload supports async_only for automation-first queueing when immediate inline handling is not required. # Notes: Blog: https://www.vcloudinfo.com/2026/03/joanna-dispatch-telemetry-home-assistant-infrastructure-dashboard/ ###################################################################### @@ -22,6 +23,7 @@ rest_command: bearclaw_command: url: !secret bearclaw_command_url method: post + timeout: 30 content_type: application/json headers: x-codex-token: !secret bearclaw_token @@ -31,7 +33,8 @@ rest_command: "user": {{ user | default('carlo') | tojson }}, "source": {{ source | default('home_assistant') | tojson }}, "context": {{ context | default(none) | tojson }}, - "callback": {{ callback | default(none) | tojson }} + "callback": {{ callback | default(none) | tojson }}, + "async_only": {{ async_only | default(false) | tojson }} } bearclaw_ingest: diff --git a/config/packages/docker_infrastructure.yaml b/config/packages/docker_infrastructure.yaml index 608939d7..b86abd8a 100644 --- a/config/packages/docker_infrastructure.yaml +++ b/config/packages/docker_infrastructure.yaml @@ -275,10 +275,14 @@ template: {% set ent = item.entity_id %} {% if ent is search('^switch\\..*_container(?:_2)?$') %} {% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} - {% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0 - or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 - or expand('sensor.' ~ key ~ '_state') | count > 0 - or expand('sensor.' ~ key ~ '_state_2') | count > 0 %} + {% set has_companion = (expand('binary_sensor.' ~ key ~ '_status') | count > 0 + and states('binary_sensor.' ~ key ~ '_status') | lower not in ['unknown', 'unavailable', '']) + or (expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 + and states('binary_sensor.' ~ key ~ '_status_2') | lower not in ['unknown', 'unavailable', '']) + or (expand('sensor.' ~ key ~ '_state') | count > 0 + and states('sensor.' ~ key ~ '_state') | lower not in ['unknown', 'unavailable', '']) + or (expand('sensor.' ~ key ~ '_state_2') | count > 0 + and states('sensor.' ~ key ~ '_state_2') | lower not in ['unknown', 'unavailable', '']) %} {% set switch_state = states(ent) | lower %} {% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %} {% if ent not in ns.items %} @@ -295,10 +299,14 @@ template: {% set ent = item.entity_id %} {% if ent is search('^switch\\..*_container(?:_2)?$') %} {% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} - {% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0 - or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 - or expand('sensor.' ~ key ~ '_state') | count > 0 - or expand('sensor.' ~ key ~ '_state_2') | count > 0 %} + {% set has_companion = (expand('binary_sensor.' ~ key ~ '_status') | count > 0 + and states('binary_sensor.' ~ key ~ '_status') | lower not in ['unknown', 'unavailable', '']) + or (expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 + and states('binary_sensor.' ~ key ~ '_status_2') | lower not in ['unknown', 'unavailable', '']) + or (expand('sensor.' ~ key ~ '_state') | count > 0 + and states('sensor.' ~ key ~ '_state') | lower not in ['unknown', 'unavailable', '']) + or (expand('sensor.' ~ key ~ '_state_2') | count > 0 + and states('sensor.' ~ key ~ '_state_2') | lower not in ['unknown', 'unavailable', '']) %} {% set switch_state = states(ent) | lower %} {% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %} {% if ent not in ns.items %} @@ -316,10 +324,14 @@ template: {% set ent = item.entity_id %} {% if ent is search('^switch\\..*_container(?:_2)?$') %} {% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} - {% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0 - or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 - or expand('sensor.' ~ key ~ '_state') | count > 0 - or expand('sensor.' ~ key ~ '_state_2') | count > 0 %} + {% set has_companion = (expand('binary_sensor.' ~ key ~ '_status') | count > 0 + and states('binary_sensor.' ~ key ~ '_status') | lower not in ['unknown', 'unavailable', '']) + or (expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 + and states('binary_sensor.' ~ key ~ '_status_2') | lower not in ['unknown', 'unavailable', '']) + or (expand('sensor.' ~ key ~ '_state') | count > 0 + and states('sensor.' ~ key ~ '_state') | lower not in ['unknown', 'unavailable', '']) + or (expand('sensor.' ~ key ~ '_state_2') | count > 0 + and states('sensor.' ~ key ~ '_state_2') | lower not in ['unknown', 'unavailable', '']) %} {% set switch_state = states(ent) | lower %} {% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %} {% if ent not in discovered_ns.items %} @@ -343,10 +355,14 @@ template: {% set ent = item.entity_id %} {% if ent is search('^switch\\..*_container(?:_2)?$') %} {% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} - {% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0 - or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 - or expand('sensor.' ~ key ~ '_state') | count > 0 - or expand('sensor.' ~ key ~ '_state_2') | count > 0 %} + {% set has_companion = (expand('binary_sensor.' ~ key ~ '_status') | count > 0 + and states('binary_sensor.' ~ key ~ '_status') | lower not in ['unknown', 'unavailable', '']) + or (expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 + and states('binary_sensor.' ~ key ~ '_status_2') | lower not in ['unknown', 'unavailable', '']) + or (expand('sensor.' ~ key ~ '_state') | count > 0 + and states('sensor.' ~ key ~ '_state') | lower not in ['unknown', 'unavailable', '']) + or (expand('sensor.' ~ key ~ '_state_2') | count > 0 + and states('sensor.' ~ key ~ '_state_2') | lower not in ['unknown', 'unavailable', '']) %} {% set switch_state = states(ent) | lower %} {% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %} {% if ent not in discovered_ns.items %} @@ -730,7 +746,13 @@ script: effective_state_20m={{ persistent_effective_state }} request: >- Troubleshoot and resolve the persistent Docker container outage if possible. - Use Duplicati and the related host/container telemetry to verify recovery. + Reply with explicit status fields: + resolved=true/false, + root_cause, + action_taken, + verification (entity plus observed state), + next_action_required=true/false. + Use Duplicati and related host/container telemetry to verify recovery. - conditions: "{{ op == 'clear' }}" sequence: - variables: diff --git a/config/packages/vacuum.yaml b/config/packages/vacuum.yaml index f81062a0..45a7a2f4 100755 --- a/config/packages/vacuum.yaml +++ b/config/packages/vacuum.yaml @@ -12,7 +12,7 @@ # - Treat 2+ minutes in a room as "being cleaned" and dequeue immediately (queue = remaining rooms). # - Phase changes happen only after verified completion at dock (`task_status: completed`). # - Guarded fallback: if docked with empty queue for 10 minutes but no `completed`, advance with `fallback_advance` log. -# - Avoid reissuing `dreame_vacuum.vacuum_clean_segment` while already cleaning; only send a new segment job when starting/resuming or switching phases. +# - Use `vacuum.clean_area` (HA 2026.3+) and keep room->area mappings aligned with Home Assistant Areas. # - Jinja2 loop scoping: use a `namespace` when building lists (otherwise the queue can appear empty and get cleared). # - If docked+completed still has queue entries, treat queue as stale and clear it before phase advance. # - Mop phases use `sweeping_and_mopping` instead of mop-only. @@ -133,6 +133,30 @@ script: {{ bath_ids }} {% endif %} segments_to_clean: "{{ queue_ints if queue_ints | length > 0 else phase_segments }}" + segment_area_name_map: + 14: Kitchen + 12: "Dining Room" + 10: "Living Room" + 7: "Master Bedroom" + 15: Foyer + 9: "Stacey Office" + 13: Hallway + 8: "Justin Bedroom" + 6: "Paige Bedroom" + 4: "Master Bathroom" + 2: Office + 1: "Pool Bath" + 3: "Kids Bathroom" + cleaning_area_ids: > + {% set ns = namespace(ids=[]) %} + {% for seg in segments_to_clean %} + {% set area_name = segment_area_name_map.get(seg) %} + {% set aid = area_id(area_name) if area_name else none %} + {% if aid %} + {% set ns.ids = ns.ids + [aid] %} + {% endif %} + {% endfor %} + {{ ns.ids }} # 0. Reseed the current phase when queue is empty. - choose: @@ -168,6 +192,19 @@ script: - stop: 'No rooms left to clean today.' default: [] + # 2b. Clean-area needs a mapped Home Assistant area ID for every segment + - choose: + - conditions: + - condition: template + value_template: "{{ cleaning_area_ids | length != segments_to_clean | length }}" + sequence: + - service: script.send_to_logbook + data: + topic: "VACUUM" + message: "Missing area mappings for one or more segments {{ segments_to_clean }}; skipping clean_area." + - stop: "Incomplete Home Assistant area mappings." + default: [] + # 3. Start cleaning (but don't clobber an active job) - choose: - conditions: @@ -177,7 +214,7 @@ script: - service: script.send_to_logbook data: topic: "VACUUM" - message: "Vacuum is already cleaning; queue/phase updated but not issuing a new segment job." + message: "Vacuum is already cleaning; queue/phase updated but not issuing a new clean_area action." - stop: "Already cleaning." default: [] @@ -192,12 +229,12 @@ script: entity_id: vacuum.l10s_vacuum data: fan_speed: Standard - - service: dreame_vacuum.vacuum_clean_segment + - service: vacuum.clean_area target: entity_id: vacuum.l10s_vacuum data: - # Clean the non-bathrooms if any, otherwise clean the bathrooms - segments: "{{ segments_to_clean }}" + # Clean mapped Home Assistant areas for this phase queue. + cleaning_area_id: "{{ cleaning_area_ids }}" ## 3. Automations @@ -294,22 +331,24 @@ automation: id: kids_bathroom variables: room_map: - kitchen: {segment: 14, name: Kitchen} - dining_room: {segment: 12, name: 'Dining Room'} - living_room: {segment: 10, name: 'Living Room'} - master_bedroom: {segment: 7, name: 'Master Bedroom'} - foyer: {segment: 15, name: Foyer} - stacey_office: {segment: 9, name: 'Stacey Office'} - formal_dining: {segment: 17, name: 'Formal Dining'} - hallway: {segment: 13, name: Hallway} - justin_bedroom: {segment: 8, name: 'Justin Bedroom'} - paige_bedroom: {segment: 6, name: 'Paige Bedroom'} - master_bathroom: {segment: 4, name: 'Master Bathroom'} - office: {segment: 2, name: Office} - pool_bath: {segment: 1, name: 'Pool Bath'} - kids_bathroom: {segment: 3, name: 'Kids Bathroom'} + kitchen: {segment: 14, name: Kitchen, area: Kitchen} + dining_room: {segment: 12, name: 'Dining Room', area: 'Dining Room'} + living_room: {segment: 10, name: 'Living Room', area: 'Living Room'} + master_bedroom: {segment: 7, name: 'Master Bedroom', area: 'Master Bedroom'} + foyer: {segment: 15, name: Foyer, area: Foyer} + stacey_office: {segment: 9, name: 'Stacey Office', area: 'Stacey Office'} + formal_dining: {segment: 17, name: 'Formal Dining', area: 'Formal Dining'} + hallway: {segment: 13, name: Hallway, area: Hallway} + justin_bedroom: {segment: 8, name: 'Justin Bedroom', area: 'Justin Bedroom'} + paige_bedroom: {segment: 6, name: 'Paige Bedroom', area: 'Paige Bedroom'} + master_bathroom: {segment: 4, name: 'Master Bathroom', area: 'Master Bathroom'} + office: {segment: 2, name: Office, area: Office} + pool_bath: {segment: 1, name: 'Pool Bath', area: 'Pool Bath'} + kids_bathroom: {segment: 3, name: 'Kids Bathroom', area: 'Kids Bathroom'} room_key: "{{ trigger.id }}" room_name: "{{ room_map[room_key].name }}" + area_name: "{{ room_map[room_key].area }}" + area_id_value: "{{ area_id(area_name) if area_name else none }}" segment_id: "{{ room_map[room_key].segment | int }}" vac_state: "{{ states('vacuum.l10s_vacuum') }}" on_demand: "{{ is_state('input_boolean.l10s_vacuum_on_demand', 'on') }}" @@ -319,7 +358,7 @@ automation: - choose: - conditions: - condition: template - value_template: "{{ can_start }}" + value_template: "{{ can_start and area_id_value is not none }}" sequence: - service: script.send_to_logbook data: @@ -338,17 +377,17 @@ automation: data: fan_speed: Standard - continue_on_error: true - service: dreame_vacuum.vacuum_clean_segment + service: vacuum.clean_area target: entity_id: vacuum.l10s_vacuum data: - segments: "{{ [segment_id] }}" + cleaning_area_id: "{{ [area_id_value] }}" - delay: "00:00:02" default: - service: script.send_to_logbook data: topic: "VACUUM" - message: "One-off clean blocked: {{ room_name }} (vac={{ vac_state }}, on_demand={{ on_demand }}, queue='{{ queue_raw }}')." + message: "One-off clean blocked: {{ room_name }} (area={{ area_name }}, area_id={{ area_id_value }}, vac={{ vac_state }}, on_demand={{ on_demand }}, queue='{{ queue_raw }}')." - service: input_boolean.turn_off data: entity_id: "{{ trigger.entity_id }}" diff --git a/config/script/joanna_dispatch.yaml b/config/script/joanna_dispatch.yaml index 79517f6b..2ff92bdc 100644 --- a/config/script/joanna_dispatch.yaml +++ b/config/script/joanna_dispatch.yaml @@ -8,6 +8,7 @@ # ------------------------------------------------------------------- # Notes: Keep this helper generic so package automations can reuse one schema. # Notes: Source defaults to home_assistant_automation.unknown when omitted. +# Notes: Automation dispatches are async_only by default so HA calls return quickly while BearClaw works in queue. ###################################################################### joanna_dispatch: @@ -64,3 +65,4 @@ joanna_dispatch: user: "{{ normalized_user }}" source: "{{ normalized_source }}" context: "{{ normalized_context }}" + async_only: true