148 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-22 20:21:39 +00:00
1 parent 43021c1d3a
commit 94e9f32a43
148 files changed
+15528 -2013

No files matched your search

@@ -129,7 +129,7 @@ def _build_task_summary(
from homeassistant.helpers import entity_registry as er
from ..entity.triggers import normalize_entity_ids
from ..helpers.schedule import Schedule, read_legacy_fields
from ..helpers.schedule import KIND_CALENDAR, Schedule, read_legacy_fields
ct = coordinator_task or {}
@@ -139,6 +139,12 @@ def _build_task_summary(
# express the calendar kinds (weekdays / nth_weekday / day_of_month).
sched = read_legacy_fields(task_data)
schedule_obj = Schedule.parse(task_data).to_dict()
# #187: a calendar-entity schedule carries the calendar's friendly name so
# lists can read "Calendar: Bio waste" without a per-row state lookup.
schedule_entity_name: str | None = None
if schedule_obj.get("kind") == KIND_CALENDAR and isinstance(schedule_obj.get("entity_id"), str):
cal_state = hass.states.get(schedule_obj["entity_id"])
schedule_entity_name = cal_state.attributes.get("friendly_name", schedule_obj["entity_id"]) if cal_state else None
# Enrich trigger config with entity friendly name and state info
trigger_config = task_data.get("trigger_config")
@@ -189,6 +195,7 @@ def _build_task_summary(
# Nested recurrence object — the frontend reads this for the calendar
# kinds; the flat fields above remain for interval/one_time back-compat.
"schedule": schedule_obj,
**({"schedule_entity_name": schedule_entity_name} if schedule_entity_name is not None else {}),
"last_planned_due": task_data.get("last_planned_due"),
"schedule_time": task_data.get("schedule_time"),
"warning_days": task_data.get("warning_days", DEFAULT_WARNING_DAYS),
@@ -204,6 +211,8 @@ def _build_task_summary(
"allow_skip": task_data.get("allow_skip") is not False,
# #173: False = this task sends no reminders.
"notify_enabled": task_data.get("notify_enabled") is not False,
# #185: per-task push-notification icon override (None = type default).
"notify_icon": task_data.get("notify_icon"),
# v2.20 (#83): unit for `reading`-type tasks; values live in history.
"reading_unit": task_data.get("reading_unit"),
# #161 phase 2: reading slots [{id, name, unit}] ([] = single value).
@@ -611,7 +611,7 @@ async def ws_schedule_preview(
from homeassistant.util import dt as dt_util
from ..helpers.schedule import Schedule, preview_occurrences
from ..helpers.schedule import KIND_CALENDAR, Schedule, preview_occurrences
lp: date_cls | None = None
raw_lp = msg.get("last_performed")
@@ -625,6 +625,12 @@ async def ws_schedule_preview(
today = dt_util.now().date()
try:
sched = Schedule.from_dict(msg["schedule"])
if sched.kind == KIND_CALENDAR and sched.entity_id:
# #187: a calendar picked in the dialog is not in the coordinator
# cache yet — fetch its events now so the preview shows real dates.
from ..helpers.calendar_source import async_refresh_calendar_occurrences
await async_refresh_calendar_occurrences(hass, {sched.entity_id})
dates, series_ended = preview_occurrences(
sched,
last_performed=lp,
@@ -790,6 +790,8 @@ async def ws_import_json(
"require_tag_scan",
"allow_skip",
"notify_enabled",
# #185: notification icon override (shape-checked below).
"notify_icon",
"responsible_user_id",
"entity_slug",
"trigger_config",
@@ -973,6 +975,17 @@ async def ws_import_json(
else:
task_data.pop("mirror_todo_entities", None)
# #185: notify_icon — same shape rule as the WS write paths; a
# malformed or empty value drops the override (type default).
if "notify_icon" in task_data:
from ..helpers.notify_icons import normalize_icon
icon = normalize_icon(task_data["notify_icon"])
if icon:
task_data["notify_icon"] = icon
else:
task_data.pop("notify_icon", None)
# schedule_time: canonical HH:MM. The options flow's TimeSelector
# stores "HH:MM:SS" and the export writes it verbatim — that used
# to be DROPPED here (strict HH:MM), so a backup lost the time.
@@ -76,6 +76,10 @@ async def ws_adopt_problem_sensors(
Each selection attaches to its ``entry_id`` (an existing object) or, when
omitted, to a freshly created object named ``object_name`` and bound to the
sensor's ``device_id`` — so a second adoption on the same device reuses it.
Selections in the same batch that carry the same ``object_name`` share ONE
new object even when they belong to different devices (#188: "these four
sensors are one thing"); the dialog resolves an existing object's name to
its ``entry_id`` before sending, so the name match stays within the batch.
"""
from ..export import object_entries
from ..websocket.objects import async_create_object
@@ -89,6 +93,9 @@ async def ws_adopt_problem_sensors(
# Reuse an object created earlier in THIS batch for the same device, so two
# sensors on one device don't spawn two objects.
device_to_entry: dict[str, str] = {}
# #188: ...and for the same object NAME (case-insensitive) — the way the
# dialog lets the user say which sensors belong together.
name_to_entry: dict[str, str] = {}
errors: list[dict[str, str]] = []
for sel in selections:
@@ -96,9 +103,12 @@ async def ws_adopt_problem_sensors(
entry_id = sel.get("entry_id")
device_id = sel.get("device_id")
created_entry_id: str | None = None # object created in THIS iteration
group_name = str(sel.get("object_name") or "").strip().casefold()
try:
if not entry_id and device_id and device_id in device_to_entry:
entry_id = device_to_entry[device_id]
if not entry_id and group_name and group_name in name_to_entry:
entry_id = name_to_entry[group_name]
if not entry_id:
entry_id = await async_create_object(
hass,
@@ -109,6 +119,8 @@ async def ws_adopt_problem_sensors(
objects_created += 1
if device_id:
device_to_entry[device_id] = entry_id
if group_name:
name_to_entry[group_name] = entry_id
entry = hass.config_entries.async_get_entry(entry_id)
# Same guard as websocket._load_object_entry: the global settings
@@ -174,6 +186,8 @@ async def ws_adopt_problem_sensors(
objects_created -= 1
if device_id:
device_to_entry.pop(device_id, None)
if group_name:
name_to_entry.pop(group_name, None)
if hass.config_entries.async_get_entry(created_entry_id) is not None:
await hass.config_entries.async_remove(created_entry_id)
@@ -35,6 +35,7 @@ from ..const import (
MAX_MIRROR_TODO_LISTS,
MAX_NAME_LENGTH,
MAX_NFC_TAG_LENGTH,
MAX_NOTIFY_ICON_LENGTH,
MAX_READING_UNIT_LENGTH,
MAX_TEXT_LENGTH,
MAX_TYPE_LENGTH,
@@ -46,6 +47,7 @@ from ..const import (
from ..helpers.aggregate import get_store
from ..helpers.dates import INTERVAL_UNITS
from ..helpers.entry_tasks import write_task
from ..helpers.notify_icons import is_valid_icon
from ..helpers.permissions import require_write
from ..helpers.sanitize import strip_task_runtime_state
from ..helpers.schedule import (
@@ -94,6 +96,27 @@ def _validate_entity_slug(connection: websocket_api.ActiveConnection, msg: dict[
return False
def _validate_notify_icon(connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> bool:
"""False (after sending ``invalid_icon``) when ``msg["notify_icon"]`` is a
non-empty string that is not a well-formed ``mdi:`` name (#185). Strips
the value in place; ``None`` / ``""`` mean "no override" and pass.
Shared by create and update — the update path's raw field-map copy
would otherwise persist whatever the client sent."""
raw = msg.get("notify_icon")
if raw is None:
return True
icon = raw.strip() if isinstance(raw, str) else raw
msg["notify_icon"] = icon or None
if not icon or is_valid_icon(icon):
return True
connection.send_error(
msg["id"],
"invalid_icon",
"notify_icon must be an mdi: icon name (e.g. mdi:air-filter)",
)
return False
def _normalize_nfc_tag(
hass: HomeAssistant,
msg: dict[str, Any],
@@ -146,6 +169,8 @@ TASK_UPDATE_FIELD_MAP = {
"require_tag_scan": "require_tag_scan",
"allow_skip": "allow_skip",
"notify_enabled": "notify_enabled",
# #185: per-task push-notification icon override.
"notify_icon": "notify_icon",
"reading_unit": "reading_unit",
"readings": "readings",
"consumes_parts": "consumes_parts",
@@ -247,6 +272,9 @@ _TASK_CREATE_SCHEMA: dict[Any, Any] = {
# task (status changes, repeats, lead-time, bundles); the dashboard
# and entities still show it.
vol.Optional("notify_enabled"): vol.Any(bool, None),
# #185: per-task push-notification icon ("mdi:…"); null/"" = the
# maintenance type's default. Shape-checked by _validate_notify_icon.
vol.Optional("notify_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_NOTIFY_ICON_LENGTH)), None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
@@ -423,6 +451,12 @@ async def ws_create_task(
# #173: same shape — stored only when False (absence = notifications on).
if msg.get("notify_enabled") is False:
task_data["notify_enabled"] = False
# #185: per-task notification icon — stored only when set (absence = the
# maintenance type's default); a malformed value refuses the create.
if not _validate_notify_icon(connection, msg):
return
if msg.get("notify_icon"):
task_data["notify_icon"] = msg["notify_icon"]
# v2.20 (#83): unit for `reading`-type tasks.
if msg.get("reading_unit") is not None:
task_data["reading_unit"] = (msg["reading_unit"] or "").strip() or None
@@ -548,6 +582,9 @@ _TASK_UPDATE_SCHEMA: dict[Any, Any] = {
# task (status changes, repeats, lead-time, bundles); the dashboard
# and entities still show it.
vol.Optional("notify_enabled"): vol.Any(bool, None),
# #185: per-task push-notification icon ("mdi:…"); null/"" = the
# maintenance type's default. Shape-checked by _validate_notify_icon.
vol.Optional("notify_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_NOTIFY_ICON_LENGTH)), None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
@@ -628,6 +665,11 @@ async def ws_update_task(
if not _validate_entity_slug(connection, msg):
return
# #185: validate notify_icon BEFORE the raw field-map copy below (it
# would persist a malformed value verbatim — the create path refuses it).
if not _validate_notify_icon(connection, msg):
return
# Normalise empty NFC tag to None and check uniqueness
if "nfc_tag_id" in msg:
_normalize_nfc_tag(hass, msg, tc_warnings, exclude_task_id=task_id)
@@ -689,6 +731,10 @@ async def ws_update_task(
task["notify_enabled"] = False
else:
task.pop("notify_enabled", None)
# #185: notify_icon is stored only when set — null/"" clears the override
# (the verbatim copy above would persist a None literal).
if "notify_icon" in msg and not msg["notify_icon"]:
task.pop("notify_icon", None)
# D#183: an empty mirror list means "off" — drop the key, don't store [].
if "mirror_todo_entities" in msg and not msg["mirror_todo_entities"]:
task.pop("mirror_todo_entities", None)