148 files
This commit is contained in:
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)
|
||||
|
||||
Reference in new issue
Block a user