217 files
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user