Skip to content

Commit c26c8bc

Browse files
committed
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.
1 parent 88e791e commit c26c8bc

5 files changed

Lines changed: 108 additions & 42 deletions

File tree

config/.HA_VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2026.3.4
1+
2026.4.0

config/packages/bearclaw.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
# Notes: Reply webhook writes JOANNA activity entries to logbook for traceability.
1616
# Notes: Status telemetry polling expects !secret bearclaw_status_url (token header stays !secret bearclaw_token).
1717
# Notes: Telegram freeform input now includes LLM-first routing context to improve intent understanding before entity lookups.
18+
# Notes: Command payload supports async_only for automation-first queueing when immediate inline handling is not required.
1819
# Notes: Blog: https://www.vcloudinfo.com/2026/03/joanna-dispatch-telemetry-home-assistant-infrastructure-dashboard/
1920
######################################################################
2021

2122
rest_command:
2223
bearclaw_command:
2324
url: !secret bearclaw_command_url
2425
method: post
26+
timeout: 30
2527
content_type: application/json
2628
headers:
2729
x-codex-token: !secret bearclaw_token
@@ -31,7 +33,8 @@ rest_command:
3133
"user": {{ user | default('carlo') | tojson }},
3234
"source": {{ source | default('home_assistant') | tojson }},
3335
"context": {{ context | default(none) | tojson }},
34-
"callback": {{ callback | default(none) | tojson }}
36+
"callback": {{ callback | default(none) | tojson }},
37+
"async_only": {{ async_only | default(false) | tojson }}
3538
}
3639
3740
bearclaw_ingest:

config/packages/docker_infrastructure.yaml

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -275,10 +275,14 @@ template:
275275
{% set ent = item.entity_id %}
276276
{% if ent is search('^switch\\..*_container(?:_2)?$') %}
277277
{% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %}
278-
{% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0
279-
or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0
280-
or expand('sensor.' ~ key ~ '_state') | count > 0
281-
or expand('sensor.' ~ key ~ '_state_2') | count > 0 %}
278+
{% set has_companion = (expand('binary_sensor.' ~ key ~ '_status') | count > 0
279+
and states('binary_sensor.' ~ key ~ '_status') | lower not in ['unknown', 'unavailable', ''])
280+
or (expand('binary_sensor.' ~ key ~ '_status_2') | count > 0
281+
and states('binary_sensor.' ~ key ~ '_status_2') | lower not in ['unknown', 'unavailable', ''])
282+
or (expand('sensor.' ~ key ~ '_state') | count > 0
283+
and states('sensor.' ~ key ~ '_state') | lower not in ['unknown', 'unavailable', ''])
284+
or (expand('sensor.' ~ key ~ '_state_2') | count > 0
285+
and states('sensor.' ~ key ~ '_state_2') | lower not in ['unknown', 'unavailable', '']) %}
282286
{% set switch_state = states(ent) | lower %}
283287
{% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %}
284288
{% if ent not in ns.items %}
@@ -295,10 +299,14 @@ template:
295299
{% set ent = item.entity_id %}
296300
{% if ent is search('^switch\\..*_container(?:_2)?$') %}
297301
{% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %}
298-
{% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0
299-
or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0
300-
or expand('sensor.' ~ key ~ '_state') | count > 0
301-
or expand('sensor.' ~ key ~ '_state_2') | count > 0 %}
302+
{% set has_companion = (expand('binary_sensor.' ~ key ~ '_status') | count > 0
303+
and states('binary_sensor.' ~ key ~ '_status') | lower not in ['unknown', 'unavailable', ''])
304+
or (expand('binary_sensor.' ~ key ~ '_status_2') | count > 0
305+
and states('binary_sensor.' ~ key ~ '_status_2') | lower not in ['unknown', 'unavailable', ''])
306+
or (expand('sensor.' ~ key ~ '_state') | count > 0
307+
and states('sensor.' ~ key ~ '_state') | lower not in ['unknown', 'unavailable', ''])
308+
or (expand('sensor.' ~ key ~ '_state_2') | count > 0
309+
and states('sensor.' ~ key ~ '_state_2') | lower not in ['unknown', 'unavailable', '']) %}
302310
{% set switch_state = states(ent) | lower %}
303311
{% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %}
304312
{% if ent not in ns.items %}
@@ -316,10 +324,14 @@ template:
316324
{% set ent = item.entity_id %}
317325
{% if ent is search('^switch\\..*_container(?:_2)?$') %}
318326
{% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %}
319-
{% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0
320-
or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0
321-
or expand('sensor.' ~ key ~ '_state') | count > 0
322-
or expand('sensor.' ~ key ~ '_state_2') | count > 0 %}
327+
{% set has_companion = (expand('binary_sensor.' ~ key ~ '_status') | count > 0
328+
and states('binary_sensor.' ~ key ~ '_status') | lower not in ['unknown', 'unavailable', ''])
329+
or (expand('binary_sensor.' ~ key ~ '_status_2') | count > 0
330+
and states('binary_sensor.' ~ key ~ '_status_2') | lower not in ['unknown', 'unavailable', ''])
331+
or (expand('sensor.' ~ key ~ '_state') | count > 0
332+
and states('sensor.' ~ key ~ '_state') | lower not in ['unknown', 'unavailable', ''])
333+
or (expand('sensor.' ~ key ~ '_state_2') | count > 0
334+
and states('sensor.' ~ key ~ '_state_2') | lower not in ['unknown', 'unavailable', '']) %}
323335
{% set switch_state = states(ent) | lower %}
324336
{% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %}
325337
{% if ent not in discovered_ns.items %}
@@ -343,10 +355,14 @@ template:
343355
{% set ent = item.entity_id %}
344356
{% if ent is search('^switch\\..*_container(?:_2)?$') %}
345357
{% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %}
346-
{% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0
347-
or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0
348-
or expand('sensor.' ~ key ~ '_state') | count > 0
349-
or expand('sensor.' ~ key ~ '_state_2') | count > 0 %}
358+
{% set has_companion = (expand('binary_sensor.' ~ key ~ '_status') | count > 0
359+
and states('binary_sensor.' ~ key ~ '_status') | lower not in ['unknown', 'unavailable', ''])
360+
or (expand('binary_sensor.' ~ key ~ '_status_2') | count > 0
361+
and states('binary_sensor.' ~ key ~ '_status_2') | lower not in ['unknown', 'unavailable', ''])
362+
or (expand('sensor.' ~ key ~ '_state') | count > 0
363+
and states('sensor.' ~ key ~ '_state') | lower not in ['unknown', 'unavailable', ''])
364+
or (expand('sensor.' ~ key ~ '_state_2') | count > 0
365+
and states('sensor.' ~ key ~ '_state_2') | lower not in ['unknown', 'unavailable', '']) %}
350366
{% set switch_state = states(ent) | lower %}
351367
{% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %}
352368
{% if ent not in discovered_ns.items %}
@@ -730,7 +746,13 @@ script:
730746
effective_state_20m={{ persistent_effective_state }}
731747
request: >-
732748
Troubleshoot and resolve the persistent Docker container outage if possible.
733-
Use Duplicati and the related host/container telemetry to verify recovery.
749+
Reply with explicit status fields:
750+
resolved=true/false,
751+
root_cause,
752+
action_taken,
753+
verification (entity plus observed state),
754+
next_action_required=true/false.
755+
Use Duplicati and related host/container telemetry to verify recovery.
734756
- conditions: "{{ op == 'clear' }}"
735757
sequence:
736758
- variables:

config/packages/vacuum.yaml

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# - Treat 2+ minutes in a room as "being cleaned" and dequeue immediately (queue = remaining rooms).
1313
# - Phase changes happen only after verified completion at dock (`task_status: completed`).
1414
# - Guarded fallback: if docked with empty queue for 10 minutes but no `completed`, advance with `fallback_advance` log.
15-
# - Avoid reissuing `dreame_vacuum.vacuum_clean_segment` while already cleaning; only send a new segment job when starting/resuming or switching phases.
15+
# - Use `vacuum.clean_area` (HA 2026.3+) and keep room->area mappings aligned with Home Assistant Areas.
1616
# - Jinja2 loop scoping: use a `namespace` when building lists (otherwise the queue can appear empty and get cleared).
1717
# - If docked+completed still has queue entries, treat queue as stale and clear it before phase advance.
1818
# - Mop phases use `sweeping_and_mopping` instead of mop-only.
@@ -133,6 +133,30 @@ script:
133133
{{ bath_ids }}
134134
{% endif %}
135135
segments_to_clean: "{{ queue_ints if queue_ints | length > 0 else phase_segments }}"
136+
segment_area_name_map:
137+
14: Kitchen
138+
12: "Dining Room"
139+
10: "Living Room"
140+
7: "Master Bedroom"
141+
15: Foyer
142+
9: "Stacey Office"
143+
13: Hallway
144+
8: "Justin Bedroom"
145+
6: "Paige Bedroom"
146+
4: "Master Bathroom"
147+
2: Office
148+
1: "Pool Bath"
149+
3: "Kids Bathroom"
150+
cleaning_area_ids: >
151+
{% set ns = namespace(ids=[]) %}
152+
{% for seg in segments_to_clean %}
153+
{% set area_name = segment_area_name_map.get(seg) %}
154+
{% set aid = area_id(area_name) if area_name else none %}
155+
{% if aid %}
156+
{% set ns.ids = ns.ids + [aid] %}
157+
{% endif %}
158+
{% endfor %}
159+
{{ ns.ids }}
136160
137161
# 0. Reseed the current phase when queue is empty.
138162
- choose:
@@ -168,6 +192,19 @@ script:
168192
- stop: 'No rooms left to clean today.'
169193
default: []
170194

195+
# 2b. Clean-area needs a mapped Home Assistant area ID for every segment
196+
- choose:
197+
- conditions:
198+
- condition: template
199+
value_template: "{{ cleaning_area_ids | length != segments_to_clean | length }}"
200+
sequence:
201+
- service: script.send_to_logbook
202+
data:
203+
topic: "VACUUM"
204+
message: "Missing area mappings for one or more segments {{ segments_to_clean }}; skipping clean_area."
205+
- stop: "Incomplete Home Assistant area mappings."
206+
default: []
207+
171208
# 3. Start cleaning (but don't clobber an active job)
172209
- choose:
173210
- conditions:
@@ -177,7 +214,7 @@ script:
177214
- service: script.send_to_logbook
178215
data:
179216
topic: "VACUUM"
180-
message: "Vacuum is already cleaning; queue/phase updated but not issuing a new segment job."
217+
message: "Vacuum is already cleaning; queue/phase updated but not issuing a new clean_area action."
181218
- stop: "Already cleaning."
182219
default: []
183220

@@ -192,12 +229,12 @@ script:
192229
entity_id: vacuum.l10s_vacuum
193230
data:
194231
fan_speed: Standard
195-
- service: dreame_vacuum.vacuum_clean_segment
232+
- service: vacuum.clean_area
196233
target:
197234
entity_id: vacuum.l10s_vacuum
198235
data:
199-
# Clean the non-bathrooms if any, otherwise clean the bathrooms
200-
segments: "{{ segments_to_clean }}"
236+
# Clean mapped Home Assistant areas for this phase queue.
237+
cleaning_area_id: "{{ cleaning_area_ids }}"
201238

202239

203240
## 3. Automations
@@ -294,22 +331,24 @@ automation:
294331
id: kids_bathroom
295332
variables:
296333
room_map:
297-
kitchen: {segment: 14, name: Kitchen}
298-
dining_room: {segment: 12, name: 'Dining Room'}
299-
living_room: {segment: 10, name: 'Living Room'}
300-
master_bedroom: {segment: 7, name: 'Master Bedroom'}
301-
foyer: {segment: 15, name: Foyer}
302-
stacey_office: {segment: 9, name: 'Stacey Office'}
303-
formal_dining: {segment: 17, name: 'Formal Dining'}
304-
hallway: {segment: 13, name: Hallway}
305-
justin_bedroom: {segment: 8, name: 'Justin Bedroom'}
306-
paige_bedroom: {segment: 6, name: 'Paige Bedroom'}
307-
master_bathroom: {segment: 4, name: 'Master Bathroom'}
308-
office: {segment: 2, name: Office}
309-
pool_bath: {segment: 1, name: 'Pool Bath'}
310-
kids_bathroom: {segment: 3, name: 'Kids Bathroom'}
334+
kitchen: {segment: 14, name: Kitchen, area: Kitchen}
335+
dining_room: {segment: 12, name: 'Dining Room', area: 'Dining Room'}
336+
living_room: {segment: 10, name: 'Living Room', area: 'Living Room'}
337+
master_bedroom: {segment: 7, name: 'Master Bedroom', area: 'Master Bedroom'}
338+
foyer: {segment: 15, name: Foyer, area: Foyer}
339+
stacey_office: {segment: 9, name: 'Stacey Office', area: 'Stacey Office'}
340+
formal_dining: {segment: 17, name: 'Formal Dining', area: 'Formal Dining'}
341+
hallway: {segment: 13, name: Hallway, area: Hallway}
342+
justin_bedroom: {segment: 8, name: 'Justin Bedroom', area: 'Justin Bedroom'}
343+
paige_bedroom: {segment: 6, name: 'Paige Bedroom', area: 'Paige Bedroom'}
344+
master_bathroom: {segment: 4, name: 'Master Bathroom', area: 'Master Bathroom'}
345+
office: {segment: 2, name: Office, area: Office}
346+
pool_bath: {segment: 1, name: 'Pool Bath', area: 'Pool Bath'}
347+
kids_bathroom: {segment: 3, name: 'Kids Bathroom', area: 'Kids Bathroom'}
311348
room_key: "{{ trigger.id }}"
312349
room_name: "{{ room_map[room_key].name }}"
350+
area_name: "{{ room_map[room_key].area }}"
351+
area_id_value: "{{ area_id(area_name) if area_name else none }}"
313352
segment_id: "{{ room_map[room_key].segment | int }}"
314353
vac_state: "{{ states('vacuum.l10s_vacuum') }}"
315354
on_demand: "{{ is_state('input_boolean.l10s_vacuum_on_demand', 'on') }}"
@@ -319,7 +358,7 @@ automation:
319358
- choose:
320359
- conditions:
321360
- condition: template
322-
value_template: "{{ can_start }}"
361+
value_template: "{{ can_start and area_id_value is not none }}"
323362
sequence:
324363
- service: script.send_to_logbook
325364
data:
@@ -338,17 +377,17 @@ automation:
338377
data:
339378
fan_speed: Standard
340379
- continue_on_error: true
341-
service: dreame_vacuum.vacuum_clean_segment
380+
service: vacuum.clean_area
342381
target:
343382
entity_id: vacuum.l10s_vacuum
344383
data:
345-
segments: "{{ [segment_id] }}"
384+
cleaning_area_id: "{{ [area_id_value] }}"
346385
- delay: "00:00:02"
347386
default:
348387
- service: script.send_to_logbook
349388
data:
350389
topic: "VACUUM"
351-
message: "One-off clean blocked: {{ room_name }} (vac={{ vac_state }}, on_demand={{ on_demand }}, queue='{{ queue_raw }}')."
390+
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 }}')."
352391
- service: input_boolean.turn_off
353392
data:
354393
entity_id: "{{ trigger.entity_id }}"

config/script/joanna_dispatch.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
# -------------------------------------------------------------------
99
# Notes: Keep this helper generic so package automations can reuse one schema.
1010
# Notes: Source defaults to home_assistant_automation.unknown when omitted.
11+
# Notes: Automation dispatches are async_only by default so HA calls return quickly while BearClaw works in queue.
1112
######################################################################
1213

1314
joanna_dispatch:
@@ -64,3 +65,4 @@ joanna_dispatch:
6465
user: "{{ normalized_user }}"
6566
source: "{{ normalized_source }}"
6667
context: "{{ normalized_context }}"
68+
async_only: true

0 commit comments

Comments
 (0)