429 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-30 02:43:31 +00:00
1 parent 472c19bceb
commit 963cce81a7
566 files changed
+50311 -47616

No files matched your search

@@ -9,7 +9,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback, valid_entity_id
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import async_call_later
@@ -59,6 +59,7 @@ from ..const import (
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
CONF_REMINDER_LEAD_DAYS,
CONF_SHOPPING_LIST_ENTITY,
CONF_SNOOZE_DURATION_HOURS,
CONF_WARRANTY_REMINDER_DAYS,
CONF_WARRANTY_REMINDER_ENABLED,
@@ -149,6 +150,8 @@ def _build_full_settings(options: Mapping[str, Any], *, notify_targets: list[str
"default_warning_days": options.get(CONF_DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS),
"notifications_enabled": options.get(CONF_NOTIFICATIONS_ENABLED, False),
"notify_service": options.get(CONF_NOTIFY_SERVICE, ""),
# v2.67: buy-task shopping sync target ("" = off).
"shopping_list_entity": options.get(CONF_SHOPPING_LIST_ENTITY, ""),
# Shared pickable-target list so the panel picker can't drift from
# the options-flow dropdown (both go through build_notify_targets).
"notify_targets": notify_targets or [],
@@ -327,7 +330,7 @@ async def ws_subscribe(
deltas: bool = msg.get("deltas", False)
compact: bool = msg.get("compact", False)
attached_entry_ids: set[str] = set()
attached_entry_ids: dict[str, tuple[Any, Callable[[], None]]] = {}
unsub_callbacks: list[Callable[[], None]] = []
dirty: set[str] = set()
last_hash: dict[str, int] = {}
@@ -405,18 +408,33 @@ async def ws_subscribe(
debounce_unsub = async_call_later(hass, 1.0, _send_now)
def _attach_entry(entry_id: str) -> None:
"""Attach a coordinator listener for a specific entry."""
if entry_id in attached_entry_ids:
return
"""Attach a coordinator listener for a specific entry.
Every entry RELOAD (task create/edit/delete, options flow, ...) builds
a brand-new coordinator and re-announces the entry via
SIGNAL_NEW_OBJECT_ENTRY. An "already attached" early return kept this
subscription bound to the dead coordinator, so other clients froze
for that object until a page reload (bug audit 2026-08-29). Track
the coordinator instance and re-attach when it changed.
"""
rd = _get_runtime_data(hass, entry_id)
if rd and rd.coordinator:
coordinator = rd.coordinator if rd else None
if coordinator is None:
return
previous = attached_entry_ids.get(entry_id)
if previous is not None:
if previous[0] is coordinator:
return
previous[1]()
unsub_callbacks.remove(previous[1])
@callback
def _on_update(eid: str = entry_id) -> None:
_forward_update(eid)
@callback
def _on_update(eid: str = entry_id) -> None:
_forward_update(eid)
unsub_callbacks.append(rd.coordinator.async_add_listener(_on_update))
attached_entry_ids.add(entry_id)
unsub = coordinator.async_add_listener(_on_update)
unsub_callbacks.append(unsub)
attached_entry_ids[entry_id] = (coordinator, unsub)
# Register listeners on all existing coordinators
entries = _get_object_entries(hass)
@@ -677,6 +695,17 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
tids.append(v)
filtered[CONF_DISABLED_TEMPLATE_IDS] = tids
# Validate shopping_list_entity if provided: "" clears; otherwise it must
# be a todo.* entity id. Format-only — NO existence check (the list's
# integration may load later; the sync self-heals on first appearance).
if CONF_SHOPPING_LIST_ENTITY in filtered:
raw_ent = (filtered[CONF_SHOPPING_LIST_ENTITY] or "").strip()
if raw_ent and not valid_entity_id(raw_ent):
return filtered, "invalid_shopping_list_entity"
if raw_ent and not raw_ent.startswith("todo."):
return filtered, "invalid_shopping_list_entity"
filtered[CONF_SHOPPING_LIST_ENTITY] = raw_ent
# Validate notify_service if provided
if CONF_NOTIFY_SERVICE in filtered:
from ..config_flow_options_global import validate_notify_service