217 files

This commit is contained in:
Home Assistant Version Control
2026-07-30 23:59:38 +00:00
parent d43a63ad29
commit 7b5e46e702
217 changed files with 15978 additions and 3912 deletions
@@ -47,6 +47,7 @@ from .const import (
CONF_BUDGET_MONTHLY,
CONF_BUDGET_YEARLY,
CONF_GROUPS,
CONF_INSTALL_ASSIST_SENTENCES,
CONF_OBJECT,
CONF_PANEL_ENABLED,
CONF_TASKS,
@@ -58,6 +59,11 @@ from .const import (
GLOBAL_UNIQUE_ID,
MAX_COST,
MAX_DURATION_MINUTES,
MAX_LABEL_LENGTH,
MAX_LABELS,
MAX_NAME_LENGTH,
MAX_TEXT_LENGTH,
MAX_TYPE_LENGTH,
PLATFORMS,
SERVICE_ADD_OBJECT,
SERVICE_ADD_TASK,
@@ -81,10 +87,16 @@ from .const import (
from .coordinator import MaintenanceCoordinator
from .entity.summary_coordinator import MaintenanceSummaryCoordinator
from .frontend import async_register_card
from .helpers.assist_sentences import async_sync as async_sync_assist_sentences
from .helpers.dates import INTERVAL_UNITS
from .helpers.documents import DocumentStore
from .helpers.notification_manager import NotificationManager
from .helpers.schedule import normalize_task_storage
from .helpers.task_fields import (
INTERVAL_DAYS_RANGE,
TASK_PRIORITIES,
WARNING_DAYS_RANGE,
)
from .panel import async_register_panel, async_unregister_panel
from .storage import MaintenanceStore, async_migrate_to_store
from .websocket import async_register_commands
@@ -158,21 +170,29 @@ SERVICE_ADD_OBJECT_SCHEMA = vol.Schema(
}
)
# The service task schemas mirror the WS `task/create` / `task/update` schemas
# field-for-field, consuming the SAME constants + ranges (const.py /
# helpers.task_fields) rather than restating literals — the service path is
# just a third UI onto the same storage, so a value the WS API rejects must not
# be reachable from an automation. `vol.Coerce` stays (unlike the WS schemas)
# because YAML/templated service data arrives as strings.
SERVICE_ADD_TASK_SCHEMA = vol.Schema(
{
vol.Required("entry_id"): cv.string,
vol.Required("name"): vol.All(cv.string, vol.Length(min=1, max=255)),
vol.Optional("task_type"): cv.string,
vol.Optional("schedule_type"): cv.string,
vol.Optional("interval_days"): vol.All(vol.Coerce(int), vol.Range(min=1)),
vol.Required("name"): vol.All(cv.string, vol.Length(min=1, max=MAX_NAME_LENGTH)),
vol.Optional("task_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
vol.Optional("schedule_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
vol.Optional("interval_days"): vol.All(
vol.Coerce(int), vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])
),
vol.Optional("interval_unit"): vol.In(INTERVAL_UNITS),
vol.Optional("due_date"): cv.string,
# Nested recurrence for the calendar kinds (weekdays / nth_weekday /
# day_of_month); takes precedence over the flat fields above.
vol.Optional("schedule"): dict,
vol.Optional("warning_days"): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional("warning_days"): vol.All(vol.Coerce(int), vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
vol.Optional("enabled"): cv.boolean,
vol.Optional("notes"): cv.string,
vol.Optional("notes"): vol.All(cv.string, vol.Length(max=MAX_TEXT_LENGTH)),
}
)
@@ -180,18 +200,20 @@ SERVICE_UPDATE_TASK_SCHEMA = vol.Schema(
{
vol.Required("entry_id"): cv.string,
vol.Required("task_id"): cv.string,
vol.Optional("name"): vol.All(cv.string, vol.Length(min=1, max=255)),
vol.Optional("task_type"): cv.string,
vol.Optional("schedule_type"): cv.string,
vol.Optional("interval_days"): vol.All(vol.Coerce(int), vol.Range(min=1)),
vol.Optional("name"): vol.All(cv.string, vol.Length(min=1, max=MAX_NAME_LENGTH)),
vol.Optional("task_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
vol.Optional("schedule_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
vol.Optional("interval_days"): vol.All(
vol.Coerce(int), vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])
),
vol.Optional("interval_unit"): vol.In(INTERVAL_UNITS),
vol.Optional("due_date"): cv.string,
vol.Optional("schedule"): dict,
vol.Optional("warning_days"): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional("warning_days"): vol.All(vol.Coerce(int), vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
vol.Optional("enabled"): cv.boolean,
vol.Optional("notes"): cv.string,
vol.Optional("priority"): vol.In(["low", "normal", "high"]),
vol.Optional("labels"): [cv.string],
vol.Optional("notes"): vol.All(cv.string, vol.Length(max=MAX_TEXT_LENGTH)),
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
vol.Optional("labels"): vol.All([vol.All(cv.string, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)),
}
)
@@ -702,7 +724,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
try:
if action_type == "complete":
_LOGGER.info("Completing task %s via notification action", task_id)
await runtime_data.coordinator.complete_maintenance(task_id=task_id)
await runtime_data.coordinator.complete_maintenance(task_id=task_id, unattended=True)
elif action_type == "skip":
_LOGGER.info("Skipping task %s via notification action", task_id)
await runtime_data.coordinator.skip_maintenance(task_id=task_id, reason="Skipped from notification")
@@ -711,6 +733,17 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
if nm is not None:
nm.snooze_task(entry_id, task_id)
return # Snooze: keep notification visible for later reminder
except ServiceValidationError as err:
# The task demands details a notification button cannot capture.
# Not an error in our code — log it plainly and deliberately do
# NOT dismiss the notification, so the reminder survives until the
# user completes the task properly in the panel.
_LOGGER.warning(
"Notification completion of task %s rejected: %s",
task_id,
err,
)
return
except Exception:
_LOGGER.exception(
"Failed to handle notification action %s for task %s",
@@ -755,11 +788,18 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
tag_id,
)
user_id = event.context.user_id if event.context else None
await runtime_data.coordinator.complete_maintenance(
task_id=task_id,
completed_by=user_id,
notes="Completed via NFC tag",
)
try:
await runtime_data.coordinator.complete_maintenance(
task_id=task_id,
completed_by=user_id,
notes="Completed via NFC tag",
unattended=True,
)
except ServiceValidationError as err:
# A tag tap cannot capture a cost or a photo. Log it
# plainly and leave the task open so the user finishes it
# in the panel — the honest outcome, not a silent no-op.
_LOGGER.warning("NFC completion of task %s rejected: %s", task_id, err)
return
# No match found — not our tag, ignore silently
@@ -1074,6 +1114,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
# Listen for options changes (panel toggle)
entry.async_on_unload(entry.add_update_listener(_async_global_options_updated))
# Keep <config>/custom_sentences/ in step with the opt-in setting. Runs
# on every setup, not just on change, so an upgrade that ships new
# sentences reaches an install that already opted in.
await async_sync_assist_sentences(
hass, entry.options.get(CONF_INSTALL_ASSIST_SENTENCES, False)
)
# Initial orphan check for admin_panel_user_ids (HA users deleted
# while the integration was offline land here as repair issues).
await _check_admin_panel_user_orphans(hass, entry)
@@ -1305,6 +1352,10 @@ async def _async_global_options_updated(hass: HomeAssistant, entry: ConfigEntry)
await _check_admin_panel_user_orphans(hass, entry)
# The notify service may have just been (re)configured — re-check it.
_verify_notify_service(hass)
# Install or remove the Assist sentence files to match the setting.
await async_sync_assist_sentences(
hass, entry.options.get(CONF_INSTALL_ASSIST_SENTENCES, False)
)
_ORPHAN_ISSUE_PREFIX = "orphan_admin_panel_user_"
@@ -1466,6 +1517,16 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
# as a fixable repair issue so the user can restore it (#86).
_sync_missing_global_entry_issue(hass)
return
# #111: hand any shared spare-part pool to a borrower BEFORE the Store goes
# — the stock numbers exist nowhere else. Must happen here rather than in
# the panel's delete: HA's own Configure-UI removal never reaches that path.
from .helpers.shared_parts import async_transfer_pools_on_removal
try:
await async_transfer_pools_on_removal(hass, entry)
except Exception:
_LOGGER.exception("Could not transfer shared spare parts from %s", entry.title)
store = hass.data.get(STORES_CACHE_KEY, {}).pop(entry.entry_id, None)
if store is None:
store = MaintenanceStore(hass, entry.entry_id)