393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -37,11 +37,12 @@ from .const import (
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
slugify_object_name,
)
from .helpers.global_options import get_global_entry
from .helpers.i18n import normalize_language
from .helpers.schedule import normalize_task_storage
from .helpers.task_fields import WARNING_DAYS_RANGE
from .helpers.template_usage import OBJECT_TEMPLATE_ID
from .templates import (
TEMPLATE_CATEGORIES,
ObjectTemplate,
@@ -59,6 +60,35 @@ def _localized_template_default_name(template: ObjectTemplate, hass: HomeAssista
return localize_template_text(template.name, normalize_language(hass)) or template.name
# 2.94: the template list marks templates the home already has an object for
# (the gallery's "Already set up" badge). Recommended ones carry a ★, which
# the step descriptions explain — a symbol needs no translation.
_TEMPLATE_STRINGS: dict[str, dict[str, str]] = {
"en": {"set_up": "already set up"},
"de": {"set_up": "bereits eingerichtet"},
"cs": {"set_up": "již nastaveno"},
"da": {"set_up": "allerede oprettet"},
"es": {"set_up": "ya configurado"},
"fi": {"set_up": "jo käytössä"},
"fr": {"set_up": "déjà configuré"},
"hi": {"set_up": "पहले से सेट है"},
"hu": {"set_up": "már beállítva"},
"it": {"set_up": "già configurato"},
"ja": {"set_up": "設定済み"},
"ko": {"set_up": "이미 설정됨"},
"nb": {"set_up": "allerede satt opp"},
"nl": {"set_up": "al ingesteld"},
"pl": {"set_up": "już skonfigurowano"},
"pt": {"set_up": "já configurado"},
"pt-br": {"set_up": "já configurado"},
"ru": {"set_up": "уже настроено"},
"sv": {"set_up": "redan konfigurerad"},
"tr": {"set_up": "zaten kurulu"},
"uk": {"set_up": "уже налаштовано"},
"zh": {"set_up": "已设置"},
}
class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Maintenance Supporter."""
@@ -74,13 +104,26 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
self._trigger_entity_state: State | None = None
self._template_category: str = ""
self._selected_template: ObjectTemplate | None = None
self._template_marks: dict[str, dict[str, Any]] | None = None
async def _marks(self) -> dict[str, dict[str, Any]]:
"""Per template: recommended for this home / already set up / untypical
for the dwelling — the gallery's judgement (templates.recommend_template,
helpers.template_usage), worked out once per flow."""
if self._template_marks is None:
from .helpers.home_profile import async_home_profile
from .helpers.template_usage import templates_in_use
from .templates import TEMPLATES, recommend_template
profile = await async_home_profile(self.hass)
in_use = templates_in_use(self.hass)
self._template_marks = {t.id: recommend_template(t, profile, set_up=t.id in in_use) for t in TEMPLATES}
return self._template_marks
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the initial step."""
# Check if global entry exists
global_exists = any(entry.unique_id == GLOBAL_UNIQUE_ID for entry in self.hass.config_entries.async_entries(DOMAIN))
if not global_exists:
if get_global_entry(self.hass) is None:
return await self.async_step_global_setup()
return self.async_show_menu(
@@ -175,14 +218,18 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
self._template_category = user_input["template_category"]
return await self.async_step_template_select()
from .templates import get_disabled_template_ids
lang = normalize_language(self.hass)
options = [
selector.SelectOptionDict(
value=cat_id,
label=cat.get(f"name_{lang}", cat["name_en"]),
)
for cat_id, cat in TEMPLATE_CATEGORIES.items()
]
marks = await self._marks()
disabled = get_disabled_template_ids(self.hass)
options = []
for cat_id, cat in TEMPLATE_CATEGORIES.items():
label = cat.get(f"name_{lang}", cat["name_en"])
# 2.94: how many templates of the category fit this home (★ = the
# gallery's "Recommended for your home").
recommended = sum(1 for t in get_templates_by_category(cat_id) if t.id not in disabled and marks[t.id]["recommended"])
options.append(selector.SelectOptionDict(value=cat_id, label=f"{label} ★ {recommended}" if recommended else label))
return self.async_show_form(
step_id="create_from_template",
@@ -215,14 +262,20 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
lang = normalize_language(self.hass)
disabled = get_disabled_template_ids(self.hass)
marks = await self._marks()
templates = [t for t in get_templates_by_category(self._template_category) if t.id not in disabled]
options = [
selector.SelectOptionDict(
value=t.id,
label=localize_template_text(t.name, lang) or t.name,
)
for t in templates
]
# The gallery's order: what fits this home first, what the dwelling
# rarely has (a pool in an apartment) last; stable otherwise.
templates.sort(key=lambda t: 0 if marks[t.id]["recommended"] else 2 if marks[t.id]["dwelling_mismatch"] else 1)
set_up = _TEMPLATE_STRINGS.get(lang, _TEMPLATE_STRINGS["en"])["set_up"]
def label(t: ObjectTemplate) -> str:
name = localize_template_text(t.name, lang) or t.name
if marks[t.id]["set_up"]:
return f"{name} · ✓ {set_up}"
return f"★ {name}" if marks[t.id]["recommended"] else name
options = [selector.SelectOptionDict(value=t.id, label=label(t)) for t in templates]
return self.async_show_form(
step_id="template_select",
@@ -252,13 +305,10 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
name = user_input[CONF_OBJECT_NAME]
# Validate unique name (case-insensitive to match slug-based unique_id)
existing_names = [
entry.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "").lower()
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.unique_id != GLOBAL_UNIQUE_ID
]
if name.lower() in existing_names:
# Validate unique name (helpers.object_names: by slug, current names)
from .helpers.object_names import name_taken
if name_taken(self.hass, name):
errors[CONF_OBJECT_NAME] = "name_exists"
else:
# Build object data
@@ -269,6 +319,8 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
CONF_OBJECT_MANUFACTURER: user_input.get(CONF_OBJECT_MANUFACTURER),
CONF_OBJECT_MODEL: user_input.get(CONF_OBJECT_MODEL),
CONF_OBJECT_SERIAL_NUMBER: user_input.get(CONF_OBJECT_SERIAL_NUMBER),
# 2.94: templates in use are marked "already set up".
OBJECT_TEMPLATE_ID: template.id,
}
# Build tasks from template
@@ -277,29 +329,16 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
from .helpers.sanitize import cap_object_fields, cap_task_fields
today_iso = dt_util.now().date().isoformat()
from .templates import localize_template_text
from .templates import async_build_template_tasks
create_lang = normalize_language(self.hass)
self._tasks = {}
for tt in template.tasks:
task_id = uuid4().hex
task_data = {
"id": task_id,
"object_id": self._object_data["id"],
"name": localize_template_text(tt.name, create_lang),
"type": tt.type,
"enabled": True,
"schedule_type": tt.schedule_type,
"warning_days": tt.warning_days,
"history": [],
"created_at": today_iso,
}
if tt.interval_days is not None:
task_data["interval_days"] = tt.interval_days
if tt.notes:
task_data["notes"] = localize_template_text(tt.notes, create_lang)
# Seasons follow the home's hemisphere and climate.
self._tasks = await async_build_template_tasks(
self.hass, template, normalize_language(self.hass), self._object_data["id"]
)
for task_data in self._tasks.values():
# Server-managed, not user-editable (no parity concern).
task_data.update({"history": [], "created_at": today_iso})
cap_task_fields(task_data)
self._tasks[task_id] = task_data
cap_object_fields(self._object_data)
self._object_data["task_ids"] = list(self._tasks.keys())
@@ -328,13 +367,23 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
}
),
errors=errors,
description_placeholders={
"template_name": template.name,
"task_count": str(len(template.tasks)),
"task_list": ", ".join(t.name for t in template.tasks),
},
# 2.94: in the user's language, and the tasks THIS home gets
# (winter-only ones are left out without a cold season — the
# same list the builder below creates).
description_placeholders=await self._template_summary(template),
)
async def _template_summary(self, template: ObjectTemplate) -> dict[str, str]:
from .templates import async_home_template_tasks, localize_template_text
lang = normalize_language(self.hass)
tasks = await async_home_template_tasks(self.hass, template)
return {
"template_name": localize_template_text(template.name, lang) or template.name,
"task_count": str(len(tasks)),
"task_list": ", ".join(localize_template_text(t.name, lang) or t.name for t in tasks),
}
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Allow user to reconfigure object settings."""
entry = self._get_reconfigure_entry()
@@ -343,12 +392,13 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
if user_input is not None:
name = user_input[CONF_OBJECT_NAME]
# Validate unique name (skip self)
for other in self.hass.config_entries.async_entries(DOMAIN):
if other.entry_id != entry.entry_id and other.unique_id != GLOBAL_UNIQUE_ID:
if other.data.get(CONF_OBJECT, {}).get("name", "").lower() == name.lower():
errors["base"] = "name_exists"
break
# Validate unique name — only a CHANGED name: the successor of a
# replace shares its archived predecessor's name and must stay
# editable (bug audit 2026-09-27, helpers.object_names).
from .helpers.object_names import name_changed_onto_taken
if name_changed_onto_taken(self.hass, entry, name):
errors["base"] = "name_exists"
if not errors:
# Migrate name-slug-based unique_ids BEFORE overwriting the
@@ -367,6 +417,9 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
obj_data["documentation_url"] = user_input.get(CONF_OBJECT_DOCUMENTATION_URL) or None
# v1.4.10 (#46)
obj_data["notes"] = (user_input.get(CONF_OBJECT_NOTES) or "").strip() or None
from .helpers.sanitize import cap_object_fields
cap_object_fields(obj_data)
new_data = dict(entry.data)
new_data[CONF_OBJECT] = obj_data
@@ -436,9 +489,13 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
obj_data = dict(user_input.get(CONF_OBJECT, {}))
cap_object_fields(obj_data)
object_name = obj_data.get(CONF_OBJECT_NAME, "Unknown")
object_slug = slugify_object_name(object_name)
from .helpers.object_names import object_unique_id
await self.async_set_unique_id(f"maintenance_supporter_{object_slug}")
# A replacement may reuse the name of the object it retires.
unique_id = object_unique_id(self.hass, object_name, exclude_entry_id=obj_data.get("predecessor_entry_id"))
if unique_id is None:
return self.async_abort(reason="already_configured")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
obj_data.setdefault("task_ids", [])
@@ -455,7 +512,13 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
new_td = dict(td)
if "created_at" not in new_td:
new_td["created_at"] = today_iso
cap_task_fields(new_td)
# The completion action's owner survives: this step only receives
# server-built task dicts — object duplicate / replace copy stored
# tasks, and the JSON import stamps the importing admin itself
# (a file-supplied owner is never kept). Stripping it here made a
# copied operator action run with system rights (bug audit
# 2026-09-27).
cap_task_fields(new_td, keep_action_owner=True)
# Store recurrence in the canonical nested `schedule` shape — this is
# the CSV/JSON import chokepoint (schedule-model v2).
tasks[task_id] = normalize_task_storage(new_td)
@@ -492,13 +555,10 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
name = user_input[CONF_OBJECT_NAME]
# Validate unique name (case-insensitive to match slug-based unique_id)
existing_names = [
entry.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "").lower()
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.unique_id != GLOBAL_UNIQUE_ID
]
if name.lower() in existing_names:
# Validate unique name (helpers.object_names: by slug, current names)
from .helpers.object_names import name_taken
if name_taken(self.hass, name):
errors[CONF_OBJECT_NAME] = "name_exists"
else:
from .helpers.sanitize import cap_object_fields
@@ -793,9 +853,12 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
)
object_name = self._object_data.get(CONF_OBJECT_NAME, "Unknown")
object_slug = slugify_object_name(object_name)
from .helpers.object_names import object_unique_id
await self.async_set_unique_id(f"maintenance_supporter_{object_slug}")
unique_id = object_unique_id(self.hass, object_name)
if unique_id is None:
return self.async_abort(reason="already_configured")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
# Add task_ids to object