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