Update HA version to 2026.4.0, enhance BearClaw integration with async command payload support, improve vacuum area mapping for cleaning segments, and ensure async dispatch for Joanna automation.

This commit is contained in:
Carlo Costanzo
2026-04-02 12:24:06 -04:00
parent 88e791e23c
commit c26c8bc64d
5 changed files with 108 additions and 42 deletions
+1 -1
View File
@@ -1 +1 @@
2026.3.4
2026.4.0
+4 -1
View File
@@ -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:
+39 -17
View File
@@ -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:
+62 -23
View File
@@ -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 }}"
+2
View File
@@ -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