182 files

This commit is contained in:
Home Assistant Version Control
2026-08-17 12:11:35 +00:00
parent 7dddf6bb13
commit ece15a1c1b
183 changed files with 11457 additions and 3092 deletions
+1 -1
View File
@@ -1 +1 @@
{"pid": 71, "version": 1, "ha_version": "2026.8.2", "start_ts": 1786865261.4982338} {"pid": 71, "version": 1, "ha_version": "2026.8.2", "start_ts": 1786968619.5823264}
+2 -2
View File
@@ -196,7 +196,7 @@
}, },
{ {
"id": "e92ef0caff41454e9f49ea966ed99e41", "id": "e92ef0caff41454e9f49ea966ed99e41",
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374101", "url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374102",
"type": "module" "type": "module"
}, },
{ {
@@ -315,7 +315,7 @@
"type": "module" "type": "module"
}, },
{ {
"id": "d79e36a45ffb434dae8af23a2404abe4", "id": "3af9295bc61240279b88bc700c1ef48a",
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1", "url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
"type": "module" "type": "module"
} }
+90 -14
View File
@@ -80,6 +80,25 @@ PLATFORMS = COORDINATOR_AWARE_PLATFORMS + [
Platform.SWITCH, Platform.SWITCH,
] ]
# Suffixes used when building gateway-attached entity unique_ids in sensor.py
# (e.g. f"{device.identifier.lower()}-{suffix}"). Used to migrate those
# unique_ids when the gateway's identifier changes from DSN-based to MAC-based.
GATEWAY_ENTITY_SUFFIXES = [
"defi_hilo",
"recompenses_hilo",
"notifications_hilo",
"outdoor_weather_hilo",
"hilo_gateway",
"hilo_rate_current",
"hilo_rate_low",
"hilo_rate_medium",
"hilo_rate_high",
"hilo_rate_access",
"hilo_rate_low_threshold",
"hilo_rate_reward_rate",
"hilo_cost_total",
]
@callback @callback
def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -124,9 +143,39 @@ def _async_register_custom_device(
) )
async def async_setup_entry( # noqa: C901 @callback
hass: HomeAssistant, entry: ConfigEntry def _async_migrate_gateway_device_identifier(
) -> bool: hass: HomeAssistant, entry: ConfigEntry, old_dsn: str | None, new_mac: str
) -> None:
"""Migrate the gateway device registry entry from DSN-based to MAC-based identity.
Preserves the device's registry entry (and thus its device_id, so entities,
dashboards, and automations referencing it stay attached) by renaming its
identifier in place instead of letting a new device get created.
"""
if not old_dsn or old_dsn == new_mac:
return
device_registry = dr.async_get(hass)
old_device = device_registry.async_get_device(identifiers={(DOMAIN, old_dsn)})
if old_device is None:
return # fresh install, or already migrated
new_device = device_registry.async_get_device(identifiers={(DOMAIN, new_mac)})
if new_device is not None and new_device.id != old_device.id:
LOG.warning(
"Gateway device already registered under new identifier %s, skipping device migration",
new_mac,
)
return
device_registry.async_update_device(
old_device.id, new_identifiers={(DOMAIN, new_mac)}
)
LOG.info("Migrated gateway device identifier %s -> %s", old_dsn, new_mac)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Hilo as config entry.""" """Set up Hilo as config entry."""
HiloFlowHandler.async_register_implementation( HiloFlowHandler.async_register_implementation(
hass, AuthCodeWithPKCEImplementation(hass) hass, AuthCodeWithPKCEImplementation(hass)
@@ -160,9 +209,7 @@ async def async_setup_entry( # noqa: C901
_async_standardize_config_entry(hass, entry) _async_standardize_config_entry(hass, entry)
scan_interval = current_options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) scan_interval = current_options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
scan_interval = ( scan_interval = max(scan_interval, MIN_SCAN_INTERVAL)
scan_interval if scan_interval >= MIN_SCAN_INTERVAL else MIN_SCAN_INTERVAL
)
hilo = Hilo(hass, entry, api) hilo = Hilo(hass, entry, api)
try: try:
@@ -176,9 +223,7 @@ async def async_setup_entry( # noqa: C901
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = hilo hass.data[DOMAIN][entry.entry_id] = hilo
hass.async_create_task( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
)
# Note (ic-dev21): This is a bit of a hack to rename some entities that were created with non-standard names in early versions # Note (ic-dev21): This is a bit of a hack to rename some entities that were created with non-standard names in early versions
# HA has changed the way they name entities linked to a device by default and this breaks the naming scheme of the gateway entities. # HA has changed the way they name entities linked to a device by default and this breaks the naming scheme of the gateway entities.
@@ -731,6 +776,29 @@ class Hilo:
) )
return self._events[event_id] return self._events[event_id]
async def _fetch_legacy_gateway_dsn(self, new_mac: str) -> str | None:
"""This function looks up the Hilo gateway device in the device registry and
returns its old DSN-based identifier if it exists. If it doesn't,
it returns Noneto use the MAC address instead."""
device_registry = dr.async_get(self._hass)
for device in device_registry.devices.values():
if device.manufacturer != "Hilo" or device.model != "EQ000017":
continue
for domain, identifier in device.identifiers:
if domain == DOMAIN and identifier != new_mac:
return identifier
return None
@callback
def async_migrate_gateway_entities(self, old_dsn: str | None, new_mac: str) -> None:
"""Migrate gateway-attached entity unique_ids from DSN-based to MAC-based."""
if not old_dsn or old_dsn == new_mac:
return
for suffix in GATEWAY_ENTITY_SUFFIXES:
old_unique_id = f"{old_dsn.lower()}-{suffix}"
new_unique_id = f"{new_mac.lower()}-{suffix}"
self.async_migrate_unique_id(old_unique_id, new_unique_id, Platform.SENSOR)
async def async_init(self, scan_interval: int) -> None: async def async_init(self, scan_interval: int) -> None:
"""Initialize the Hilo "manager" class. """Initialize the Hilo "manager" class.
@@ -772,10 +840,18 @@ class Hilo:
) )
) )
# Step 6: Register custom devices in HA # Step 6: Migrate gateway identity (DSN -> MAC) if needed, then register
_async_register_custom_device( # custom devices in HA.
self._hass, self.entry, self.devices.find_device(1) gateway = self.devices.find_device(1)
) if gateway:
old_dsn = await self._fetch_legacy_gateway_dsn(gateway.identifier)
if old_dsn:
_async_migrate_gateway_device_identifier(
self._hass, self.entry, old_dsn, gateway.identifier
)
self.async_migrate_gateway_entities(old_dsn, gateway.identifier)
_async_register_custom_device(self._hass, self.entry, gateway)
if self.track_unknown_sources: if self.track_unknown_sources:
if not self.unknown_tracker_device: if not self.unknown_tracker_device:
self.unknown_tracker_device = self.devices.generate_device( self.unknown_tracker_device = self.devices.generate_device(
@@ -1122,7 +1198,7 @@ class Hilo:
ATTR_UNIT_OF_MEASUREMENT: parent_unit, # note ic-dev21: now uses parent_unit directly ATTR_UNIT_OF_MEASUREMENT: parent_unit, # note ic-dev21: now uses parent_unit directly
ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY,
} }
if not all(a in attrs.keys() for a in new_attrs.keys()): if not all(a in attrs.keys() for a in new_attrs):
LOG.warning( LOG.warning(
f"Fixing utility sensor: {entity} {current_state} new_attrs: {new_attrs}" f"Fixing utility sensor: {entity} {current_state} new_attrs: {new_attrs}"
) )
+1 -1
View File
@@ -12,5 +12,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"issue_tracker": "https://github.com/dvd-dev/hilo/issues", "issue_tracker": "https://github.com/dvd-dev/hilo/issues",
"requirements": ["python-hilo>=2026.3.5"], "requirements": ["python-hilo>=2026.3.5"],
"version": "2026.8.1" "version": "2026.8.2"
} }
+2 -2
View File
@@ -15,7 +15,6 @@ from homeassistant.components.sensor import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
CURRENCY_DOLLAR, CURRENCY_DOLLAR,
PERCENTAGE, PERCENTAGE,
@@ -24,6 +23,7 @@ from homeassistant.const import (
Platform, Platform,
UnitOfEnergy, UnitOfEnergy,
UnitOfPower, UnitOfPower,
UnitOfRatio,
UnitOfSoundPressure, UnitOfSoundPressure,
UnitOfTemperature, UnitOfTemperature,
__short_version__ as current_version, __short_version__ as current_version,
@@ -271,7 +271,7 @@ class Co2Sensor(HiloEntity, SensorEntity):
"""Define a Co2 sensor entity.""" """Define a Co2 sensor entity."""
_attr_device_class = SensorDeviceClass.CO2 _attr_device_class = SensorDeviceClass.CO2
_attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION _attr_native_unit_of_measurement = UnitOfRatio.PARTS_PER_MILLION
_attr_state_class = SensorStateClass.MEASUREMENT _attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, hilo, device): def __init__(self, hilo, device):
@@ -88,9 +88,11 @@ from .const import (
from .coordinator import MaintenanceCoordinator from .coordinator import MaintenanceCoordinator
from .entity.summary_coordinator import MaintenanceSummaryCoordinator from .entity.summary_coordinator import MaintenanceSummaryCoordinator
from .frontend import async_register_card from .frontend import async_register_card
from .helpers.aggregate import object_name as aggregate_object_name
from .helpers.assist_sentences import async_sync as async_sync_assist_sentences from .helpers.assist_sentences import async_sync as async_sync_assist_sentences
from .helpers.dates import INTERVAL_UNITS from .helpers.dates import INTERVAL_UNITS
from .helpers.documents import DocumentStore from .helpers.documents import DocumentStore
from .helpers.global_options import get_global_entry
from .helpers.notification_manager import NotificationManager from .helpers.notification_manager import NotificationManager
from .helpers.schedule import normalize_task_storage from .helpers.schedule import normalize_task_storage
from .helpers.task_fields import ( from .helpers.task_fields import (
@@ -252,10 +254,7 @@ async def async_maybe_send_weekly_digest(hass: HomeAssistant, *, force: bool = F
if not force and dt_util.now().weekday() != 0: # Monday only if not force and dt_util.now().weekday() != 0: # Monday only
return return
global_entry = next( global_entry = get_global_entry(hass)
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
None,
)
if global_entry is None: if global_entry is None:
return return
options = global_entry.options or global_entry.data options = global_entry.options or global_entry.data
@@ -290,10 +289,7 @@ async def async_maybe_send_warranty_reminders(hass: HomeAssistant, *, force: boo
DEFAULT_WARRANTY_REMINDER_DAYS, DEFAULT_WARRANTY_REMINDER_DAYS,
) )
global_entry = next( global_entry = get_global_entry(hass)
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
None,
)
if global_entry is None: if global_entry is None:
return return
options = global_entry.options or global_entry.data options = global_entry.options or global_entry.data
@@ -314,7 +310,7 @@ async def async_maybe_send_warranty_reminders(hass: HomeAssistant, *, force: boo
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
if delta == days or (force and 0 <= delta <= days): if delta == days or (force and 0 <= delta <= days):
names.append(obj.get("name") or entry.title) names.append(aggregate_object_name(entry))
if not names: if not names:
return return
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY) nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
@@ -336,10 +332,7 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None:
from .const import CONF_REMINDER_LEAD_DAYS from .const import CONF_REMINDER_LEAD_DAYS
from .models.maintenance_task import MaintenanceTask from .models.maintenance_task import MaintenanceTask
global_entry = next( global_entry = get_global_entry(hass)
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
None,
)
if global_entry is None: if global_entry is None:
return return
options = global_entry.options or global_entry.data options = global_entry.options or global_entry.data
@@ -368,7 +361,7 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None:
continue continue
# Merged data so last_performed reflects the Store, not stale entry data. # Merged data so last_performed reflects the Store, not stale entry data.
merged = coordinator._get_merged_tasks_data() merged = coordinator._get_merged_tasks_data()
obj_name = obj.get("name") or entry.title obj_name = aggregate_object_name(entry)
for task_id, task_data in merged.items(): for task_id, task_data in merged.items():
if not task_data.get("enabled", True): if not task_data.get("enabled", True):
continue continue
@@ -678,7 +671,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
coordinator = getattr(rd, "coordinator", None) if rd else None coordinator = getattr(rd, "coordinator", None) if rd else None
if coordinator is None or not coordinator.data: if coordinator is None or not coordinator.data:
continue continue
object_name = ce.data.get(CONF_OBJECT, {}).get("name", ce.title) object_name = aggregate_object_name(ce)
for task_id, task in coordinator.data.get(CONF_TASKS, {}).items(): for task_id, task in coordinator.data.get(CONF_TASKS, {}).items():
status = str(task.get("_status", "")) status = str(task.get("_status", ""))
if status == "archived": if status == "archived":
@@ -1345,7 +1338,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
is_fixable=True, is_fixable=True,
severity=ir.IssueSeverity.WARNING, severity=ir.IssueSeverity.WARNING,
translation_key="device_link_self" if self_link else "device_link_lost", translation_key="device_link_self" if self_link else "device_link_lost",
translation_placeholders={"object": obj_data.get("name") or entry.title}, translation_placeholders={"object": aggregate_object_name(entry)},
data={"entry_id": entry.entry_id}, data={"entry_id": entry.entry_id},
) )
else: else:
@@ -30,6 +30,7 @@ from .const import (
SIGNAL_TASK_RESET, SIGNAL_TASK_RESET,
MaintenanceStatus, MaintenanceStatus,
slugify_object_name, slugify_object_name,
task_unique_id,
) )
from .coordinator import MaintenanceCoordinator from .coordinator import MaintenanceCoordinator
from .entity.entity_base import MaintenanceEntity from .entity.entity_base import MaintenanceEntity
@@ -94,7 +95,7 @@ class MaintenanceBinarySensor(MaintenanceEntity, BinarySensorEntity):
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {}) task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
object_slug = slugify_object_name(obj_data.get("name", "unknown")) object_slug = slugify_object_name(obj_data.get("name", "unknown"))
self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}_overdue" self._attr_unique_id = task_unique_id(object_slug, task_id, "overdue")
entity_slug = task_data.get("entity_slug") entity_slug = task_data.get("entity_slug")
if entity_slug: if entity_slug:
@@ -21,6 +21,7 @@ from .const import (
CONF_TASKS, CONF_TASKS,
GLOBAL_UNIQUE_ID, GLOBAL_UNIQUE_ID,
slugify_object_name, slugify_object_name,
task_unique_id,
) )
from .coordinator import MaintenanceCoordinator from .coordinator import MaintenanceCoordinator
from .entity.entity_base import MaintenanceEntity from .entity.entity_base import MaintenanceEntity
@@ -89,7 +90,7 @@ class MaintenanceActionButton(MaintenanceEntity, ButtonEntity):
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {}) task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
object_slug = slugify_object_name(obj_data.get("name", "unknown")) object_slug = slugify_object_name(obj_data.get("name", "unknown"))
self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}_{action}" self._attr_unique_id = task_unique_id(object_slug, task_id, action)
self._attr_translation_key = f"button_{action}" self._attr_translation_key = f"button_{action}"
# Custom entity_slug → stable, language-independent entity_id/name. # Custom entity_slug → stable, language-independent entity_id/name.
entity_slug = task_data.get("entity_slug") entity_slug = task_data.get("entity_slug")
@@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import ( from .const import (
CONF_ADVANCED_SCHEDULE_TIME,
CONF_OBJECT, CONF_OBJECT,
CONF_TASKS, CONF_TASKS,
DOMAIN, DOMAIN,
@@ -20,7 +19,9 @@ from .const import (
MaintenanceStatus, MaintenanceStatus,
ScheduleType, ScheduleType,
) )
from .helpers.aggregate import merged_tasks
from .helpers.dates import interval_span_days from .helpers.dates import interval_span_days
from .helpers.global_options import is_schedule_time_enabled
from .helpers.i18n import normalize_language from .helpers.i18n import normalize_language
from .models.maintenance_task import MaintenanceTask from .models.maintenance_task import MaintenanceTask
@@ -521,9 +522,7 @@ class MaintenanceCalendar(CalendarEntity):
live_tasks = {} live_tasks = {}
# Merge static (ConfigEntry) + dynamic (Store) task data # Merge static (ConfigEntry) + dynamic (Store) task data
store = getattr(runtime_data, "store", None) if runtime_data else None tasks_data = merged_tasks(entry)
static_tasks = entry.data.get(CONF_TASKS, {})
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
for task_id, task_dict in tasks_data.items(): for task_id, task_dict in tasks_data.items():
task = MaintenanceTask.from_dict(task_dict) task = MaintenanceTask.from_dict(task_dict)
@@ -638,9 +637,5 @@ class MaintenanceCalendar(CalendarEntity):
) )
def _is_schedule_time_feature_enabled(self) -> bool: def _is_schedule_time_feature_enabled(self) -> bool:
"""Lookup the global advanced flag — same approach as coordinator.""" """Lookup the global advanced flag — same source as the coordinator."""
for ce in self._hass.config_entries.async_entries(DOMAIN): return is_schedule_time_enabled(self._hass)
if ce.unique_id == GLOBAL_UNIQUE_ID:
opts = ce.options or ce.data
return bool(opts.get(CONF_ADVANCED_SCHEDULE_TIME, False))
return False
@@ -16,14 +16,8 @@ from homeassistant.config_entries import (
from homeassistant.core import HomeAssistant, State, callback from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import selector from homeassistant.helpers import selector
from .config_flow_helpers import (
CALENDAR_KIND_VALUES,
apply_interval_unit,
calendar_schema,
interval_unit_selector,
schedule_from_calendar_input,
)
from .config_flow_options_global import validate_notify_service from .config_flow_options_global import validate_notify_service
from .config_flow_schedule import ScheduleStepsMixin
from .config_flow_trigger import TriggerConfigMixin from .config_flow_trigger import TriggerConfigMixin
from .const import ( from .const import (
CONF_DEFAULT_WARNING_DAYS, CONF_DEFAULT_WARNING_DAYS,
@@ -39,30 +33,15 @@ from .const import (
CONF_OBJECT_NOTES, CONF_OBJECT_NOTES,
CONF_OBJECT_SERIAL_NUMBER, CONF_OBJECT_SERIAL_NUMBER,
CONF_OBJECT_WARRANTY_EXPIRY, CONF_OBJECT_WARRANTY_EXPIRY,
CONF_TASK_DUE_DATE,
CONF_TASK_ICON,
CONF_TASK_INTERVAL_DAYS,
CONF_TASK_INTERVAL_UNIT,
CONF_TASK_LABELS_TEXT,
CONF_TASK_NAME,
CONF_TASK_NOTES,
CONF_TASK_PRIORITY,
CONF_TASK_SCHEDULE_TYPE,
CONF_TASK_TYPE,
CONF_TASK_WARNING_DAYS,
CONF_TASKS, CONF_TASKS,
DEFAULT_INTERVAL_DAYS,
DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS,
DOMAIN, DOMAIN,
GLOBAL_UNIQUE_ID, GLOBAL_UNIQUE_ID,
MaintenanceTypeEnum,
ScheduleType,
slugify_object_name, slugify_object_name,
) )
from .helpers.global_options import get_default_warning_days
from .helpers.i18n import normalize_language from .helpers.i18n import normalize_language
from .helpers.schedule import KIND_WEEKDAYS, normalize_task_storage from .helpers.schedule import normalize_task_storage
from .helpers.task_fields import INTERVAL_DAYS_RANGE, TASK_PRIORITIES, WARNING_DAYS_RANGE from .helpers.task_fields import WARNING_DAYS_RANGE
from .templates import ( from .templates import (
TEMPLATE_CATEGORIES, TEMPLATE_CATEGORIES,
ObjectTemplate, ObjectTemplate,
@@ -80,7 +59,7 @@ def _localized_template_default_name(template: ObjectTemplate, hass: HomeAssista
return localize_template_text(template.name, normalize_language(hass)) or template.name return localize_template_text(template.name, normalize_language(hass)) or template.name
class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMAIN): class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Maintenance Supporter.""" """Handle a config flow for Maintenance Supporter."""
VERSION = 1 VERSION = 1
@@ -588,79 +567,16 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA
async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Add a maintenance task.""" """Add a maintenance task."""
if user_input is not None: return await self._schedule_add_task(
if user_input.get("go_back"): user_input,
return await self.async_step_task_menu()
self._current_task = {
"id": uuid4().hex,
CONF_TASK_NAME: user_input[CONF_TASK_NAME],
CONF_TASK_TYPE: user_input[CONF_TASK_TYPE],
CONF_TASK_SCHEDULE_TYPE: user_input[CONF_TASK_SCHEDULE_TYPE],
}
if user_input.get(CONF_TASK_ICON):
self._current_task[CONF_TASK_ICON] = user_input[CONF_TASK_ICON]
if user_input.get(CONF_TASK_PRIORITY):
self._current_task[CONF_TASK_PRIORITY] = user_input[CONF_TASK_PRIORITY]
if user_input.get(CONF_TASK_LABELS_TEXT):
self._current_task[CONF_TASK_LABELS_TEXT] = user_input[CONF_TASK_LABELS_TEXT]
schedule = user_input[CONF_TASK_SCHEDULE_TYPE]
if schedule == ScheduleType.TIME_BASED:
return await self.async_step_time_based()
if schedule in CALENDAR_KIND_VALUES:
return await self.async_step_calendar()
if schedule == ScheduleType.SENSOR_BASED:
return await self.async_step_sensor_select()
if schedule == ScheduleType.ONE_TIME:
return await self.async_step_one_time()
# Manual
return await self.async_step_manual()
type_options = [t.value for t in MaintenanceTypeEnum]
schedule_options = [
ScheduleType.TIME_BASED,
*CALENDAR_KIND_VALUES,
ScheduleType.SENSOR_BASED,
ScheduleType.ONE_TIME,
ScheduleType.MANUAL,
]
return self.async_show_form(
step_id="add_task", step_id="add_task",
data_schema=vol.Schema( on_go_back=self.async_step_task_menu,
{ time_based_step=self.async_step_time_based,
vol.Required(CONF_TASK_NAME): selector.TextSelector( calendar_step=self.async_step_calendar,
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) sensor_step=self.async_step_sensor_select,
), one_time_step=self.async_step_one_time,
vol.Required(CONF_TASK_TYPE, default=MaintenanceTypeEnum.CLEANING): selector.SelectSelector( manual_step=self.async_step_manual,
selector.SelectSelectorConfig( seed_id=True,
options=type_options,
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="maintenance_type",
)
),
vol.Required(CONF_TASK_SCHEDULE_TYPE, default=ScheduleType.TIME_BASED): selector.SelectSelector(
selector.SelectSelectorConfig(
options=schedule_options,
mode=selector.SelectSelectorMode.LIST,
translation_key="schedule_type",
)
),
vol.Optional(CONF_TASK_ICON): selector.IconSelector(),
vol.Optional(CONF_TASK_PRIORITY, default="normal"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(TASK_PRIORITIES),
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="task_priority",
)
),
vol.Optional(CONF_TASK_LABELS_TEXT): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
description_placeholders={ description_placeholders={
"object_name": self._object_data.get(CONF_OBJECT_NAME, ""), "object_name": self._object_data.get(CONF_OBJECT_NAME, ""),
}, },
@@ -668,131 +584,30 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA
async def async_step_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure time-based schedule.""" """Configure time-based schedule."""
errors: dict[str, str] = {} return await self._schedule_time_based(
user_input,
if user_input is not None:
if user_input.get("go_back"):
return await self.async_step_add_task()
interval = user_input.get(CONF_TASK_INTERVAL_DAYS, DEFAULT_INTERVAL_DAYS)
if interval <= 0:
errors[CONF_TASK_INTERVAL_DAYS] = "invalid_interval"
else:
self._current_task[CONF_TASK_INTERVAL_DAYS] = interval
apply_interval_unit(self._current_task, user_input)
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
last_performed = user_input.get("last_performed")
if last_performed:
self._current_task["last_performed"] = str(last_performed)
return self._save_task_and_return()
return self.async_show_form(
step_id="time_based", step_id="time_based",
data_schema=vol.Schema( on_go_back=self.async_step_add_task,
{ on_complete=self._save_task_and_return,
vol.Required(CONF_TASK_INTERVAL_DAYS, default=DEFAULT_INTERVAL_DAYS): selector.NumberSelector(
selector.NumberSelectorConfig(
min=INTERVAL_DAYS_RANGE[0],
max=INTERVAL_DAYS_RANGE[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
vol.Optional("last_performed"): selector.DateSelector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0],
max=WARNING_DAYS_RANGE[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
errors=errors,
) )
async def async_step_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure a calendar recurrence kind (weekdays / nth_weekday / """Configure a calendar recurrence kind (weekdays / nth_weekday /
day_of_month) during initial setup.""" day_of_month) during initial setup."""
errors: dict[str, str] = {} return await self._schedule_calendar(
kind = self._current_task.get(CONF_TASK_SCHEDULE_TYPE, KIND_WEEKDAYS) user_input,
if user_input is not None:
if user_input.get("go_back"):
return await self.async_step_add_task()
schedule = schedule_from_calendar_input(kind, user_input)
if schedule is None:
errors["base"] = "invalid_schedule"
else:
self._current_task["schedule"] = schedule
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
return self._save_task_and_return()
schema = calendar_schema(kind).extend(
{
vol.Optional(CONF_TASK_WARNING_DAYS, default=get_default_warning_days(self.hass)): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
)
return self.async_show_form(
step_id="calendar", step_id="calendar",
data_schema=schema, on_go_back=self.async_step_add_task,
errors=errors, on_complete=self._save_task_and_return,
) )
async def async_step_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure a one-time (non-recurring) task.""" """Configure a one-time (non-recurring) task."""
errors: dict[str, str] = {} return await self._schedule_one_time(
user_input,
if user_input is not None:
if user_input.get("go_back"):
return await self.async_step_add_task()
due_date = user_input.get(CONF_TASK_DUE_DATE)
if not due_date:
errors[CONF_TASK_DUE_DATE] = "invalid_due_date"
else:
self._current_task[CONF_TASK_DUE_DATE] = str(due_date)
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
return self._save_task_and_return()
return self.async_show_form(
step_id="one_time", step_id="one_time",
data_schema=vol.Schema( on_go_back=self.async_step_add_task,
{ on_complete=self._save_task_and_return,
vol.Required(CONF_TASK_DUE_DATE): selector.DateSelector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0],
max=WARNING_DAYS_RANGE[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
errors=errors,
) )
# --- Sensor trigger steps (thin wrappers delegating to TriggerConfigMixin) --- # --- Sensor trigger steps (thin wrappers delegating to TriggerConfigMixin) ---
@@ -954,40 +769,11 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA
async def async_step_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure manual schedule.""" """Configure manual schedule."""
if user_input is not None: return await self._schedule_manual(
if user_input.get("go_back"): user_input,
return await self.async_step_add_task()
self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.MANUAL
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
if user_input.get(CONF_TASK_NOTES):
self._current_task[CONF_TASK_NOTES] = user_input[CONF_TASK_NOTES]
return self._save_task_and_return()
return self.async_show_form(
step_id="manual", step_id="manual",
data_schema=vol.Schema( on_go_back=self.async_step_add_task,
{ on_complete=self._save_task_and_return,
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional(CONF_TASK_NOTES): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT,
multiline=True,
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
) )
async def async_step_finish(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_finish(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
@@ -1023,49 +809,19 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA
def _save_task_and_return(self) -> ConfigFlowResult: def _save_task_and_return(self) -> ConfigFlowResult:
"""Save the current task and return to task menu.""" """Save the current task and return to task menu."""
from homeassistant.util import dt as dt_util from .config_flow_schedule import build_new_task_record
from .helpers.sanitize import cap_task_fields, parse_labels_text
task_id = self._current_task.get("id", uuid4().hex) task_id = self._current_task.get("id", uuid4().hex)
task_data = { task_data = build_new_task_record(
"id": task_id, self._current_task,
"object_id": self._object_data.get("id", ""), task_id=task_id,
"name": self._current_task.get(CONF_TASK_NAME, ""), object_id=self._object_data.get("id", ""),
"type": self._current_task.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CUSTOM), hass=self.hass,
"enabled": True, # No entry (and thus no Store) exists yet during setup — history and
"schedule_type": self._current_task.get(CONF_TASK_SCHEDULE_TYPE, ScheduleType.TIME_BASED), # a backdated last_performed must ride entry.data.
"warning_days": self._current_task.get(CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)), seed_history=True,
"history": [], include_last_performed=True,
# Anchor for next_due fallback when last_performed is None (issue #30). )
"created_at": dt_util.now().date().isoformat(),
}
# Calendar kinds carry a pre-built nested schedule; create_entry
# normalizes it (treated as authoritative over the flat fields).
if "schedule" in self._current_task:
task_data["schedule"] = self._current_task["schedule"]
if CONF_TASK_INTERVAL_DAYS in self._current_task:
task_data["interval_days"] = int(self._current_task[CONF_TASK_INTERVAL_DAYS])
if CONF_TASK_INTERVAL_UNIT in self._current_task:
task_data["interval_unit"] = self._current_task[CONF_TASK_INTERVAL_UNIT]
if CONF_TASK_DUE_DATE in self._current_task:
task_data["due_date"] = self._current_task[CONF_TASK_DUE_DATE]
if "last_performed" in self._current_task:
task_data["last_performed"] = self._current_task["last_performed"]
if "trigger_config" in self._current_task:
task_data["trigger_config"] = self._current_task["trigger_config"]
if CONF_TASK_NOTES in self._current_task:
task_data["notes"] = self._current_task[CONF_TASK_NOTES]
if CONF_TASK_ICON in self._current_task:
task_data["custom_icon"] = self._current_task[CONF_TASK_ICON]
if CONF_TASK_PRIORITY in self._current_task:
task_data["priority"] = self._current_task[CONF_TASK_PRIORITY]
if self._current_task.get(CONF_TASK_LABELS_TEXT):
task_data["labels"] = parse_labels_text(self._current_task[CONF_TASK_LABELS_TEXT])
cap_task_fields(task_data)
self._tasks[task_id] = task_data self._tasks[task_id] = task_data
self._current_task = {} self._current_task = {}
@@ -157,6 +157,22 @@ def interval_unit_selector() -> selector.SelectSelector:
) )
def interval_anchor_selector() -> selector.SelectSelector:
"""Shared completion/planned anchor dropdown (issue #30).
Single source for the add-task time-based step (both flows via
ScheduleStepsMixin) and the task-edit form."""
return selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="completion", label="From completion date"),
selector.SelectOptionDict(value="planned", label="From planned date (no drift)"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
def apply_interval_unit(target: dict[str, Any], user_input: dict[str, Any]) -> None: def apply_interval_unit(target: dict[str, Any], user_input: dict[str, Any]) -> None:
"""Persist ``interval_unit`` from a flow step into ``target`` only when it """Persist ``interval_unit`` from a flow step into ``target`` only when it
differs from the implicit default ``days`` (keeps stored task dicts minimal). differs from the implicit default ``days`` (keeps stored task dicts minimal).
@@ -1,43 +1,16 @@
"""Add-task + schedule-kind steps (mixin).""" """Add-task + schedule-kind steps (mixin).
Thin wrappers: the step content lives in config_flow_schedule.ScheduleStepsMixin,
shared verbatim with the setup wizard (config_flow.py) so the two can't drift.
"""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import selector
from .config_flow_helpers import ( from .config_flow_schedule import ScheduleStepsMixin
CALENDAR_KIND_VALUES,
apply_interval_unit,
calendar_schema,
interval_unit_selector,
schedule_from_calendar_input,
)
from .const import (
CONF_TASK_DUE_DATE,
CONF_TASK_ICON,
CONF_TASK_INTERVAL_ANCHOR,
CONF_TASK_INTERVAL_DAYS,
CONF_TASK_INTERVAL_UNIT,
CONF_TASK_LABELS_TEXT,
CONF_TASK_NAME,
CONF_TASK_NOTES,
CONF_TASK_PRIORITY,
CONF_TASK_READING_UNIT,
CONF_TASK_SCHEDULE_TYPE,
CONF_TASK_TYPE,
CONF_TASK_WARNING_DAYS,
DEFAULT_INTERVAL_DAYS,
MaintenanceTypeEnum,
ScheduleType,
)
from .helpers.global_options import get_default_warning_days
from .helpers.schedule import (
KIND_WEEKDAYS,
)
from .helpers.task_fields import INTERVAL_DAYS_RANGE, TASK_PRIORITIES, WARNING_DAYS_RANGE
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
@@ -45,7 +18,7 @@ if TYPE_CHECKING:
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
class AddTaskMixin: class AddTaskMixin(ScheduleStepsMixin):
"""Add a new task and pick its schedule kind.""" """Add a new task and pick its schedule kind."""
# -- provided by the assembled MaintenanceOptionsFlow -- # -- provided by the assembled MaintenanceOptionsFlow --
@@ -58,257 +31,57 @@ class AddTaskMixin:
def async_show_form(self, **kwargs: Any) -> ConfigFlowResult: ... def async_show_form(self, **kwargs: Any) -> ConfigFlowResult: ...
async def async_step_opt_sensor_select(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: ... async def async_step_opt_sensor_select(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: ...
def _wire_add_task_callbacks(self) -> None:
"""Route the sensor-trigger sub-flow's completion/cancel to this flow."""
self._trigger_on_complete = self._save_new_task
self._on_cancel = self._show_init_menu
async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_add_task(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Add a new task — step 1: name, type, schedule.""" """Add a new task — step 1: name, type, schedule."""
if user_input is not None: return await self._schedule_add_task(
if user_input.get("go_back"): user_input,
return self._show_init_menu()
self._current_task = {
CONF_TASK_NAME: user_input[CONF_TASK_NAME],
CONF_TASK_TYPE: user_input.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CLEANING),
CONF_TASK_SCHEDULE_TYPE: user_input[CONF_TASK_SCHEDULE_TYPE],
}
if user_input.get(CONF_TASK_ICON):
self._current_task[CONF_TASK_ICON] = user_input[CONF_TASK_ICON]
if user_input.get(CONF_TASK_PRIORITY):
self._current_task[CONF_TASK_PRIORITY] = user_input[CONF_TASK_PRIORITY]
if user_input.get(CONF_TASK_LABELS_TEXT):
self._current_task[CONF_TASK_LABELS_TEXT] = user_input[CONF_TASK_LABELS_TEXT]
if user_input.get(CONF_TASK_READING_UNIT):
self._current_task[CONF_TASK_READING_UNIT] = user_input[CONF_TASK_READING_UNIT].strip()
self._trigger_on_complete = self._save_new_task
self._on_cancel = self._show_init_menu
schedule = user_input[CONF_TASK_SCHEDULE_TYPE]
if schedule == ScheduleType.TIME_BASED:
return await self.async_step_opt_time_based()
if schedule in CALENDAR_KIND_VALUES:
return await self.async_step_opt_calendar()
if schedule == ScheduleType.SENSOR_BASED:
return await self.async_step_opt_sensor_select()
if schedule == ScheduleType.ONE_TIME:
return await self.async_step_opt_one_time()
# Manual
return await self.async_step_opt_manual()
type_options = [t.value for t in MaintenanceTypeEnum]
# Recurrence kinds: time-based, the calendar kinds (Phase 4), then the
# trigger/one-time/manual kinds.
schedule_options = [
ScheduleType.TIME_BASED,
*CALENDAR_KIND_VALUES,
ScheduleType.SENSOR_BASED,
ScheduleType.ONE_TIME,
ScheduleType.MANUAL,
]
return self.async_show_form(
step_id="add_task", step_id="add_task",
data_schema=vol.Schema( on_go_back=self._show_init_menu,
{ time_based_step=self.async_step_opt_time_based,
vol.Required(CONF_TASK_NAME): selector.TextSelector( calendar_step=self.async_step_opt_calendar,
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) sensor_step=self.async_step_opt_sensor_select,
), one_time_step=self.async_step_opt_one_time,
vol.Required(CONF_TASK_TYPE, default=MaintenanceTypeEnum.CLEANING): selector.SelectSelector( manual_step=self.async_step_opt_manual,
selector.SelectSelectorConfig( before_dispatch=self._wire_add_task_callbacks,
options=type_options,
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="maintenance_type",
)
),
vol.Required(CONF_TASK_SCHEDULE_TYPE, default=ScheduleType.TIME_BASED): selector.SelectSelector(
selector.SelectSelectorConfig(
options=schedule_options,
mode=selector.SelectSelectorMode.LIST,
translation_key="schedule_type",
)
),
vol.Optional(CONF_TASK_ICON): selector.IconSelector(),
vol.Optional(CONF_TASK_PRIORITY, default="normal"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(TASK_PRIORITIES),
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="task_priority",
)
),
vol.Optional(CONF_TASK_LABELS_TEXT): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³").
vol.Optional(CONF_TASK_READING_UNIT): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
) )
async def async_step_opt_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_opt_time_based(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure time-based schedule for new task.""" """Configure time-based schedule for new task."""
errors: dict[str, str] = {} return await self._schedule_time_based(
user_input,
if user_input is not None:
if user_input.get("go_back"):
return self._show_init_menu()
interval = user_input.get(CONF_TASK_INTERVAL_DAYS)
if not interval or interval <= 0:
errors[CONF_TASK_INTERVAL_DAYS] = "invalid_interval"
else:
self._current_task[CONF_TASK_INTERVAL_DAYS] = interval
apply_interval_unit(self._current_task, user_input)
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
self._current_task[CONF_TASK_INTERVAL_ANCHOR] = user_input.get(CONF_TASK_INTERVAL_ANCHOR, "completion")
last_performed = user_input.get("last_performed")
if last_performed:
self._current_task["last_performed"] = str(last_performed)
return self._save_new_task()
return self.async_show_form(
step_id="opt_time_based", step_id="opt_time_based",
data_schema=vol.Schema( on_go_back=self._show_init_menu,
{ on_complete=self._save_new_task,
vol.Required(CONF_TASK_INTERVAL_DAYS, default=DEFAULT_INTERVAL_DAYS): selector.NumberSelector(
selector.NumberSelectorConfig(
min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
vol.Optional(CONF_TASK_INTERVAL_ANCHOR, default="completion"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="completion", label="From completion date"),
selector.SelectOptionDict(value="planned", label="From planned date (no drift)"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional("last_performed"): selector.DateSelector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
errors=errors,
) )
async def async_step_opt_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_opt_calendar(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure a calendar recurrence kind for a new task.""" """Configure a calendar recurrence kind for a new task."""
errors: dict[str, str] = {} return await self._schedule_calendar(
kind = self._current_task.get(CONF_TASK_SCHEDULE_TYPE, KIND_WEEKDAYS) user_input,
if user_input is not None:
if user_input.get("go_back"):
return self._show_init_menu()
schedule = schedule_from_calendar_input(kind, user_input)
if schedule is None:
errors["base"] = "invalid_schedule"
else:
self._current_task["schedule"] = schedule
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
if user_input.get("last_performed"):
self._current_task["last_performed"] = str(user_input["last_performed"])
return self._save_new_task()
schema = calendar_schema(kind).extend(
{
vol.Optional("last_performed"): selector.DateSelector(),
vol.Optional(CONF_TASK_WARNING_DAYS, default=get_default_warning_days(self.hass)): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
)
return self.async_show_form(
step_id="opt_calendar", step_id="opt_calendar",
data_schema=schema, on_go_back=self._show_init_menu,
errors=errors, on_complete=self._save_new_task,
description_placeholders={"kind": kind},
) )
async def async_step_opt_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_opt_one_time(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure a one-time (non-recurring) task for new task.""" """Configure a one-time (non-recurring) task for new task."""
errors: dict[str, str] = {} return await self._schedule_one_time(
user_input,
if user_input is not None:
if user_input.get("go_back"):
return self._show_init_menu()
due_date = user_input.get(CONF_TASK_DUE_DATE)
if not due_date:
errors[CONF_TASK_DUE_DATE] = "invalid_due_date"
else:
self._current_task[CONF_TASK_DUE_DATE] = str(due_date)
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
return self._save_new_task()
return self.async_show_form(
step_id="opt_one_time", step_id="opt_one_time",
data_schema=vol.Schema( on_go_back=self._show_init_menu,
{ on_complete=self._save_new_task,
vol.Required(CONF_TASK_DUE_DATE): selector.DateSelector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
errors=errors,
) )
async def async_step_opt_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: async def async_step_opt_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure manual schedule for new task.""" """Configure manual schedule for new task."""
if user_input is not None: return await self._schedule_manual(
if user_input.get("go_back"): user_input,
return self._show_init_menu()
self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.MANUAL
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
)
if user_input.get(CONF_TASK_NOTES):
self._current_task[CONF_TASK_NOTES] = user_input[CONF_TASK_NOTES]
return self._save_new_task()
return self.async_show_form(
step_id="opt_manual", step_id="opt_manual",
data_schema=vol.Schema( on_go_back=self._show_init_menu,
{ on_complete=self._save_new_task,
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional(CONF_TASK_NOTES): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT, multiline=True)
),
vol.Optional("go_back", default=False): selector.BooleanSelector(),
}
),
) )
@@ -17,25 +17,8 @@ from .const import (
CONF_ADVANCED_ADAPTIVE, CONF_ADVANCED_ADAPTIVE,
CONF_ADVANCED_CHECKLISTS, CONF_ADVANCED_CHECKLISTS,
CONF_OBJECT, CONF_OBJECT,
CONF_TASK_DUE_DATE,
CONF_TASK_ICON,
CONF_TASK_INTERVAL_ANCHOR,
CONF_TASK_INTERVAL_DAYS,
CONF_TASK_INTERVAL_UNIT,
CONF_TASK_LABELS_TEXT,
CONF_TASK_NAME,
CONF_TASK_NOTES,
CONF_TASK_PRIORITY,
CONF_TASK_SCHEDULE_TYPE,
CONF_TASK_TYPE,
CONF_TASK_WARNING_DAYS,
CONF_TASKS, CONF_TASKS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MaintenanceTypeEnum,
ScheduleType,
) )
from .helpers.global_options import get_default_warning_days
from .helpers.schedule import ( from .helpers.schedule import (
normalize_task_storage, normalize_task_storage,
) )
@@ -69,49 +52,15 @@ class _OptionsFlowBase(TriggerConfigMixin, OptionsFlow):
def _save_new_task(self) -> ConfigFlowResult: def _save_new_task(self) -> ConfigFlowResult:
"""Save the current task and return to init.""" """Save the current task and return to init."""
from homeassistant.util import dt as dt_util from .config_flow_schedule import build_new_task_record
from .helpers.sanitize import cap_task_fields, parse_labels_text
task_id = uuid4().hex task_id = uuid4().hex
task_data: dict[str, Any] = { task_data = build_new_task_record(
"id": task_id, self._current_task,
"object_id": self.config_entry.data.get(CONF_OBJECT, {}).get("id", ""), task_id=task_id,
"name": self._current_task.get(CONF_TASK_NAME, ""), object_id=self.config_entry.data.get(CONF_OBJECT, {}).get("id", ""),
"type": self._current_task.get(CONF_TASK_TYPE, MaintenanceTypeEnum.CUSTOM), hass=self.hass,
"enabled": True, )
"schedule_type": self._current_task.get(CONF_TASK_SCHEDULE_TYPE, ScheduleType.TIME_BASED),
"warning_days": self._current_task.get(CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)),
# Anchor for next_due fallback when last_performed is None (issue #30).
"created_at": dt_util.now().date().isoformat(),
}
# Calendar kinds carry a pre-built nested schedule; normalize (in
# _update_config_entry) treats it as authoritative over the flat fields.
if "schedule" in self._current_task:
task_data["schedule"] = self._current_task["schedule"]
if CONF_TASK_INTERVAL_DAYS in self._current_task:
task_data["interval_days"] = int(self._current_task[CONF_TASK_INTERVAL_DAYS])
if CONF_TASK_INTERVAL_UNIT in self._current_task:
task_data["interval_unit"] = self._current_task[CONF_TASK_INTERVAL_UNIT]
if CONF_TASK_DUE_DATE in self._current_task:
task_data["due_date"] = self._current_task[CONF_TASK_DUE_DATE]
anchor = self._current_task.get(CONF_TASK_INTERVAL_ANCHOR, "completion")
if anchor != "completion":
task_data["interval_anchor"] = anchor
if "trigger_config" in self._current_task:
task_data["trigger_config"] = self._current_task["trigger_config"]
if CONF_TASK_NOTES in self._current_task:
task_data["notes"] = self._current_task[CONF_TASK_NOTES]
if CONF_TASK_ICON in self._current_task:
task_data["custom_icon"] = self._current_task[CONF_TASK_ICON]
if CONF_TASK_PRIORITY in self._current_task:
task_data["priority"] = self._current_task[CONF_TASK_PRIORITY]
if self._current_task.get(CONF_TASK_LABELS_TEXT):
task_data["labels"] = parse_labels_text(self._current_task[CONF_TASK_LABELS_TEXT])
cap_task_fields(task_data)
new_data = dict(self.config_entry.data) new_data = dict(self.config_entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {})) new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = task_data new_tasks[task_id] = task_data
@@ -171,10 +120,9 @@ class _OptionsFlowBase(TriggerConfigMixin, OptionsFlow):
def _get_global_options(self) -> dict[str, Any]: def _get_global_options(self) -> dict[str, Any]:
"""Get global options from the global config entry.""" """Get global options from the global config entry."""
for entry in self.hass.config_entries.async_entries(DOMAIN): from .helpers.global_options import get_global_options
if entry.unique_id == GLOBAL_UNIQUE_ID:
return dict(entry.options or entry.data) return dict(get_global_options(self.hass))
return {}
def _build_task_action_menu(self) -> list[str]: def _build_task_action_menu(self) -> list[str]:
"""Build the task_action menu options list.""" """Build the task_action menu options list."""
@@ -13,6 +13,7 @@ from .config_flow_helpers import (
apply_season_ends, apply_season_ends,
calendar_current, calendar_current,
calendar_schema, calendar_schema,
interval_anchor_selector,
interval_unit_selector, interval_unit_selector,
schedule_from_calendar_input, schedule_from_calendar_input,
season_ends_schema, season_ends_schema,
@@ -385,15 +386,7 @@ class TaskCrudMixin:
vol.Optional( vol.Optional(
CONF_TASK_INTERVAL_ANCHOR, CONF_TASK_INTERVAL_ANCHOR,
default=sched["interval_anchor"], default=sched["interval_anchor"],
): selector.SelectSelector( ): interval_anchor_selector(),
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="completion", label="From completion date"),
selector.SelectOptionDict(value="planned", label="From planned date (no drift)"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
**( **(
{ {
vol.Optional( vol.Optional(
@@ -104,6 +104,58 @@ def _recovery_default(tc: dict[str, Any] | None) -> bool:
return bool((tc or {}).get("auto_complete_on_recovery")) return bool((tc or {}).get("auto_complete_on_recovery"))
def _recovery_field(tc: dict[str, Any] | None) -> dict[Any, Any]:
"""The #53 recovery checkbox — one schema entry, six call sites."""
return {
vol.Optional(
"auto_complete_on_recovery",
default=_recovery_default(tc),
): selector.BooleanSelector()
}
def _entity_logic_field(entity_ids: list[Any]) -> dict[Any, Any]:
"""The any/all selector, shown only with 2+ entities — identical on every
trigger-type step (and per compound condition)."""
if len(entity_ids) < 2:
return {}
return {
vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="any", label="Any entity triggers"),
selector.SelectOptionDict(value="all", label="All entities must trigger"),
],
mode=selector.SelectSelectorMode.LIST,
translation_key="entity_logic",
)
)
}
def _interval_warning_fields(hass: HomeAssistant) -> dict[Any, Any]:
"""The safety-interval + warning-days tail shared by all four type steps."""
return {
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
selector.NumberSelectorConfig(
min=INTERVAL_DAYS_RANGE[0],
max=INTERVAL_DAYS_RANGE[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
}
def _state_selector(entity_id: str | None, *, multiple: bool = False) -> Any: def _state_selector(entity_id: str | None, *, multiple: bool = False) -> Any:
"""State field bound to the trigger entity (#129 follow-up). """State field bound to the trigger entity (#129 follow-up).
@@ -460,47 +512,10 @@ class TriggerConfigMixin:
vol.Optional(CONF_TRIGGER_FOR_MINUTES, default=0): selector.NumberSelector( vol.Optional(CONF_TRIGGER_FOR_MINUTES, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX) selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX)
), ),
vol.Optional( **_recovery_field(self._current_task.get("trigger_config")),
"auto_complete_on_recovery",
default=_recovery_default(self._current_task.get("trigger_config")),
): selector.BooleanSelector(),
} }
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
# Add entity_logic selector when multiple entities are selected schema_fields.update(_interval_warning_fields(self.hass))
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
if len(entity_ids) > 1:
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="any", label="Any entity triggers"),
selector.SelectOptionDict(value="all", label="All entities must trigger"),
],
mode=selector.SelectSelectorMode.LIST,
translation_key="entity_logic",
)
)
schema_fields.update(
{
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
selector.NumberSelectorConfig(
min=INTERVAL_DAYS_RANGE[0],
max=INTERVAL_DAYS_RANGE[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
}
)
return self.async_show_form( return self.async_show_form(
step_id=step_id, step_id=step_id,
@@ -583,47 +598,10 @@ class TriggerConfigMixin:
step="any", step="any",
) )
), ),
vol.Optional( **_recovery_field(prev_tc),
"auto_complete_on_recovery",
default=_recovery_default(prev_tc),
): selector.BooleanSelector(),
} }
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
# Add entity_logic selector when multiple entities are selected schema_fields.update(_interval_warning_fields(self.hass))
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
if len(entity_ids) > 1:
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="any", label="Any entity triggers"),
selector.SelectOptionDict(value="all", label="All entities must trigger"),
],
mode=selector.SelectSelectorMode.LIST,
translation_key="entity_logic",
)
)
schema_fields.update(
{
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
selector.NumberSelectorConfig(
min=INTERVAL_DAYS_RANGE[0],
max=INTERVAL_DAYS_RANGE[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
}
)
return self.async_show_form( return self.async_show_form(
step_id=step_id, step_id=step_id,
@@ -688,47 +666,10 @@ class TriggerConfigMixin:
mode=selector.NumberSelectorMode.BOX, mode=selector.NumberSelectorMode.BOX,
) )
), ),
vol.Optional( **_recovery_field(self._current_task.get("trigger_config")),
"auto_complete_on_recovery",
default=_recovery_default(self._current_task.get("trigger_config")),
): selector.BooleanSelector(),
} }
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
# Add entity_logic selector when multiple entities are selected schema_fields.update(_interval_warning_fields(self.hass))
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
if len(entity_ids) > 1:
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="any", label="Any entity triggers"),
selector.SelectOptionDict(value="all", label="All entities must trigger"),
],
mode=selector.SelectSelectorMode.LIST,
translation_key="entity_logic",
)
)
schema_fields.update(
{
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
selector.NumberSelectorConfig(
min=INTERVAL_DAYS_RANGE[0],
max=INTERVAL_DAYS_RANGE[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
}
)
return self.async_show_form( return self.async_show_form(
step_id=step_id, step_id=step_id,
@@ -796,47 +737,10 @@ class TriggerConfigMixin:
vol.Optional(CONF_TRIGGER_ON_STATES, default=default_states): _state_selector( vol.Optional(CONF_TRIGGER_ON_STATES, default=default_states): _state_selector(
self._trigger_entity_id, multiple=True self._trigger_entity_id, multiple=True
), ),
vol.Optional( **_recovery_field(current_tc),
"auto_complete_on_recovery",
default=_recovery_default(current_tc),
): selector.BooleanSelector(),
} }
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
# Add entity_logic selector when multiple entities are selected schema_fields.update(_interval_warning_fields(self.hass))
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
if len(entity_ids) > 1:
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="any", label="Any entity triggers"),
selector.SelectOptionDict(value="all", label="All entities must trigger"),
],
mode=selector.SelectSelectorMode.LIST,
translation_key="entity_logic",
)
)
schema_fields.update(
{
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
selector.NumberSelectorConfig(
min=INTERVAL_DAYS_RANGE[0],
max=INTERVAL_DAYS_RANGE[1],
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=get_default_warning_days(self.hass),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
)
),
}
)
return self.async_show_form( return self.async_show_form(
step_id=step_id, step_id=step_id,
@@ -893,10 +797,7 @@ class TriggerConfigMixin:
translation_key="compound_logic", translation_key="compound_logic",
) )
), ),
vol.Optional( **_recovery_field(self._current_task.get("trigger_config")),
"auto_complete_on_recovery",
default=_recovery_default(self._current_task.get("trigger_config")),
): selector.BooleanSelector(),
} }
return self.async_show_form( return self.async_show_form(
step_id=step_id, step_id=step_id,
@@ -1104,18 +1005,7 @@ class TriggerConfigMixin:
): _state_selector(cond.get("entity_id"), multiple=True), ): _state_selector(cond.get("entity_id"), multiple=True),
} }
entity_ids = cond.get("entity_ids", []) schema_fields.update(_entity_logic_field(cond.get("entity_ids", [])))
if len(entity_ids) > 1:
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="any", label="Any entity triggers"),
selector.SelectOptionDict(value="all", label="All entities must trigger"),
],
mode=selector.SelectSelectorMode.LIST,
translation_key="entity_logic",
)
)
return self.async_show_form( return self.async_show_form(
step_id=step_id, step_id=step_id,
@@ -47,6 +47,20 @@ def slugify_object_name(name: str) -> str:
return slug return slug
def task_unique_id(object_slug: str, task_id: str, suffix: str = "") -> str:
"""The unique_id of a per-task entity:
``maintenance_supporter_{slug}_{task_id}[_suffix]``.
ONE formatter for every builder the sensor/binary_sensor/button
platforms, the WS entity-id resolver, and the logbook each hand-formatted
this string, so changing the scheme in one place silently broke the
others' registry lookups. (The parse-side consumers — entity_rename and
the service-target resolver key off the same prefix/suffix contract.)
"""
base = f"maintenance_supporter_{object_slug}_{task_id}"
return f"{base}_{suffix}" if suffix else base
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.SENSOR, Platform.SENSOR,
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
@@ -4,7 +4,6 @@ from __future__ import annotations
import logging import logging
import time import time
from collections.abc import Mapping
from datetime import date, timedelta from datetime import date, timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -23,7 +22,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import ( from .const import (
BUDGET_CACHE_KEY, BUDGET_CACHE_KEY,
BUDGET_CURRENCIES, BUDGET_CURRENCIES,
CONF_ADVANCED_SCHEDULE_TIME,
CONF_BUDGET_ALERT_THRESHOLD, CONF_BUDGET_ALERT_THRESHOLD,
CONF_BUDGET_ALERTS_ENABLED, CONF_BUDGET_ALERTS_ENABLED,
CONF_BUDGET_CURRENCY, CONF_BUDGET_CURRENCY,
@@ -38,7 +36,6 @@ from .const import (
EVENT_TASK_COMPLETED, EVENT_TASK_COMPLETED,
EVENT_TASK_RESET, EVENT_TASK_RESET,
EVENT_TASK_SKIPPED, EVENT_TASK_SKIPPED,
GLOBAL_UNIQUE_ID,
MANUAL_COMPLETION_DEDUP_SECONDS, MANUAL_COMPLETION_DEDUP_SECONDS,
MISSING_ENTITY_THRESHOLD_REFRESHES, MISSING_ENTITY_THRESHOLD_REFRESHES,
NOTIFICATION_MANAGER_KEY, NOTIFICATION_MANAGER_KEY,
@@ -51,6 +48,8 @@ from .const import (
TriggerEntityState, TriggerEntityState,
) )
from .helpers.budget import compute_spend from .helpers.budget import compute_spend
from .helpers.entry_tasks import write_task
from .helpers.global_options import get_global_options, is_schedule_time_enabled
from .helpers.schedule import normalize_task_storage, read_legacy_fields from .helpers.schedule import normalize_task_storage, read_legacy_fields
from .models.maintenance_object import MaintenanceObject from .models.maintenance_object import MaintenanceObject
from .models.maintenance_task import MaintenanceTask from .models.maintenance_task import MaintenanceTask
@@ -59,6 +58,24 @@ from .storage import MaintenanceStore
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _inert_task_result(task: MaintenanceTask, status: str, **extra: Any) -> dict[str, Any]:
"""Coordinator payload for a task that gets NO live evaluation (archived /
paused): due fields nulled, trigger off, only cost/history-derived fields
surfaced. The archived and paused short-circuits were hand-copied twins."""
task_result = task.to_dict()
task_result["_status"] = status
task_result["_days_until_due"] = None
task_result["_next_due"] = None
task_result["_is_done"] = task.is_done
task_result["_trigger_active"] = False
task_result["_times_performed"] = task.times_performed
task_result["_total_cost"] = task.total_cost
task_result["_average_duration"] = task.average_duration
task_result["_last_entry"] = task.last_entry
task_result.update(extra)
return task_result
class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for a single maintenance object and its tasks.""" """Coordinator for a single maintenance object and its tasks."""
@@ -96,18 +113,8 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._trigger_entity_states: dict[str, str] = {} # task_id -> TriggerEntityState self._trigger_entity_states: dict[str, str] = {} # task_id -> TriggerEntityState
def _is_schedule_time_feature_enabled(self) -> bool: def _is_schedule_time_feature_enabled(self) -> bool:
"""Return True iff the global advanced flag for time-of-day scheduling is on. """Return True iff the global advanced flag for time-of-day scheduling is on."""
return is_schedule_time_enabled(self.hass)
Reads the global config entry's options on every call. Cheap (just a
dict lookup over the small list of integration entries) and means a
toggle in Settings takes effect on the next coordinator refresh
without needing a restart.
"""
for ce in self.hass.config_entries.async_entries(DOMAIN):
if ce.unique_id == GLOBAL_UNIQUE_ID:
opts = ce.options or ce.data
return bool(opts.get(CONF_ADVANCED_SCHEDULE_TIME, False))
return False
def _in_startup_grace_period(self) -> bool: def _in_startup_grace_period(self) -> bool:
"""Return True if still within the startup grace period.""" """Return True if still within the startup grace period."""
@@ -195,17 +202,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# are surfaced (budget keeps counting; the detail view still renders # are surfaced (budget keeps counting; the detail view still renders
# the record). # the record).
if task.archived_at is not None: if task.archived_at is not None:
task_result = task.to_dict() result[CONF_TASKS][task_id] = _inert_task_result(task, MaintenanceStatus.ARCHIVED)
task_result["_status"] = MaintenanceStatus.ARCHIVED
task_result["_days_until_due"] = None
task_result["_next_due"] = None
task_result["_is_done"] = task.is_done
task_result["_trigger_active"] = False
task_result["_times_performed"] = task.times_performed
task_result["_total_cost"] = task.total_cost
task_result["_average_duration"] = task.average_duration
task_result["_last_entry"] = task.last_entry
result[CONF_TASKS][task_id] = task_result
continue continue
# v2.20 (N3): tasks of a paused object are frozen — status PAUSED, # v2.20 (N3): tasks of a paused object are frozen — status PAUSED,
@@ -214,18 +211,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# first-class citizen in every view. `_paused` mirrors the status # first-class citizen in every view. `_paused` mirrors the status
# for the dict-twin recomputation in helpers.status. # for the dict-twin recomputation in helpers.status.
if object_paused: if object_paused:
task_result = task.to_dict() result[CONF_TASKS][task_id] = _inert_task_result(task, MaintenanceStatus.PAUSED, _paused=True)
task_result["_status"] = MaintenanceStatus.PAUSED
task_result["_paused"] = True
task_result["_days_until_due"] = None
task_result["_next_due"] = None
task_result["_is_done"] = task.is_done
task_result["_trigger_active"] = False
task_result["_times_performed"] = task.times_performed
task_result["_total_cost"] = task.total_cost
task_result["_average_duration"] = task.average_duration
task_result["_last_entry"] = task.last_entry
result[CONF_TASKS][task_id] = task_result
continue continue
# Restore live trigger state from previous coordinator data # Restore live trigger state from previous coordinator data
@@ -719,7 +705,6 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# BEFORE the bundle threshold, like the vacation filter above. A stale # BEFORE the bundle threshold, like the vacation filter above. A stale
# view id (view deleted) means no scope, never "silence everything". # view id (view deleted) means no scope, never "silence everything".
from .const import CONF_NOTIFY_SCOPE_VIEW_ID from .const import CONF_NOTIFY_SCOPE_VIEW_ID
from .helpers.global_options import get_global_options
scope_view_id = get_global_options(self.hass).get(CONF_NOTIFY_SCOPE_VIEW_ID) or "" scope_view_id = get_global_options(self.hass).get(CONF_NOTIFY_SCOPE_VIEW_ID) or ""
if scope_view_id: if scope_view_id:
@@ -739,15 +724,9 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
from .const import ( from .const import (
CONF_NOTIFICATION_BUNDLE_THRESHOLD, CONF_NOTIFICATION_BUNDLE_THRESHOLD,
CONF_NOTIFICATION_BUNDLING_ENABLED, CONF_NOTIFICATION_BUNDLING_ENABLED,
GLOBAL_UNIQUE_ID,
) )
global_options: Mapping[str, Any] = {} global_options = get_global_options(self.hass)
for entry in self.hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
global_options = entry.options or entry.data
break
bundling_enabled = global_options.get(CONF_NOTIFICATION_BUNDLING_ENABLED, False) bundling_enabled = global_options.get(CONF_NOTIFICATION_BUNDLING_ENABLED, False)
bundle_threshold = int(global_options.get(CONF_NOTIFICATION_BUNDLE_THRESHOLD, 2)) bundle_threshold = int(global_options.get(CONF_NOTIFICATION_BUNDLE_THRESHOLD, 2))
@@ -810,12 +789,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not isinstance(nm, NotificationManager) or not nm.enabled: if not isinstance(nm, NotificationManager) or not nm.enabled:
return return
global_options: Mapping[str, Any] = {} global_options = get_global_options(self.hass)
for entry in self.hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
global_options = entry.options or entry.data
break
if not global_options.get(CONF_BUDGET_ALERTS_ENABLED, False): if not global_options.get(CONF_BUDGET_ALERTS_ENABLED, False):
return return
@@ -1036,16 +1010,11 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) )
enriched_used: list[dict[str, Any]] | None = None enriched_used: list[dict[str, Any]] | None = None
if used_parts is not None or record_links: if used_parts is not None or record_links:
own_catalog = self.entry.data.get("parts") or {}
def _part_name(link: dict[str, Any]) -> str: def _part_name(link: dict[str, Any]) -> str:
owner_id = link.get("entry_id") from .parts_runtime import part_link_name
catalog: dict[str, Any] = own_catalog
if owner_id and owner_id != self.entry.entry_id: return part_link_name(self.hass, self.entry, link)
owner = self.hass.config_entries.async_get_entry(owner_id)
catalog = (owner.data.get("parts") or {}) if owner else {}
name = (catalog.get(link["part_id"]) or {}).get("name")
return str(name) if name else str(link["part_id"])
enriched_used = [ enriched_used = [
{ {
@@ -1110,13 +1079,9 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# writes then loses only the rotation — the completion is recorded, # writes then loses only the rotation — the completion is recorded,
# and a retried completion can't double-advance the pointer. # and a retried completion can't double-advance the pointer.
if task.responsible_user_id != pre_rotation_responsible: if task.responsible_user_id != pre_rotation_responsible:
new_data = dict(self.entry.data) td = dict(self.entry.data.get(CONF_TASKS, {}).get(task_id, {}))
new_tasks = dict(new_data.get(CONF_TASKS, {}))
td = dict(new_tasks.get(task_id, {}))
td["responsible_user_id"] = task.responsible_user_id td["responsible_user_id"] = task.responsible_user_id
new_tasks[task_id] = td write_task(self.hass, self.entry, task_id, td)
new_data[CONF_TASKS] = new_tasks
self.hass.config_entries.async_update_entry(self.entry, data=new_data)
# Invalidate budget cache when a cost is recorded # Invalidate budget cache when a cost is recorded
if cost is not None: if cost is not None:
@@ -66,12 +66,10 @@ async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: Mainten
diag["overview"] = _get_integration_overview(hass) diag["overview"] = _get_integration_overview(hass)
else: else:
# Object entry diagnostics — merge Store dynamic data for stats # Object entry diagnostics — merge Store dynamic data for stats
runtime_data = getattr(entry, "runtime_data", None) from .helpers.aggregate import merged_tasks
store = getattr(runtime_data, "store", None) if runtime_data else None
static_tasks = entry.data.get(CONF_TASKS, {})
merged_tasks = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
merged_data = dict(entry.data) merged_data = dict(entry.data)
merged_data[CONF_TASKS] = merged_tasks merged_data[CONF_TASKS] = merged_tasks(entry)
diag["statistics"] = _calculate_statistics(merged_data) diag["statistics"] = _calculate_statistics(merged_data)
diag["trigger_status"] = _check_trigger_status(hass, entry.data) diag["trigger_status"] = _check_trigger_status(hass, entry.data)
@@ -79,10 +79,16 @@ class RuntimeTrigger(BaseTrigger):
async def async_setup(self) -> None: async def async_setup(self) -> None:
"""Set up runtime trigger with state restoration.""" """Set up runtime trigger with state restoration."""
state = self.hass.states.get(self.entity_id) state = self.hass.states.get(self.entity_id)
if state is None: if state is None or state.state in ("unavailable", "unknown"):
# No USABLE state yet (#131 family): "unavailable" must not read
# as OFF — a running device whose sensor merely connects late
# would lose its restored on_since anchor and undercount. Keep
# the restored tracking state untouched and let the first real
# state event decide.
_LOGGER.info( _LOGGER.info(
"Runtime trigger entity %s not yet available — listener registered, waiting for entity to appear", "Runtime trigger entity %s not ready at setup (state=%s) — listener registered, waiting for a real state",
self.entity_id, self.entity_id,
state.state if state else "missing",
) )
self._unsub_listener = async_track_state_change_event( self._unsub_listener = async_track_state_change_event(
self.hass, self.hass,
@@ -31,6 +31,10 @@ class StateChangeTrigger(BaseTrigger):
Triggers when count reaches target_changes. Triggers when count reaches target_changes.
""" """
# Setup saw no usable state -> reconcile on the first real one (#131).
# Class default so hand-built test instances inherit it.
_needs_latch_reconcile: bool = False
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
@@ -52,6 +56,7 @@ class StateChangeTrigger(BaseTrigger):
self._change_count: int = trigger_config.get("trigger_change_count", 0) self._change_count: int = trigger_config.get("trigger_change_count", 0)
self._current_value = float(self._change_count) self._current_value = float(self._change_count)
self._last_state: str | None = None self._last_state: str | None = None
self._needs_latch_reconcile = False
async def async_setup(self) -> None: async def async_setup(self) -> None:
"""Set up state change trigger. """Set up state change trigger.
@@ -61,40 +66,25 @@ class StateChangeTrigger(BaseTrigger):
appears (old_state=None), so the trigger will self-heal automatically. appears (old_state=None), so the trigger will self-heal automatically.
""" """
state = self.hass.states.get(self.entity_id) state = self.hass.states.get(self.entity_id)
if state is None: if state is None or state.state in ("unavailable", "unknown"):
# No USABLE state yet — the #131 family: trigger setup races both
# the entity's registration AND its device readiness (a Zigbee /
# Z-Wave problem sensor restores as unavailable long before it
# reports). "unavailable" must never read as "recovered" — it
# used to quietly clear a single-shot latch right here. Register
# the listener and defer the latch reconciliation to the first
# real state.
self._needs_latch_reconcile = True
_LOGGER.info( _LOGGER.info(
"Trigger entity %s not yet available — listener registered, waiting for entity to appear", "Trigger entity %s not ready at setup (state=%s) — listener registered, latch check deferred",
self.entity_id, self.entity_id,
state.state if state else "missing",
) )
# Register listener anyway so we catch the entity appearing
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition) self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
return return
self._last_state = state.state self._last_state = state.state
self._reconcile_persisted_latch(state.state)
# Restore triggered state from persisted change count
if self._change_count >= self._target_changes:
# Latch reconciliation: a single-shot state alarm (target_changes
# == 1) that already left its alert state while we were down is no
# longer active. Clear it quietly — the recovery transition was
# never observed, so we must NOT auto-complete for it here (that
# path only runs on a live off event, guarded against double-count).
if (
self._to_state is not None
and self._target_changes == 1
and _norm_state(state.state) != self._to_state
):
self._change_count = 0
self._current_value = 0.0
if self.hass.is_running:
self.hass.async_create_task(self._persist_change_count())
else:
self._triggered = True
self.entity.async_update_trigger_state(
is_triggered=True,
current_value=float(self._change_count),
trigger_entity_id=self.entity_id,
)
# Register state change listener (override base: we handle events differently) # Register state change listener (override base: we handle events differently)
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition) self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
@@ -108,6 +98,42 @@ class StateChangeTrigger(BaseTrigger):
self._to_state, self._to_state,
) )
def _reconcile_persisted_latch(self, live_state: str) -> None:
"""Align the persisted change-count latch with the LIVE entity state.
Runs at setup when the entity already exists, and again when the
entity first APPEARS (issue #131): trigger setup races HA's state
restoration, so a source that restores later kept a stale latch
a problem sensor still on read OK, and a single-shot alarm that had
recovered while we were down stayed triggered.
A single-shot state alarm (target_changes == 1) whose entity is no
longer in its alert state is cleared QUIETLY the recovery
transition was never observed, so we must NOT auto-complete for it
here (that path only runs on a live off event, guarded against
double-count). Otherwise the latch is restored and repainted.
"""
if self._change_count < self._target_changes:
return
if self._to_state is not None and self._target_changes == 1 and _norm_state(live_state) != self._to_state:
self._change_count = 0
self._current_value = 0.0
self._triggered = False
if self.hass.is_running:
self.hass.async_create_task(self._persist_change_count())
self.entity.async_update_trigger_state(
is_triggered=False,
current_value=0.0,
trigger_entity_id=self.entity_id,
)
else:
self._triggered = True
self.entity.async_update_trigger_state(
is_triggered=True,
current_value=float(self._change_count),
trigger_entity_id=self.entity_id,
)
@callback @callback
def _handle_state_transition(self, event: Event[EventStateChangedData]) -> None: def _handle_state_transition(self, event: Event[EventStateChangedData]) -> None:
"""Handle state transition and count matching changes.""" """Handle state transition and count matching changes."""
@@ -128,9 +154,14 @@ class StateChangeTrigger(BaseTrigger):
new_val, new_val,
) )
self._logged_unavailable = False self._logged_unavailable = False
# Capture initial state but don't count as a transition # Capture initial state but don't count as a transition — and
# reconcile the persisted latch against it (issue #131): when the
# entity restores AFTER our setup, this appearance is the first
# moment the latch can be checked against reality.
if new_val not in ("unavailable", "unknown"): if new_val not in ("unavailable", "unknown"):
self._needs_latch_reconcile = False
self._last_state = new_val self._last_state = new_val
self._reconcile_persisted_latch(new_val)
return return
old_val = old_state.state old_val = old_state.state
@@ -155,6 +186,17 @@ class StateChangeTrigger(BaseTrigger):
) )
self._logged_unavailable = False self._logged_unavailable = False
# First REAL state after a setup that saw none/unavailable (#131
# family): reconcile the persisted latch against it instead of
# counting the restore as a transition. Mid-run unavailability
# glitches never set the flag, so their observed recovery still goes
# through the normal transition/auto-complete path below.
if self._needs_latch_reconcile:
self._needs_latch_reconcile = False
self._last_state = new_val
self._reconcile_persisted_latch(new_val)
return
# Use _last_state as fallback when old_val is unavailable/unknown # Use _last_state as fallback when old_val is unavailable/unknown
effective_old = old_val effective_old = old_val
if old_val in ("unavailable", "unknown") and self._last_state is not None: if old_val in ("unavailable", "unknown") and self._last_state is not None:
@@ -15,8 +15,8 @@ from .const import (
CONF_TASKS, CONF_TASKS,
DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS,
DOMAIN, DOMAIN,
GLOBAL_UNIQUE_ID,
) )
from .helpers.aggregate import get_object_entries, merged_tasks
from .helpers.schedule import Schedule, read_legacy_fields from .helpers.schedule import Schedule, read_legacy_fields
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -73,8 +73,7 @@ def _build_export_object(
# Merge static + Store dynamic data for each task # Merge static + Store dynamic data for each task
rd = getattr(entry, "runtime_data", None) rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None store = getattr(rd, "store", None) if rd else None
static_tasks = entry.data.get(CONF_TASKS, {}) tasks_data = merged_tasks(entry)
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {}) ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {})
tasks = [] tasks = []
@@ -116,6 +115,9 @@ def _build_export_object(
"entity_slug": tdata.get("entity_slug"), "entity_slug": tdata.get("entity_slug"),
"adaptive_config": tdata.get("adaptive_config"), "adaptive_config": tdata.get("adaptive_config"),
"checklist": tdata.get("checklist") or [], "checklist": tdata.get("checklist") or [],
# In-cycle ticks (#73) — merged_tasks overlays them from the Store;
# exporting them keeps half-done checklists across backup/restore.
"checklist_progress": tdata.get("checklist_progress"),
"schedule_time": tdata.get("schedule_time"), "schedule_time": tdata.get("schedule_time"),
# v2.17+ / #83 task fields — persisted and user-facing, so a JSON # v2.17+ / #83 task fields — persisted and user-facing, so a JSON
# backup must restore them (same field-completeness contract as #67 # backup must restore them (same field-completeness contract as #67
@@ -197,15 +199,11 @@ def _build_export_object(
} }
def object_entries(hass: HomeAssistant, entry_ids: set[str] | None = None) -> list[ConfigEntry]: # The maintenance OBJECT entries (never the global hub). Shared by every
"""The maintenance OBJECT entries (never the global hub), optionally # exporter so JSON/YAML/CSV apply the same selective-export filter — the
narrowed to a selection. Shared by every exporter so JSON/YAML/CSV apply # implementation lives in helpers.aggregate, this module keeps the name its
the same selective-export filter. ``entry_ids=None`` means all objects.""" # importers (csv_handler, doc_archive, WS adopt handlers) bind to.
return [ object_entries = get_object_entries
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.unique_id != GLOBAL_UNIQUE_ID and (entry_ids is None or entry.entry_id in entry_ids)
]
def build_export_data( def build_export_data(
@@ -284,3 +282,49 @@ def export_maintenance_data(
""" """
data = build_export_data(hass, include_history=include_history) data = build_export_data(hass, include_history=include_history)
return serialize_export(data, fmt) return serialize_export(data, fmt)
# Global settings the export deliberately leaves behind: HA user ids are
# instance-bound (and the panel-access allowlist is security-relevant), and
# the adopted-task stash is transient re-adopt state.
_NON_PORTABLE_SETTINGS = ("admin_panel_user_ids", "adopted_task_notes")
def build_settings_export(hass: HomeAssistant) -> dict[str, Any]:
"""The SECOND export: the global entry's settings.
``build_export_data`` deliberately carries only objects groups, saved
views, vacation config, notification/budget settings and the feature
toggles live on the global entry and export through here instead
(2026-08 round-trip audit decision). Instance-bound keys are excluded
(see ``_NON_PORTABLE_SETTINGS``).
"""
from .const import (
CONF_GROUPS,
CONF_SAVED_FILTER_VIEWS,
CONF_VACATION_BUFFER_DAYS,
CONF_VACATION_ENABLED,
CONF_VACATION_END,
CONF_VACATION_EXEMPT_TASK_IDS,
CONF_VACATION_START,
)
from .helpers.global_options import get_global_options
from .helpers.settings_registry import ALLOWED_SETTING_KEYS
opts = get_global_options(hass)
settings: dict[str, Any] = {
k: opts[k] for k in ALLOWED_SETTING_KEYS if k in opts and k not in _NON_PORTABLE_SETTINGS
}
# Structured sections with their own WS surfaces (not in the registry).
for key in (
CONF_GROUPS,
CONF_SAVED_FILTER_VIEWS,
CONF_VACATION_ENABLED,
CONF_VACATION_START,
CONF_VACATION_END,
CONF_VACATION_BUFFER_DAYS,
CONF_VACATION_EXEMPT_TASK_IDS,
):
if key in opts:
settings[key] = opts[key]
return {"version": 1, "global_settings": settings}
@@ -9,7 +9,7 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles"; import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import { UserService } from "../user-service"; import { UserService } from "../user-service";
import type { HAUser, HomeAssistant } from "../types"; import type { HAUser, HomeAssistant } from "../types";
@@ -55,7 +55,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
private _userService: UserService | null = null; private _userService: UserService | null = null;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
updated(changed: Map<string, unknown>): void { updated(changed: Map<string, unknown>): void {
@@ -6,7 +6,8 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles"; import { t, ensureLocale, langOf } from "../styles";
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -84,7 +85,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
private _localeReady = false; private _localeReady = false;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
connectedCallback(): void { connectedCallback(): void {
@@ -232,24 +233,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
</svg>`; </svg>`;
} }
private static readonly _SORT_KEY = "ms_bf_roster_sort";
private static _storedSort(): "name" | "urgency" { private static _storedSort(): "name" | "urgency" {
try { return lsGet(LS_KEYS.batteryRosterSort) === "name" ? "name" : "urgency";
const v = localStorage.getItem(MaintenanceBatteryFleetSection._SORT_KEY);
return v === "name" ? "name" : "urgency";
} catch {
return "urgency";
}
} }
private _setSort(mode: "name" | "urgency"): void { private _setSort(mode: "name" | "urgency"): void {
this._rosterSort = mode; this._rosterSort = mode;
try { // Storage may be unavailable — the toggle still works for this visit.
localStorage.setItem(MaintenanceBatteryFleetSection._SORT_KEY, mode); lsSet(LS_KEYS.batteryRosterSort, mode);
} catch {
// storage unavailable — the toggle still works for this visit
}
} }
/** Urgency (the default, issue #123): low rows first emptiest first /** Urgency (the default, issue #123): low rows first emptiest first
@@ -8,7 +8,8 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles"; import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles"; import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { BudgetStatus, HomeAssistant } from "../types"; import type { BudgetStatus, HomeAssistant } from "../types";
@@ -40,7 +41,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
} }
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
private get _isAdmin(): boolean { private get _isAdmin(): boolean {
@@ -277,9 +278,7 @@ if (!customElements.get("maintenance-budget-section-card")) {
); );
} }
(window as { customCards?: unknown[] }).customCards = registerCustomCard({
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
type: "maintenance-budget-section-card", type: "maintenance-budget-section-card",
name: "Maintenance Supporter — Budget", name: "Maintenance Supporter — Budget",
description: "Inline monthly + yearly budget editor", description: "Inline monthly + yearly budget editor",
@@ -10,10 +10,12 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url"; import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles"; import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import { downloadUrl } from "../helpers/download"; import { downloadUrl } from "../helpers/download";
import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url";
import { formatBytes } from "../helpers/format-bytes"; import { formatBytes } from "../helpers/format-bytes";
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
interface MaintenanceDocument { interface MaintenanceDocument {
@@ -28,16 +30,6 @@ interface MaintenanceDocument {
added_at?: string; added_at?: string;
} }
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
const CATEGORY_ICONS: Record<string, string> = {
manual: "mdi:book-open-variant",
warranty: "mdi:shield-check",
invoice: "mdi:receipt-text-outline",
spare_parts: "mdi:cog-outline",
photo: "mdi:image-outline",
other: "mdi:file-document-outline",
};
export class MaintenanceDocumentsSection extends LitElement { export class MaintenanceDocumentsSection extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entryId!: string; @property({ attribute: false }) public entryId!: string;
@@ -67,16 +59,11 @@ export class MaintenanceDocumentsSection extends LitElement {
} }
private async _sign(doc: MaintenanceDocument): Promise<string> { private async _sign(doc: MaintenanceDocument): Promise<string> {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ return signDocumentPath(this.hass, doc.id);
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
return signed.path;
} }
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
updated(changed: Map<string, unknown>): void { updated(changed: Map<string, unknown>): void {
@@ -210,12 +197,7 @@ export class MaintenanceDocumentsSection extends LitElement {
private async _download(doc: MaintenanceDocument): Promise<void> { private async _download(doc: MaintenanceDocument): Promise<void> {
try { try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 30,
});
downloadUrl(signed.path, doc.filename || doc.title || "document");
} catch (e) { } catch (e) {
this._error = describeWsError(e, this._lang); this._error = describeWsError(e, this._lang);
} }
@@ -228,15 +210,9 @@ export class MaintenanceDocumentsSection extends LitElement {
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc)); this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
return; return;
} }
// Open the tab synchronously (in the click gesture) so it isn't popup-blocked,
// then point it at the freshly signed URL once it resolves.
const win = window.open("about:blank", "_blank");
try { try {
const url = await this._sign(doc); await openSignedDocument(this.hass, doc.id);
// Absolute URL so it always resolves against the blank popup (about:blank).
if (win) win.location.href = new URL(url, window.location.origin).href;
} catch (e) { } catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang); this._error = describeWsError(e, this._lang);
} }
} }
@@ -3,7 +3,7 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t } from "../styles"; import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import "./ms-textfield"; import "./ms-textfield";
import type { import type {
@@ -26,7 +26,7 @@ export class MaintenanceGroupDialog extends LitElement {
@state() private _selected: Set<string> = new Set(); // "entry_id:task_id" @state() private _selected: Set<string> = new Set(); // "entry_id:task_id"
private get _lang(): string { private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en"; return langOf(this.hass);
} }
public openCreate(): void { public openCreate(): void {
@@ -8,7 +8,8 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles"; import { t, ensureLocale, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles"; import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -47,7 +48,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
} }
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
private get _isAdmin(): boolean { private get _isAdmin(): boolean {
@@ -330,9 +331,7 @@ if (!customElements.get("maintenance-groups-section-card")) {
); );
} }
(window as { customCards?: unknown[] }).customCards = registerCustomCard({
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
type: "maintenance-groups-section-card", type: "maintenance-groups-section-card",
name: "Maintenance Supporter — Groups", name: "Maintenance Supporter — Groups",
description: "Inline group CRUD", description: "Inline group CRUD",
@@ -9,7 +9,7 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t } from "../styles"; import { t, langOf } from "../styles";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
@@ -50,7 +50,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
private _originalSnapshot: HistoryEntryDraft | null = null; private _originalSnapshot: HistoryEntryDraft | null = null;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
// #130: selectable parts + the edited selection (part key -> quantity; // #130: selectable parts + the edited selection (part key -> quantity;
@@ -10,6 +10,7 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { signDocumentPath } from "../helpers/document-url";
export class MaintenanceHistoryPhoto extends LitElement { export class MaintenanceHistoryPhoto extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -29,12 +30,7 @@ export class MaintenanceHistoryPhoto extends LitElement {
private async _sign(): Promise<void> { private async _sign(): Promise<void> {
try { try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ this._url = await signDocumentPath(this.hass, this.docId);
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${this.docId}`,
expires: 300,
});
this._url = signed.path;
} catch { } catch {
this._failed = true; this._failed = true;
} }
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types"; import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
import { t } from "../styles"; import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import "./ms-textfield"; import "./ms-textfield";
@@ -33,7 +33,7 @@ export class MaintenanceObjectDialog extends LitElement {
@state() private _entryId: string | null = null; // null = create, string = update @state() private _entryId: string | null = null; // null = create, string = update
private get _lang(): string { private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en"; return langOf(this.hass);
} }
public openCreate(): void { public openCreate(): void {
@@ -11,7 +11,7 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url"; import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, STATUS_COLORS } from "../styles"; import { t, STATUS_COLORS, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types"; import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types";
@@ -31,7 +31,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
@state() private _error = ""; @state() private _error = "";
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
public async openFor(entryId: string): Promise<void> { public async openFor(entryId: string): Promise<void> {
@@ -15,7 +15,7 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url"; import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles"; import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import type { HomeAssistant, MaintenancePart } from "../types"; import type { HomeAssistant, MaintenancePart } from "../types";
// Per-part document links (v2.26) — the task-documents component in part mode. // Per-part document links (v2.26) — the task-documents component in part mode.
@@ -70,7 +70,7 @@ export class MaintenancePartsSection extends LitElement {
@state() private _docsFor: string | null = null; @state() private _docsFor: string | null = null;
private get _lang(): string { private get _lang(): string {
return this.hass?.locale?.language || this.hass?.language || "en"; return langOf(this.hass);
} }
public connectedCallback(): void { public connectedCallback(): void {
@@ -12,7 +12,7 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles"; import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types"; import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
@@ -33,7 +33,7 @@ export class MaintenanceSavedViewsDialog extends LitElement {
private _localeReady = false; private _localeReady = false;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
updated(changed: Map<string, unknown>): void { updated(changed: Map<string, unknown>): void {
@@ -171,7 +171,7 @@ export class MaintenanceSavedViewsDialog extends LitElement {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
min-width: 340px; min-width: min(360px, calc(100vw - 24px));
max-width: 480px; max-width: 480px;
width: 90vw; width: 90vw;
max-height: 80vh; max-height: 80vh;
@@ -3,7 +3,7 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t } from "../styles"; import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -23,7 +23,7 @@ export class SeasonalOverridesDialog extends LitElement {
@state() private _values: string[] = new Array(12).fill(""); @state() private _values: string[] = new Array(12).fill("");
private get _lang(): string { private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en"; return langOf(this.hass);
} }
public open(entryId: string, taskId: string, currentOverrides: Record<number, number> | null | undefined): void { public open(entryId: string, taskId: string, currentOverrides: Record<number, number> | null | undefined): void {
@@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types"; import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
import { t } from "../styles"; import { t, langOf } from "../styles";
import { signApiPath } from "../helpers/document-url";
import { downloadUrl } from "../helpers/download";
import { UserService } from "../user-service"; import { UserService } from "../user-service";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns"; import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download"; import { downloadTextFile } from "../helpers/download";
@@ -160,7 +162,7 @@ export class MaintenanceSettingsView extends LitElement {
private _userService: UserService | null = null; private _userService: UserService | null = null;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
updated(changedProps: Map<string, unknown>): void { updated(changedProps: Map<string, unknown>): void {
@@ -1430,6 +1432,7 @@ export class MaintenanceSettingsView extends LitElement {
<button @click=${this._exportJson}>${t("settings_export_json", L)}</button> <button @click=${this._exportJson}>${t("settings_export_json", L)}</button>
<button @click=${this._exportYaml}>${t("settings_export_yaml", L)}</button> <button @click=${this._exportYaml}>${t("settings_export_yaml", L)}</button>
<button @click=${this._exportCsv}>${t("settings_export_csv", L)}</button> <button @click=${this._exportCsv}>${t("settings_export_csv", L)}</button>
<button @click=${this._exportSettings}>${t("settings_export_settings", L)}</button>
</div> </div>
<div class="settings-actions docs-archive-block"> <div class="settings-actions docs-archive-block">
<h4>${t("settings_docs_archive", L)}</h4> <h4>${t("settings_docs_archive", L)}</h4>
@@ -1510,6 +1513,22 @@ export class MaintenanceSettingsView extends LitElement {
} }
} }
/** The SECOND export: the global entry's settings (groups, saved views,
* vacation, notification/budget settings, feature toggles) the objects
* export deliberately excludes them. Re-import via the regular import. */
private async _exportSettings(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/settings/export",
}) as { data: string };
const ts = new Date().toISOString().slice(0, 10);
this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
}
}
private async _exportYaml(): Promise<void> { private async _exportYaml(): Promise<void> {
try { try {
const ids = this._selectedEntryIds; const ids = this._selectedEntryIds;
@@ -1573,17 +1592,8 @@ export class MaintenanceSettingsView extends LitElement {
try { try {
const raw = this._selectedEntryIds; const raw = this._selectedEntryIds;
const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : ""; const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : "";
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ const signed = await signApiPath(this.hass, `/api/maintenance_supporter/documents/archive${q}`);
type: "auth/sign_path", downloadUrl(signed, "maintenance-documents.zip");
path: `/api/maintenance_supporter/documents/archive${q}`,
expires: 300,
});
const a = document.createElement("a");
a.href = signed.path;
a.download = "maintenance-documents.zip";
document.body.appendChild(a);
a.click();
a.remove();
} catch { } catch {
this._showToast(t("action_error", this._lang)); this._showToast(t("action_error", this._lang));
} }
@@ -8,8 +8,9 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles"; import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import { openSignedDocument } from "../helpers/document-url";
import { formatBytes } from "../helpers/format-bytes"; import { formatBytes } from "../helpers/format-bytes";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -55,7 +56,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
private _searchTimer = 0; private _searchTimer = 0;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
updated(changed: Map<string, unknown>): void { updated(changed: Map<string, unknown>): void {
@@ -137,17 +138,9 @@ export class MaintenanceStorageSectionCard extends LitElement {
window.open(doc.url, "_blank", "noopener"); window.open(doc.url, "_blank", "noopener");
return; return;
} }
const win = window.open("about:blank", "_blank");
try { try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({ await openSignedDocument(this.hass, doc.id);
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
// Absolute URL so it navigates the blank popup reliably (about:blank base).
if (win) win.location.href = new URL(s.path, window.location.origin).href;
} catch (e) { } catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang); this._error = describeWsError(e, this._lang);
} }
} }
@@ -11,7 +11,7 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles"; import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -61,7 +61,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
private _localeReady = false; private _localeReady = false;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
updated(changed: Map<string, unknown>): void { updated(changed: Map<string, unknown>): void {
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types"; import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
import { formatDate, t, weekdayName } from "../styles"; import { formatDate, t, weekdayName, langOf } from "../styles";
import { UserService } from "../user-service"; import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts"; import { partLinkKey } from "../helpers/shared-parts";
import { import {
@@ -24,6 +24,8 @@ const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
// The type selector additionally offers "compound" (a group of conditions // The type selector additionally offers "compound" (a group of conditions
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds. // joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"]; const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
// Backend defaults for the adaptive tuning fields (ewa_alpha / min / max days).
const ADAPTIVE_DEFAULTS = { alpha: "0.3", min: "7", max: "365" } as const;
/** One condition of a compound trigger a flat trigger the user edits inline. /** One condition of a compound trigger a flat trigger the user edits inline.
* String-typed like the top-level fields (form inputs); coerced on save. */ * String-typed like the top-level fields (form inputs); coerced on save. */
@@ -279,9 +281,9 @@ export class MaintenanceTaskDialog extends LitElement {
// Adaptive tuning (parity with the options flow's adaptive step) — Store- // Adaptive tuning (parity with the options flow's adaptive step) — Store-
// managed like the environmental binding, saved through task/set_adaptive. // managed like the environmental binding, saved through task/set_adaptive.
@state() private _adaptiveEnabled = false; @state() private _adaptiveEnabled = false;
@state() private _adaptiveAlpha = "0.3"; @state() private _adaptiveAlpha: string = ADAPTIVE_DEFAULTS.alpha;
@state() private _adaptiveMin = "7"; @state() private _adaptiveMin: string = ADAPTIVE_DEFAULTS.min;
@state() private _adaptiveMax = "365"; @state() private _adaptiveMax: string = ADAPTIVE_DEFAULTS.max;
@state() private _adaptiveSeasonal = true; @state() private _adaptiveSeasonal = true;
@state() private _adaptivePrediction = true; @state() private _adaptivePrediction = true;
private _adaptiveInitial = ""; private _adaptiveInitial = "";
@@ -295,7 +297,7 @@ export class MaintenanceTaskDialog extends LitElement {
private _userService: UserService | null = null; private _userService: UserService | null = null;
private get _lang(): string { private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en"; return langOf(this.hass);
} }
public async openCreate(entryId: string, objects?: Array<{ entry_id: string; object: { name: string } }>): Promise<void> { public async openCreate(entryId: string, objects?: Array<{ entry_id: string; object: { name: string } }>): Promise<void> {
@@ -404,9 +406,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._environmentalInitial = this._environmentalEntity; this._environmentalInitial = this._environmentalEntity;
this._environmentalAttributeInitial = this._environmentalAttribute; this._environmentalAttributeInitial = this._environmentalAttribute;
this._adaptiveEnabled = !!ac.enabled; this._adaptiveEnabled = !!ac.enabled;
this._adaptiveAlpha = (ac.ewa_alpha ?? 0.3).toString(); this._adaptiveAlpha = ac.ewa_alpha?.toString() ?? ADAPTIVE_DEFAULTS.alpha;
this._adaptiveMin = (ac.min_interval_days ?? 7).toString(); this._adaptiveMin = ac.min_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.min;
this._adaptiveMax = (ac.max_interval_days ?? 365).toString(); this._adaptiveMax = ac.max_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.max;
this._adaptiveSeasonal = ac.seasonal_enabled !== false; this._adaptiveSeasonal = ac.seasonal_enabled !== false;
this._adaptivePrediction = ac.sensor_prediction_enabled !== false; this._adaptivePrediction = ac.sensor_prediction_enabled !== false;
this._adaptiveInitial = this._adaptiveSnapshot(); this._adaptiveInitial = this._adaptiveSnapshot();
@@ -497,9 +499,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._environmentalInitial = ""; this._environmentalInitial = "";
this._environmentalAttributeInitial = ""; this._environmentalAttributeInitial = "";
this._adaptiveEnabled = false; this._adaptiveEnabled = false;
this._adaptiveAlpha = "0.3"; this._adaptiveAlpha = ADAPTIVE_DEFAULTS.alpha;
this._adaptiveMin = "7"; this._adaptiveMin = ADAPTIVE_DEFAULTS.min;
this._adaptiveMax = "365"; this._adaptiveMax = ADAPTIVE_DEFAULTS.max;
this._adaptiveSeasonal = true; this._adaptiveSeasonal = true;
this._adaptivePrediction = true; this._adaptivePrediction = true;
this._adaptiveInitial = this._adaptiveSnapshot(); this._adaptiveInitial = this._adaptiveSnapshot();
@@ -1314,34 +1316,13 @@ export class MaintenanceTaskDialog extends LitElement {
</select> </select>
</div> </div>
` : nothing} ` : nothing}
${this._availableAttributes.length > 0 ${this._renderAttributeSelect({
? html` label: t("attribute_optional", L),
<div class="select-row"> value: this._triggerAttribute,
<label>${t("attribute_optional", L)}</label> suggested: this._suggestedAttributes,
<select available: this._availableAttributes,
.value=${this._triggerAttribute} onSelect: (v) => (this._triggerAttribute = v),
@change=${(e: Event) => (this._triggerAttribute = (e.target as HTMLSelectElement).value)} })}
>
<option value="" ?selected=${!this._triggerAttribute}>${t("use_entity_state", L)}</option>
${this._suggestedAttributes.map(
(attr) => html`<option value=${attr} ?selected=${attr === this._triggerAttribute}>${attr} ★</option>`
)}
${this._availableAttributes
.filter((a) => !this._suggestedAttributes.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === this._triggerAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`
: html`
<ms-textfield
label="${t("attribute_optional", L)}"
.value=${this._triggerAttribute}
@input=${(e: Event) => (this._triggerAttribute = (e.target as HTMLInputElement).value)}
></ms-textfield>
`
}
${this._renderTriggerTypeFields()} ${this._renderTriggerTypeFields()}
${this._renderTriggerLiveHint()} ${this._renderTriggerLiveHint()}
`} `}
@@ -1576,76 +1557,73 @@ export class MaintenanceTaskDialog extends LitElement {
`; `;
} }
/** Shared attribute selector: "use entity state" + suggested + remaining
* attributes (non-numeric flagged), textfield fallback when none known. */
private _renderAttributeSelect(cfg: {
label: string;
value: string;
suggested: string[];
available: { name: string; numeric: boolean }[];
onSelect: (value: string) => void;
}) {
const L = this._lang;
if (cfg.available.length > 0) {
return html`
<div class="select-row">
<label>${cfg.label}</label>
<select
.value=${cfg.value}
@change=${(e: Event) => cfg.onSelect((e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!cfg.value}>${t("use_entity_state", L)}</option>
${cfg.suggested.map(
(attr) => html`<option value=${attr} ?selected=${attr === cfg.value}>${attr} ★</option>`
)}
${cfg.available
.filter((a) => !cfg.suggested.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === cfg.value}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`;
}
return html`
<ms-textfield
label="${cfg.label}"
.value=${cfg.value}
@input=${(e: Event) => cfg.onSelect((e.target as HTMLInputElement).value.trim())}
></ms-textfield>
`;
}
/** Environmental attribute the same live-fetched dropdown the flat and /** Environmental attribute the same live-fetched dropdown the flat and
* compound attribute fields use, keyed by the environmental entity. */ * compound attribute fields use, keyed by the environmental entity. */
private _renderEnvironmentalAttribute(L: string) { private _renderEnvironmentalAttribute(L: string) {
this._fetchConditionAttributes(this._environmentalEntity); this._fetchConditionAttributes(this._environmentalEntity);
const opts = this._conditionAttrOptions[this._environmentalEntity]; const opts = this._conditionAttrOptions[this._environmentalEntity];
if (opts && opts.available.length > 0) { return this._renderAttributeSelect({
return html` label: t("environmental_attribute_optional", L),
<div class="select-row"> value: this._environmentalAttribute,
<label>${t("environmental_attribute_optional", L)}</label> suggested: opts?.suggested ?? [],
<select available: opts?.available ?? [],
.value=${this._environmentalAttribute} onSelect: (v) => (this._environmentalAttribute = v),
@change=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLSelectElement).value)} });
>
<option value="" ?selected=${!this._environmentalAttribute}>${t("use_entity_state", L)}</option>
${opts.suggested.map(
(attr) => html`<option value=${attr} ?selected=${attr === this._environmentalAttribute}>${attr} ★</option>`
)}
${opts.available
.filter((a) => !opts.suggested.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === this._environmentalAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`;
}
return html`
<ms-textfield
label="${t("environmental_attribute_optional", L)}"
.value=${this._environmentalAttribute}
@input=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLInputElement).value.trim())}
></ms-textfield>
`;
} }
/** Attribute selector for one compound condition the same live-fetched /** Attribute selector for one compound condition the same live-fetched
* dropdown the flat editor has, keyed by the condition's first entity. */ * dropdown the flat editor has, keyed by the condition's first entity. */
private _renderConditionAttribute(c: CompoundConditionDraft, i: number) { private _renderConditionAttribute(c: CompoundConditionDraft, i: number) {
const L = this._lang;
const firstId = c.entityIds.split(",")[0]?.trim() || ""; const firstId = c.entityIds.split(",")[0]?.trim() || "";
if (firstId) this._fetchConditionAttributes(firstId); if (firstId) this._fetchConditionAttributes(firstId);
const opts = firstId ? this._conditionAttrOptions[firstId] : undefined; const opts = firstId ? this._conditionAttrOptions[firstId] : undefined;
if (opts && opts.available.length > 0) { return this._renderAttributeSelect({
return html` label: t("attribute_optional", this._lang),
<div class="select-row"> value: c.attribute,
<label>${t("attribute_optional", L)}</label> suggested: opts?.suggested ?? [],
<select available: opts?.available ?? [],
.value=${c.attribute} onSelect: (v) => this._patchCondition(i, { attribute: v }),
@change=${(e: Event) => this._patchCondition(i, { attribute: (e.target as HTMLSelectElement).value })} });
>
<option value="" ?selected=${!c.attribute}>${t("use_entity_state", L)}</option>
${opts.suggested.map(
(attr) => html`<option value=${attr} ?selected=${attr === c.attribute}>${attr} ★</option>`
)}
${opts.available
.filter((a) => !opts.suggested.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === c.attribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`;
}
return html`
<ms-textfield
label="${t("attribute_optional", L)}"
.value=${c.attribute}
@input=${(e: Event) => this._patchCondition(i, { attribute: (e.target as HTMLInputElement).value.trim() })}
></ms-textfield>
`;
} }
/** Type-specific inputs for a single compound condition (mirrors the flat /** Type-specific inputs for a single compound condition (mirrors the flat
@@ -11,10 +11,12 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles"; import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import { downloadUrl } from "../helpers/download"; import { downloadUrl } from "../helpers/download";
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
import { formatBytes } from "../helpers/format-bytes"; import { formatBytes } from "../helpers/format-bytes";
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
interface Doc { interface Doc {
@@ -31,16 +33,6 @@ interface Doc {
part_ids?: string[]; part_ids?: string[];
} }
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
const CATEGORY_ICONS: Record<string, string> = {
manual: "mdi:book-open-variant",
warranty: "mdi:shield-check",
invoice: "mdi:receipt-text-outline",
spare_parts: "mdi:cog-outline",
photo: "mdi:image-outline",
other: "mdi:file-document-outline",
};
export class MaintenanceTaskDocuments extends LitElement { export class MaintenanceTaskDocuments extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entryId!: string; @property({ attribute: false }) public entryId!: string;
@@ -59,7 +51,7 @@ export class MaintenanceTaskDocuments extends LitElement {
private _localeReady = false; private _localeReady = false;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
/** The id linked docs reference — a task id or (part mode) a part id. */ /** The id linked docs reference — a task id or (part mode) a part id. */
@@ -158,19 +150,9 @@ export class MaintenanceTaskDocuments extends LitElement {
// A per-task page hint jumps straight to the relevant page via the PDF // A per-task page hint jumps straight to the relevant page via the PDF
// viewer's #page=N fragment (client-side, so it never breaks the signature). // viewer's #page=N fragment (client-side, so it never breaks the signature).
const page = this._pageFor(doc); const page = this._pageFor(doc);
const frag = page ? `#page=${page}` : "";
const win = window.open("about:blank", "_blank");
try { try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({ await openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : "");
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
// Absolute URL: a fragment on a *root-relative* path won't resolve against
// the blank popup's about:blank base, so it would silently stay blank.
if (win) win.location.href = new URL(s.path + frag, window.location.origin).href;
} catch (e) { } catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang); this._error = describeWsError(e, this._lang);
} }
} }
@@ -196,12 +178,7 @@ export class MaintenanceTaskDocuments extends LitElement {
private async _download(doc: Doc): Promise<void> { private async _download(doc: Doc): Promise<void> {
try { try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({ await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 30,
});
downloadUrl(s.path, doc.filename || doc.title || "document");
} catch (e) { } catch (e) {
this._error = describeWsError(e, this._lang); this._error = describeWsError(e, this._lang);
} }
@@ -14,7 +14,7 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence } from "../styles"; import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import { renderWeibullSection } from "../renderers/weibull"; import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction"; import { renderPredictionSection } from "../renderers/prediction";
@@ -62,7 +62,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
private _featuresLoaded = false; private _featuresLoaded = false;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in /** Open the dialog. Loads fresh data from /object via WS so dialog stays in
@@ -13,7 +13,8 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles"; import { t, ensureLocale, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors"; import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles"; import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -55,7 +56,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
} }
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
private get _isAdmin(): boolean { private get _isAdmin(): boolean {
@@ -317,9 +318,7 @@ if (!customElements.get("maintenance-vacation-section-card")) {
); );
} }
(window as { customCards?: unknown[] }).customCards = registerCustomCard({
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
type: "maintenance-vacation-section-card", type: "maintenance-vacation-section-card",
name: "Maintenance Supporter — Vacation", name: "Maintenance Supporter — Vacation",
description: "Inline vacation mode toggle + dates", description: "Inline vacation mode toggle + dates",
@@ -18,4 +18,29 @@ export const LS_KEYS = {
objectView: "maintenance_supporter_object_view", objectView: "maintenance_supporter_object_view",
objectsCache: "msp-objects-cache", objectsCache: "msp-objects-cache",
gettingStartedDismissed: "msp-gs-dismissed", gettingStartedDismissed: "msp-gs-dismissed",
batteryRosterSort: "ms_bf_roster_sort",
} as const; } as const;
/**
* Guarded storage access localStorage can THROW (Safari private mode /
* locked-down policies raise instead of returning null), and an unguarded
* call aborts the surrounding handler. The 2026-08 DRY audit found two
* hand-written call sites that had missed the guard; every access now goes
* through these two (a source tripwire forbids direct localStorage use
* outside this module).
*/
export function lsGet(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function lsSet(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* private mode / storage blocked */
}
}
@@ -483,6 +483,7 @@
"settings_export_json": "Exportovat JSON", "settings_export_json": "Exportovat JSON",
"settings_export_yaml": "Exportovat YAML", "settings_export_yaml": "Exportovat YAML",
"settings_export_csv": "Exportovat CSV", "settings_export_csv": "Exportovat CSV",
"settings_export_settings": "Exportovat nastavení (JSON)",
"settings_import_csv": "Importovat CSV", "settings_import_csv": "Importovat CSV",
"settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…", "settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…",
"settings_import_btn": "Importovat", "settings_import_btn": "Importovat",
@@ -484,6 +484,7 @@
"settings_export_json": "Eksporter JSON", "settings_export_json": "Eksporter JSON",
"settings_export_yaml": "Eksporter YAML", "settings_export_yaml": "Eksporter YAML",
"settings_export_csv": "Eksporter CSV", "settings_export_csv": "Eksporter CSV",
"settings_export_settings": "Eksportér indstillinger (JSON)",
"settings_import_csv": "Importer CSV", "settings_import_csv": "Importer CSV",
"settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…", "settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…",
"settings_import_btn": "Importer", "settings_import_btn": "Importer",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON exportieren", "settings_export_json": "JSON exportieren",
"settings_export_yaml": "YAML exportieren", "settings_export_yaml": "YAML exportieren",
"settings_export_csv": "CSV exportieren", "settings_export_csv": "CSV exportieren",
"settings_export_settings": "Einstellungen exportieren (JSON)",
"settings_import_csv": "CSV importieren", "settings_import_csv": "CSV importieren",
"settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…", "settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…",
"settings_import_btn": "Importieren", "settings_import_btn": "Importieren",
@@ -484,6 +484,7 @@
"settings_export_json": "Export JSON", "settings_export_json": "Export JSON",
"settings_export_yaml": "Export YAML", "settings_export_yaml": "Export YAML",
"settings_export_csv": "Export CSV", "settings_export_csv": "Export CSV",
"settings_export_settings": "Export settings (JSON)",
"settings_import_csv": "Import CSV", "settings_import_csv": "Import CSV",
"settings_import_placeholder": "Paste JSON or CSV content here…", "settings_import_placeholder": "Paste JSON or CSV content here…",
"settings_import_btn": "Import", "settings_import_btn": "Import",
@@ -483,6 +483,7 @@
"settings_export_json": "Exportar JSON", "settings_export_json": "Exportar JSON",
"settings_export_yaml": "Exportar YAML", "settings_export_yaml": "Exportar YAML",
"settings_export_csv": "Exportar CSV", "settings_export_csv": "Exportar CSV",
"settings_export_settings": "Exportar ajustes (JSON)",
"settings_import_csv": "Importar CSV", "settings_import_csv": "Importar CSV",
"settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…", "settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…",
"settings_import_btn": "Importar", "settings_import_btn": "Importar",
@@ -484,6 +484,7 @@
"settings_export_json": "Vie JSON", "settings_export_json": "Vie JSON",
"settings_export_yaml": "Vie YAML", "settings_export_yaml": "Vie YAML",
"settings_export_csv": "Vie CSV", "settings_export_csv": "Vie CSV",
"settings_export_settings": "Vie asetukset (JSON)",
"settings_import_csv": "Tuo CSV", "settings_import_csv": "Tuo CSV",
"settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…", "settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…",
"settings_import_btn": "Tuo", "settings_import_btn": "Tuo",
@@ -483,6 +483,7 @@
"settings_export_json": "Exporter JSON", "settings_export_json": "Exporter JSON",
"settings_export_yaml": "Exporter YAML", "settings_export_yaml": "Exporter YAML",
"settings_export_csv": "Exporter CSV", "settings_export_csv": "Exporter CSV",
"settings_export_settings": "Exporter les réglages (JSON)",
"settings_import_csv": "Importer CSV", "settings_import_csv": "Importer CSV",
"settings_import_placeholder": "Collez le contenu JSON ou CSV ici…", "settings_import_placeholder": "Collez le contenu JSON ou CSV ici…",
"settings_import_btn": "Importer", "settings_import_btn": "Importer",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON निर्यात करें", "settings_export_json": "JSON निर्यात करें",
"settings_export_yaml": "YAML निर्यात करें", "settings_export_yaml": "YAML निर्यात करें",
"settings_export_csv": "CSV निर्यात करें", "settings_export_csv": "CSV निर्यात करें",
"settings_export_settings": "सेटिंग्स निर्यात करें (JSON)",
"settings_import_csv": "CSV आयात करें", "settings_import_csv": "CSV आयात करें",
"settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…", "settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…",
"settings_import_btn": "आयात करें", "settings_import_btn": "आयात करें",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON exportálása", "settings_export_json": "JSON exportálása",
"settings_export_yaml": "YAML exportálása", "settings_export_yaml": "YAML exportálása",
"settings_export_csv": "CSV exportálása", "settings_export_csv": "CSV exportálása",
"settings_export_settings": "Beállítások exportálása (JSON)",
"settings_import_csv": "CSV importálása", "settings_import_csv": "CSV importálása",
"settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…", "settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…",
"settings_import_btn": "Importálás", "settings_import_btn": "Importálás",
@@ -483,6 +483,7 @@
"settings_export_json": "Esporta JSON", "settings_export_json": "Esporta JSON",
"settings_export_yaml": "Esporta YAML", "settings_export_yaml": "Esporta YAML",
"settings_export_csv": "Esporta CSV", "settings_export_csv": "Esporta CSV",
"settings_export_settings": "Esporta impostazioni (JSON)",
"settings_import_csv": "Importa CSV", "settings_import_csv": "Importa CSV",
"settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…", "settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…",
"settings_import_btn": "Importa", "settings_import_btn": "Importa",
@@ -484,6 +484,7 @@
"settings_export_json": "JSONをエクスポート", "settings_export_json": "JSONをエクスポート",
"settings_export_yaml": "YAMLをエクスポート", "settings_export_yaml": "YAMLをエクスポート",
"settings_export_csv": "CSVをエクスポート", "settings_export_csv": "CSVをエクスポート",
"settings_export_settings": "設定をエクスポート(JSON",
"settings_import_csv": "CSVをインポート", "settings_import_csv": "CSVをインポート",
"settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…", "settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…",
"settings_import_btn": "インポート", "settings_import_btn": "インポート",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON 내보내기", "settings_export_json": "JSON 내보내기",
"settings_export_yaml": "YAML 내보내기", "settings_export_yaml": "YAML 내보내기",
"settings_export_csv": "CSV 내보내기", "settings_export_csv": "CSV 내보내기",
"settings_export_settings": "설정 내보내기 (JSON)",
"settings_import_csv": "CSV 가져오기", "settings_import_csv": "CSV 가져오기",
"settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…", "settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…",
"settings_import_btn": "가져오기", "settings_import_btn": "가져오기",
@@ -484,6 +484,7 @@
"settings_export_json": "Eksporter JSON", "settings_export_json": "Eksporter JSON",
"settings_export_yaml": "Eksporter YAML", "settings_export_yaml": "Eksporter YAML",
"settings_export_csv": "Eksporter CSV", "settings_export_csv": "Eksporter CSV",
"settings_export_settings": "Eksporter innstillinger (JSON)",
"settings_import_csv": "Importer CSV", "settings_import_csv": "Importer CSV",
"settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…", "settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…",
"settings_import_btn": "Importer", "settings_import_btn": "Importer",
@@ -483,6 +483,7 @@
"settings_export_json": "JSON exporteren", "settings_export_json": "JSON exporteren",
"settings_export_yaml": "YAML exporteren", "settings_export_yaml": "YAML exporteren",
"settings_export_csv": "CSV exporteren", "settings_export_csv": "CSV exporteren",
"settings_export_settings": "Instellingen exporteren (JSON)",
"settings_import_csv": "CSV importeren", "settings_import_csv": "CSV importeren",
"settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…", "settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…",
"settings_import_btn": "Importeren", "settings_import_btn": "Importeren",
@@ -483,6 +483,7 @@
"settings_export_json": "Eksportuj JSON", "settings_export_json": "Eksportuj JSON",
"settings_export_yaml": "Eksportuj YAML", "settings_export_yaml": "Eksportuj YAML",
"settings_export_csv": "Eksportuj CSV", "settings_export_csv": "Eksportuj CSV",
"settings_export_settings": "Eksportuj ustawienia (JSON)",
"settings_import_csv": "Importuj CSV", "settings_import_csv": "Importuj CSV",
"settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…", "settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…",
"settings_import_btn": "Importuj", "settings_import_btn": "Importuj",
@@ -484,6 +484,7 @@
"settings_export_json": "Exportar JSON", "settings_export_json": "Exportar JSON",
"settings_export_yaml": "Exportar YAML", "settings_export_yaml": "Exportar YAML",
"settings_export_csv": "Exportar CSV", "settings_export_csv": "Exportar CSV",
"settings_export_settings": "Exportar configurações (JSON)",
"settings_import_csv": "Importar CSV", "settings_import_csv": "Importar CSV",
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…", "settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
"settings_import_btn": "Importar", "settings_import_btn": "Importar",
@@ -483,6 +483,7 @@
"settings_export_json": "Exportar JSON", "settings_export_json": "Exportar JSON",
"settings_export_yaml": "Exportar YAML", "settings_export_yaml": "Exportar YAML",
"settings_export_csv": "Exportar CSV", "settings_export_csv": "Exportar CSV",
"settings_export_settings": "Exportar definições (JSON)",
"settings_import_csv": "Importar CSV", "settings_import_csv": "Importar CSV",
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…", "settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
"settings_import_btn": "Importar", "settings_import_btn": "Importar",
@@ -483,6 +483,7 @@
"settings_export_json": "Экспорт JSON", "settings_export_json": "Экспорт JSON",
"settings_export_yaml": "Экспорт YAML", "settings_export_yaml": "Экспорт YAML",
"settings_export_csv": "Экспорт CSV", "settings_export_csv": "Экспорт CSV",
"settings_export_settings": "Экспорт настроек (JSON)",
"settings_import_csv": "Импорт CSV", "settings_import_csv": "Импорт CSV",
"settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…", "settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…",
"settings_import_btn": "Импортировать", "settings_import_btn": "Импортировать",
@@ -483,6 +483,7 @@
"settings_export_json": "Exportera JSON", "settings_export_json": "Exportera JSON",
"settings_export_yaml": "Exportera YAML", "settings_export_yaml": "Exportera YAML",
"settings_export_csv": "Exportera CSV", "settings_export_csv": "Exportera CSV",
"settings_export_settings": "Exportera inställningar (JSON)",
"settings_import_csv": "Importera CSV", "settings_import_csv": "Importera CSV",
"settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…", "settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…",
"settings_import_btn": "Importera", "settings_import_btn": "Importera",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON dışa aktar", "settings_export_json": "JSON dışa aktar",
"settings_export_yaml": "YAML dışa aktar", "settings_export_yaml": "YAML dışa aktar",
"settings_export_csv": "CSV dışa aktar", "settings_export_csv": "CSV dışa aktar",
"settings_export_settings": "Ayarları dışa aktar (JSON)",
"settings_import_csv": "CSV içe aktar", "settings_import_csv": "CSV içe aktar",
"settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…", "settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…",
"settings_import_btn": "İçe aktar", "settings_import_btn": "İçe aktar",
@@ -483,6 +483,7 @@
"settings_export_json": "Експортувати JSON", "settings_export_json": "Експортувати JSON",
"settings_export_yaml": "Експортувати YAML", "settings_export_yaml": "Експортувати YAML",
"settings_export_csv": "Експортувати CSV", "settings_export_csv": "Експортувати CSV",
"settings_export_settings": "Експорт налаштувань (JSON)",
"settings_import_csv": "Імпортувати CSV", "settings_import_csv": "Імпортувати CSV",
"settings_import_placeholder": "Вставте вміст JSON або CSV сюди…", "settings_import_placeholder": "Вставте вміст JSON або CSV сюди…",
"settings_import_btn": "Імпортувати", "settings_import_btn": "Імпортувати",
@@ -484,6 +484,7 @@
"settings_export_json": "导出 JSON", "settings_export_json": "导出 JSON",
"settings_export_yaml": "导出 YAML", "settings_export_yaml": "导出 YAML",
"settings_export_csv": "导出 CSV", "settings_export_csv": "导出 CSV",
"settings_export_settings": "导出设置(JSON",
"settings_import_csv": "导入 CSV", "settings_import_csv": "导入 CSV",
"settings_import_placeholder": "在此粘贴 JSON 或 CSV 内容…", "settings_import_placeholder": "在此粘贴 JSON 或 CSV 内容…",
"settings_import_btn": "导入", "settings_import_btn": "导入",
@@ -34,7 +34,8 @@ import {
type CalendarEvent, type CalendarEvent,
} from "./helpers/calendar-bucket"; } from "./helpers/calendar-bucket";
import { calendarStyles } from "./calendar-styles"; import { calendarStyles } from "./calendar-styles";
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles"; import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles";
import { registerCustomCard } from "./helpers/register-card";
import type { import type {
HomeAssistant, HomeAssistant,
MaintenanceObjectResponse, MaintenanceObjectResponse,
@@ -121,7 +122,7 @@ export class MaintenanceCalendarCard extends LitElement {
} }
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
@@ -642,31 +643,15 @@ if (!customElements.get("maintenance-supporter-calendar-card-editor")) {
} }
// Register with HACS / customCards so the picker shows it // Register with HACS / customCards so the picker shows it
const w = window as unknown as { // The type MUST match the registered element tag exactly (custom:X → tag X),
customCards?: Array<{ // or the picker entry resolves to a non-existent element and the strategy's
type: string; // calendar mode throws a config error.
name: string; registerCustomCard({
description: string; type: "maintenance-supporter-calendar-card",
preview?: boolean; name: "Maintenance Supporter — Calendar",
}>; description:
}; "Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",
w.customCards = w.customCards || []; preview: true,
// HA's custom-card resolver maps ``custom:X`` → element tag ``X``. Our element });
// is registered as ``maintenance-supporter-calendar-card``, so the customCards
// type MUST match that exact suffix or the card-picker entry resolves to a
// non-existent element and the strategy's calendar mode throws a config error.
const CALENDAR_CARD_TYPE = "maintenance-supporter-calendar-card";
const alreadyRegistered = w.customCards.some(
(c) => c.type === CALENDAR_CARD_TYPE,
);
if (!alreadyRegistered) {
w.customCards.push({
type: CALENDAR_CARD_TYPE,
name: "Maintenance Supporter — Calendar",
description:
"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",
preview: true,
});
}
export {}; export {};
@@ -2,7 +2,7 @@
import { LitElement, html, css, nothing } from "lit"; import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { t } from "./styles"; import { t, langOf } from "./styles";
import type { HomeAssistant, CardConfig, MaintenanceObjectResponse, SavedView } from "./types"; import type { HomeAssistant, CardConfig, MaintenanceObjectResponse, SavedView } from "./types";
const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const; const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const;
@@ -18,7 +18,7 @@ export class MaintenanceSupporterCardEditor extends LitElement {
private _objectsLoaded = false; private _objectsLoaded = false;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
setConfig(config: CardConfig): void { setConfig(config: CardConfig): void {
@@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge"; import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
import { hydrateObjects } from "./helpers/hydrate-objects"; import { hydrateObjects } from "./helpers/hydrate-objects";
import { property, state } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles"; import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles";
import { openSignedDocument } from "./helpers/document-url";
import { registerCustomCard } from "./helpers/register-card";
import type { import type {
HomeAssistant, HomeAssistant,
MaintenanceObjectResponse, MaintenanceObjectResponse,
@@ -54,7 +56,7 @@ export class MaintenanceSupporterCard extends LitElement {
private _docsLoadedFor = new Set<string>(); private _docsLoadedFor = new Set<string>();
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
static getConfigElement() { static getConfigElement() {
@@ -240,16 +242,10 @@ export class MaintenanceSupporterCard extends LitElement {
window.open(doc.url, "_blank", "noopener"); window.open(doc.url, "_blank", "noopener");
return; return;
} }
const win = window.open("about:blank", "_blank");
try { try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ await openSignedDocument(this.hass, doc.id);
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
} catch { } catch {
if (win) win.close(); /* silent — the card has no error surface */
} }
} }
@@ -696,8 +692,7 @@ if (!customElements.get("maintenance-supporter-card")) {
} }
// Register as custom card so the Lovelace card picker lists it. // Register as custom card so the Lovelace card picker lists it.
(window as any).customCards = (window as any).customCards || []; registerCustomCard({
(window as any).customCards.push({
type: "maintenance-supporter-card", type: "maintenance-supporter-card",
name: "Maintenance Supporter", name: "Maintenance Supporter",
description: "Overview of your maintenance tasks with quick actions.", description: "Overview of your maintenance tasks with quick actions.",
@@ -5,8 +5,9 @@ import { isSafeHttpUrl } from "./helpers/url";
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge"; import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
import { isStaleBundle } from "./helpers/bundle-version"; import { isStaleBundle } from "./helpers/bundle-version";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles"; import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs, langOf } from "./styles";
import { LS_KEYS } from "./helpers/storage-keys"; import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys";
import { openSignedDocument, signApiPath } from "./helpers/document-url";
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache"; import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
import { hydrateObjects } from "./helpers/hydrate-objects"; import { hydrateObjects } from "./helpers/hydrate-objects";
import { daysProgress } from "./helpers/interval"; import { daysProgress } from "./helpers/interval";
@@ -134,17 +135,22 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _unsub: (() => void) | null = null; @state() private _unsub: (() => void) | null = null;
@state() private _chartRangeDays = (() => { @state() private _chartRangeDays = (() => {
try { try {
const v = parseInt(localStorage.getItem(LS_KEYS.chartRange) || "", 10); const v = parseInt(lsGet(LS_KEYS.chartRange) || "", 10);
return [7, 30, 90, 365].includes(v) ? v : 30; return [7, 30, 90, 365].includes(v) ? v : 30;
} catch { } catch {
return 30; return 30;
} }
})(); })();
@state() private _hideOutliers = (() => { @state() private _hideOutliers = (() => {
try { return localStorage.getItem(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; } try { return lsGet(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; }
})(); })();
@state() private _historyFilter: string | null = null; @state() private _historyFilter: string | null = null;
@state() private _budget: BudgetStatus | null = null; @state() private _budget: BudgetStatus | null = null;
private get _currencySymbol(): string {
return this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
}
@state() private _groups: Record<string, MaintenanceGroup> = {}; @state() private _groups: Record<string, MaintenanceGroup> = {};
@state() private _detailStatsData: Map<string, StatisticsPoint[]> = new Map(); @state() private _detailStatsData: Map<string, StatisticsPoint[]> = new Map();
@state() private _miniStatsData: Map<string, StatisticsPoint[]> = new Map(); @state() private _miniStatsData: Map<string, StatisticsPoint[]> = new Map();
@@ -185,7 +191,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// Dashboard redesign state // Dashboard redesign state
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => { @state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
try { try {
const v = localStorage.getItem(LS_KEYS.overviewTab); const v = lsGet(LS_KEYS.overviewTab);
return v === "today" || v === "calendar" ? v : "dashboard"; return v === "today" || v === "calendar" ? v : "dashboard";
} catch { return "dashboard"; } } catch { return "dashboard"; }
})(); })();
@@ -217,7 +223,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// v2.15.0: collapsed analysis sections on the task-detail overview tab, // v2.15.0: collapsed analysis sections on the task-detail overview tab,
// remembered per section across visits. // remembered per section across visits.
@state() private _collapsedSections: Set<string> = (() => { @state() private _collapsedSections: Set<string> = (() => {
try { return new Set(JSON.parse(localStorage.getItem(LS_KEYS.collapsedSections) || "[]")); } try { return new Set(JSON.parse(lsGet(LS_KEYS.collapsedSections) || "[]")); }
catch { return new Set(); } catch { return new Set(); }
})(); })();
// v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own // v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own
@@ -241,7 +247,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _lastConnection: unknown = null; private _lastConnection: unknown = null;
private get _lang(): string { private get _lang(): string {
return this.hass?.language || "en"; return langOf(this.hass);
} }
/** /**
@@ -314,19 +320,19 @@ export class MaintenanceSupporterPanel extends LitElement {
// read here would abort connectedCallback and leave the panel blank. Every // read here would abort connectedCallback and leave the panel blank. Every
// other storage access in this file is wrapped; wrap these too. // other storage access in this file is wrapped; wrap these too.
try { try {
const saved = localStorage.getItem(LS_KEYS.taskSort); const saved = lsGet(LS_KEYS.taskSort);
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) { if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
this._sortMode = saved as SortMode; this._sortMode = saved as SortMode;
} }
const savedObj = localStorage.getItem(LS_KEYS.objectSort); const savedObj = lsGet(LS_KEYS.objectSort);
if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) { if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) {
this._objectSortMode = savedObj as ObjectSortMode; this._objectSortMode = savedObj as ObjectSortMode;
} }
const savedGroup = localStorage.getItem(LS_KEYS.groupBy); const savedGroup = lsGet(LS_KEYS.groupBy);
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) { if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
this._groupByMode = savedGroup as GroupByMode; this._groupByMode = savedGroup as GroupByMode;
} }
const savedView = localStorage.getItem(LS_KEYS.objectView); const savedView = lsGet(LS_KEYS.objectView);
if (savedView === "cards" || savedView === "table") { if (savedView === "cards" || savedView === "table") {
this._objectViewMode = savedView; this._objectViewMode = savedView;
} }
@@ -640,7 +646,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setChartRange(days: number): void { private _setChartRange(days: number): void {
if (days === this._chartRangeDays) return; if (days === this._chartRangeDays) return;
this._chartRangeDays = days; this._chartRangeDays = days;
try { localStorage.setItem(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ } try { lsSet(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ }
const task = this._selectedEntryId && this._selectedTaskId const task = this._selectedEntryId && this._selectedTaskId
? this._getTask(this._selectedEntryId, this._selectedTaskId) ? this._getTask(this._selectedEntryId, this._selectedTaskId)
: null; : null;
@@ -659,7 +665,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// Outlier filtering is client-side on the already-fetched series, so just // Outlier filtering is client-side on the already-fetched series, so just
// flip the flag and let renderChart re-filter — no re-fetch needed. // flip the flag and let renderChart re-filter — no re-fetch needed.
this._hideOutliers = hide; this._hideOutliers = hide;
try { localStorage.setItem(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ } try { lsSet(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ }
} }
private async _fetchMiniStatsForOverview(): Promise<void> { private async _fetchMiniStatsForOverview(): Promise<void> {
@@ -948,8 +954,8 @@ export class MaintenanceSupporterPanel extends LitElement {
} }
// Persist sort/group like the manual controls do, so they stick after reload. // Persist sort/group like the manual controls do, so they stick after reload.
try { try {
localStorage.setItem(LS_KEYS.taskSort, this._sortMode); lsSet(LS_KEYS.taskSort, this._sortMode);
localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); lsSet(LS_KEYS.groupBy, this._groupByMode);
} catch { } catch {
// ignore private-mode storage errors // ignore private-mode storage errors
} }
@@ -1387,6 +1393,29 @@ export class MaintenanceSupporterPanel extends LitElement {
// --- Actions --- // --- Actions ---
/** Run a mutating WS action: loading state, data reload, and on failure
* the SERVER's error message as the toast. The ~16 hand-written action
* methods had drifted (most swallowed the server message behind a generic
* "Action failed", three never set _actionLoading, one skipped the reload).
* Returns the result payload, or null when the call failed (toast shown). */
private async _runAction<T = Record<string, unknown>>(
msg: Record<string, unknown>,
opts?: { successToast?: string },
): Promise<T | null> {
this._actionLoading = true;
try {
const res = await this.hass.connection.sendMessagePromise<T>(msg);
await this._loadData();
if (opts?.successToast) this._showToast(opts.successToast);
return (res ?? {}) as T;
} catch (e) {
this._showToast(describeWsError(e, this._lang));
return null;
} finally {
this._actionLoading = false;
}
}
private async _deleteObject(entryId: string): Promise<void> { private async _deleteObject(entryId: string): Promise<void> {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog"); const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
const ok = await dlg?.confirm({ const ok = await dlg?.confirm({
@@ -1396,16 +1425,11 @@ export class MaintenanceSupporterPanel extends LitElement {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
try { const res = await this._runAction({
await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/object/delete",
type: "maintenance_supporter/object/delete", entry_id: entryId,
entry_id: entryId, });
}); if (res) this._showOverview();
this._showOverview();
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
} }
/** Open a printable maintenance report for the object in a new tab (the user /** Open a printable maintenance report for the object in a new tab (the user
@@ -1442,7 +1466,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const html = buildObjectReportHtml( const html = buildObjectReportHtml(
resp.object, resp.tasks, labels, resp.object, resp.tasks, labels,
(iso) => (iso ? formatDate(iso, L) : ""), (iso) => (iso ? formatDate(iso, L) : ""),
this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL, this._currencySymbol,
new Date().toISOString(), new Date().toISOString(),
); );
const url = URL.createObjectURL(new Blob([html], { type: "text/html" })); const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
@@ -1451,20 +1475,11 @@ export class MaintenanceSupporterPanel extends LitElement {
} }
private async _duplicateObject(entryId: string): Promise<void> { private async _duplicateObject(entryId: string): Promise<void> {
this._actionLoading = true; const res = await this._runAction<{ entry_id?: string }>(
try { { type: "maintenance_supporter/object/duplicate", entry_id: entryId },
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({ { successToast: t("object_duplicated", this._lang) },
type: "maintenance_supporter/object/duplicate", );
entry_id: entryId, if (res?.entry_id) this._showObject(res.entry_id);
});
await this._loadData();
this._showToast(t("object_duplicated", this._lang));
if (res?.entry_id) this._showObject(res.entry_id);
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
} }
private async _deleteTask(entryId: string, taskId: string): Promise<void> { private async _deleteTask(entryId: string, taskId: string): Promise<void> {
@@ -1476,60 +1491,37 @@ export class MaintenanceSupporterPanel extends LitElement {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
try { const res = await this._runAction({
await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/task/delete",
type: "maintenance_supporter/task/delete", entry_id: entryId,
entry_id: entryId, task_id: taskId,
task_id: taskId, });
}); if (res) this._showObject(entryId);
this._showObject(entryId);
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
} }
// v2.10.0: archive / unarchive a single task (reversible — no confirm). // v2.10.0: archive / unarchive a single task (reversible — no confirm).
private async _duplicateTask(entryId: string, taskId: string): Promise<void> { private async _duplicateTask(entryId: string, taskId: string): Promise<void> {
this._moreMenuOpen = false; this._moreMenuOpen = false;
this._actionLoading = true; const res = await this._runAction<{ task_id?: string }>(
try { { type: "maintenance_supporter/task/duplicate", entry_id: entryId, task_id: taskId },
const res = await this.hass.connection.sendMessagePromise<{ task_id?: string }>({ { successToast: t("task_duplicated", this._lang) },
type: "maintenance_supporter/task/duplicate", );
entry_id: entryId, // Jump straight to the copy so the user can rename/adjust it.
task_id: taskId, if (res?.task_id) this._showTask(entryId, res.task_id);
});
await this._loadData();
this._showToast(t("task_duplicated", this._lang));
// Jump straight to the copy so the user can rename/adjust it.
if (res?.task_id) this._showTask(entryId, res.task_id);
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
} }
private async _toggleArchiveTask(entryId: string, taskId: string, archived: boolean): Promise<void> { private async _toggleArchiveTask(entryId: string, taskId: string, archived: boolean): Promise<void> {
this._actionLoading = true; const res = await this._runAction({
try { type: archived
await this.hass.connection.sendMessagePromise({ ? "maintenance_supporter/task/unarchive"
type: archived : "maintenance_supporter/task/archive",
? "maintenance_supporter/task/unarchive" entry_id: entryId,
: "maintenance_supporter/task/archive", task_id: taskId,
entry_id: entryId, });
task_id: taskId, // Just archived → offer a one-tap undo (unarchive) instead of a confirm.
}); if (res && !archived) {
await this._loadData(); this._showUndoToast(t("task_archived", this._lang),
// Just archived → offer a one-tap undo (unarchive) instead of a confirm. () => this._toggleArchiveTask(entryId, taskId, true));
if (!archived) {
this._showUndoToast(t("task_archived", this._lang),
() => this._toggleArchiveTask(entryId, taskId, true));
}
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
} }
} }
@@ -1537,20 +1529,15 @@ export class MaintenanceSupporterPanel extends LitElement {
// is fully reversible, so instead of a blocking confirm we run it immediately // is fully reversible, so instead of a blocking confirm we run it immediately
// and offer an Undo toast (v2.14.0). // and offer an Undo toast (v2.14.0).
private async _toggleArchiveObject(entryId: string, archived: boolean): Promise<void> { private async _toggleArchiveObject(entryId: string, archived: boolean): Promise<void> {
try { const res = await this._runAction({
await this.hass.connection.sendMessagePromise({ type: archived
type: archived ? "maintenance_supporter/object/unarchive"
? "maintenance_supporter/object/unarchive" : "maintenance_supporter/object/archive",
: "maintenance_supporter/object/archive", entry_id: entryId,
entry_id: entryId, });
}); if (res && !archived) {
await this._loadData(); this._showUndoToast(t("object_archived", this._lang),
if (!archived) { () => this._toggleArchiveObject(entryId, true));
this._showUndoToast(t("object_archived", this._lang),
() => this._toggleArchiveObject(entryId, true));
}
} catch {
this._showToast(t("action_error", this._lang));
} }
} }
@@ -1567,31 +1554,21 @@ export class MaintenanceSupporterPanel extends LitElement {
inputType: "date", inputType: "date",
}); });
if (!result?.confirmed) return; if (!result?.confirmed) return;
try { const msg: Record<string, unknown> = {
const msg: Record<string, unknown> = { type: "maintenance_supporter/object/pause",
type: "maintenance_supporter/object/pause", entry_id: entryId,
entry_id: entryId, };
}; if (result.value) msg.until = result.value;
if (result.value) msg.until = result.value; if (await this._runAction(msg)) {
await this.hass.connection.sendMessagePromise(msg);
await this._loadData();
this._showUndoToast(t("object_paused", this._lang), this._showUndoToast(t("object_paused", this._lang),
() => this._togglePauseObject(entryId, true)); () => this._togglePauseObject(entryId, true));
} catch (e) {
this._showToast(describeWsError(e, this._lang));
} }
return; return;
} }
try { await this._runAction(
await this.hass.connection.sendMessagePromise({ { type: "maintenance_supporter/object/resume", entry_id: entryId },
type: "maintenance_supporter/object/resume", { successToast: t("object_resumed", this._lang) },
entry_id: entryId, );
});
await this._loadData();
this._showToast(t("object_resumed", this._lang));
} catch (e) {
this._showToast(describeWsError(e, this._lang));
}
} }
// v2.20 (N1): replace a worn-out object with a successor — the old one is // v2.20 (N1): replace a worn-out object with a successor — the old one is
@@ -1608,71 +1585,44 @@ export class MaintenanceSupporterPanel extends LitElement {
inputValue: currentName, inputValue: currentName,
}); });
if (!result?.confirmed) return; if (!result?.confirmed) return;
this._actionLoading = true; const res = await this._runAction<{ entry_id?: string }>(
try { {
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
type: "maintenance_supporter/object/replace", type: "maintenance_supporter/object/replace",
entry_id: entryId, entry_id: entryId,
name: result.value || currentName, name: result.value || currentName,
}); },
await this._loadData(); { successToast: t("object_replaced", this._lang) },
this._showToast(t("object_replaced", this._lang)); );
if (res?.entry_id) this._showObject(res.entry_id); if (res?.entry_id) this._showObject(res.entry_id);
} catch (e) {
this._showToast(describeWsError(e, this._lang));
} finally {
this._actionLoading = false;
}
} }
private async _skipTask(entryId: string, taskId: string, reason?: string): Promise<void> { private async _skipTask(entryId: string, taskId: string, reason?: string): Promise<void> {
this._actionLoading = true; const msg: Record<string, unknown> = {
try { type: "maintenance_supporter/task/skip",
const msg: Record<string, unknown> = { entry_id: entryId,
type: "maintenance_supporter/task/skip", task_id: taskId,
entry_id: entryId, };
task_id: taskId, if (reason) msg.reason = reason;
}; await this._runAction(msg);
if (reason) msg.reason = reason;
await this.hass.connection.sendMessagePromise(msg);
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
} }
private async _resetTask(entryId: string, taskId: string, resetDate?: string): Promise<void> { private async _resetTask(entryId: string, taskId: string, resetDate?: string): Promise<void> {
this._actionLoading = true; const msg: Record<string, unknown> = {
try { type: "maintenance_supporter/task/reset",
const msg: Record<string, unknown> = { entry_id: entryId,
type: "maintenance_supporter/task/reset", task_id: taskId,
entry_id: entryId, };
task_id: taskId, if (resetDate) msg.date = resetDate;
}; await this._runAction(msg);
if (resetDate) msg.date = resetDate;
await this.hass.connection.sendMessagePromise(msg);
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
} }
private async _applySuggestion(entryId: string, taskId: string, interval: number): Promise<void> { private async _applySuggestion(entryId: string, taskId: string, interval: number): Promise<void> {
try { await this._runAction({
await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/task/apply_suggestion",
type: "maintenance_supporter/task/apply_suggestion", entry_id: entryId,
entry_id: entryId, task_id: taskId,
task_id: taskId, interval: interval,
interval: interval, });
});
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
} }
private _openSeasonalOverrides(task: MaintenanceTask): void { private _openSeasonalOverrides(task: MaintenanceTask): void {
@@ -1683,28 +1633,24 @@ export class MaintenanceSupporterPanel extends LitElement {
} }
private async _reanalyzeInterval(entryId: string, taskId: string): Promise<void> { private async _reanalyzeInterval(entryId: string, taskId: string): Promise<void> {
try { const res = await this._runAction<{
const res = await this.hass.connection.sendMessagePromise({ recommended_interval: number | null;
type: "maintenance_supporter/task/analyze_interval", confidence: string;
entry_id: entryId, data_points: number;
task_id: taskId, recommendation_reason: string | null;
}) as { }>({
recommended_interval: number | null; type: "maintenance_supporter/task/analyze_interval",
confidence: string; entry_id: entryId,
data_points: number; task_id: taskId,
recommendation_reason: string | null; });
}; if (!res) return;
if (res.recommended_interval) { if (res.recommended_interval) {
this._showToast( this._showToast(
`${t("reanalyze_result", this._lang)}: ${res.recommended_interval} ${t("days", this._lang)} ` + `${t("reanalyze_result", this._lang)}: ${res.recommended_interval} ${t("days", this._lang)} ` +
`(${t(`confidence_${res.confidence}`, this._lang)}, ${res.data_points} ${t("data_points", this._lang)})`, `(${t(`confidence_${res.confidence}`, this._lang)}, ${res.data_points} ${t("data_points", this._lang)})`,
); );
} else { } else {
this._showToast(t("reanalyze_insufficient_data", this._lang)); this._showToast(t("reanalyze_insufficient_data", this._lang));
}
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} }
} }
@@ -1737,21 +1683,10 @@ export class MaintenanceSupporterPanel extends LitElement {
} }
private async _postponeTask(entryId: string, taskId: string, until: string): Promise<void> { private async _postponeTask(entryId: string, taskId: string, until: string): Promise<void> {
this._actionLoading = true; await this._runAction(
try { { type: "maintenance_supporter/task/postpone", entry_id: entryId, task_id: taskId, until },
await this.hass.connection.sendMessagePromise({ { successToast: t("postponed", this._lang) },
type: "maintenance_supporter/task/postpone", );
entry_id: entryId,
task_id: taskId,
until,
});
this._showToast(t("postponed", this._lang));
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
} }
private async _promptPostponeTask(entryId: string, taskId: string): Promise<void> { private async _promptPostponeTask(entryId: string, taskId: string): Promise<void> {
@@ -1769,19 +1704,13 @@ export class MaintenanceSupporterPanel extends LitElement {
} }
private async _snoozeTask(entryId: string, taskId: string): Promise<void> { private async _snoozeTask(entryId: string, taskId: string): Promise<void> {
this._actionLoading = true; // Now reloads like every sibling action — this was the one mutation that
try { // skipped the refresh, leaving the snoozed due date stale until the next
await this.hass.connection.sendMessagePromise({ // poll.
type: "maintenance_supporter/task/snooze", await this._runAction(
entry_id: entryId, { type: "maintenance_supporter/task/snooze", entry_id: entryId, task_id: taskId },
task_id: taskId, { successToast: t("snoozed", this._lang) },
}); );
this._showToast(t("snoozed", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
} }
private _dismissSuggestion(entryId?: string, taskId?: string): void { private _dismissSuggestion(entryId?: string, taskId?: string): void {
@@ -1850,11 +1779,13 @@ export class MaintenanceSupporterPanel extends LitElement {
if (manual) { if (manual) {
const start = manual.task_pages![taskId]; const start = manual.task_pages![taskId];
const count = 4; const count = 4;
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ const signed = {
type: "auth/sign_path", path: await signApiPath(
path: `/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`, this.hass,
expires: 3600, `/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`,
}); 3600,
),
};
excerpt = { excerpt = {
title: manual.title || manual.filename || "Manual", title: manual.title || manual.filename || "Manual",
startPage: start, endPage: start + count - 1, startPage: start, endPage: start + count - 1,
@@ -1920,19 +1851,9 @@ export class MaintenanceSupporterPanel extends LitElement {
if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener"); if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener");
return; return;
} }
// Open the tab synchronously (inside the click gesture) so it isn't void openSignedDocument(this.hass, doc.id).catch(() => {
// popup-blocked, then point it at the freshly signed URL. /* tab already closed by the helper; the panel toast adds no value here */
const win = window.open("about:blank", "_blank"); });
void this.hass.connection
.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
})
.then((signed) => {
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
})
.catch(() => win?.close());
} }
/** #73: persist one checklist tick. Sends the FULL current state (the /** #73: persist one checklist tick. Sends the FULL current state (the
@@ -1947,15 +1868,10 @@ export class MaintenanceSupporterPanel extends LitElement {
const current = task.checklist_progress?.[step] ?? false; const current = task.checklist_progress?.[step] ?? false;
state[step] = step === item ? done : current; state[step] = step === item ? done : current;
} }
try { await this._runAction({
await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/task/checklist_progress",
type: "maintenance_supporter/task/checklist_progress", entry_id: entryId, task_id: taskId, checklist_state: state,
entry_id: entryId, task_id: taskId, checklist_state: state, });
});
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
} }
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void { private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
@@ -1988,7 +1904,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// #104 follow-up: parts carry unit costs — the dialog offers their sum // #104 follow-up: parts carry unit costs — the dialog offers their sum
// as a one-click cost suggestion (buy task: restock qty × unit cost). // as a one-click cost suggestion (buy task: restock qty × unit cost).
dlg.restockUnitCost = tk?.part_ref ? (refPart?.cost ?? null) : null; dlg.restockUnitCost = tk?.part_ref ? (refPart?.cost ?? null) : null;
dlg.currencySymbol = this._budget?.currency_symbol || ""; dlg.currencySymbol = this._currencySymbol;
// #111: a link may point at another object's pool — name that object, and // #111: a link may point at another object's pool — name that object, and
// never drop a line that fails to resolve (the old .filter(Boolean) hid it). // never drop a line that fails to resolve (the old .filter(Boolean) hid it).
dlg.consumesInfo = (tk?.consumes_parts || []).map((link) => dlg.consumesInfo = (tk?.consumes_parts || []).map((link) =>
@@ -2275,7 +2191,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void { private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
this._overviewTab = tab; this._overviewTab = tab;
try { localStorage.setItem(LS_KEYS.overviewTab, tab); } catch { /* private mode */ } try { lsSet(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
this._scrollContentToTop(); this._scrollContentToTop();
} }
@@ -2441,7 +2357,7 @@ export class MaintenanceSupporterPanel extends LitElement {
@change=${(e: Event) => { @change=${(e: Event) => {
this._sortMode = (e.target as HTMLSelectElement).value as SortMode; this._sortMode = (e.target as HTMLSelectElement).value as SortMode;
this._activeViewId = ""; this._activeViewId = "";
try { localStorage.setItem(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ } try { lsSet(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ }
}} }}
> >
<option value="due_date" ?selected=${this._sortMode === "due_date"}>${t("sort_due_date", L)}</option> <option value="due_date" ?selected=${this._sortMode === "due_date"}>${t("sort_due_date", L)}</option>
@@ -2460,7 +2376,7 @@ export class MaintenanceSupporterPanel extends LitElement {
@change=${(e: Event) => { @change=${(e: Event) => {
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode; this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
this._activeViewId = ""; this._activeViewId = "";
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ } try { lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
}} }}
> >
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option> <option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
@@ -2765,7 +2681,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.value=${this._objectSortMode} .value=${this._objectSortMode}
@change=${(e: Event) => { @change=${(e: Event) => {
this._objectSortMode = (e.target as HTMLSelectElement).value as ObjectSortMode; this._objectSortMode = (e.target as HTMLSelectElement).value as ObjectSortMode;
localStorage.setItem(LS_KEYS.objectSort, this._objectSortMode); try { lsSet(LS_KEYS.objectSort, this._objectSortMode); } catch { /* private mode */ }
}} }}
> >
<option value="alphabetical" ?selected=${this._objectSortMode === "alphabetical"}>${t("sort_alphabetical", L)}</option> <option value="alphabetical" ?selected=${this._objectSortMode === "alphabetical"}>${t("sort_alphabetical", L)}</option>
@@ -2794,7 +2710,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.value=${this._groupByMode} .value=${this._groupByMode}
@change=${(e: Event) => { @change=${(e: Event) => {
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode; this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ } try { lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
}} }}
> >
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option> <option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
@@ -2843,7 +2759,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setObjectViewMode(mode: "cards" | "table"): void { private _setObjectViewMode(mode: "cards" | "table"): void {
this._objectViewMode = mode; this._objectViewMode = mode;
localStorage.setItem(LS_KEYS.objectView, mode); try { lsSet(LS_KEYS.objectView, mode); } catch { /* private mode */ }
} }
// ── #130: instance-wide parts overview ──────────────────────────────────── // ── #130: instance-wide parts overview ────────────────────────────────────
@@ -2851,7 +2767,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _renderAllParts() { private _renderAllParts() {
const L = this._lang; const L = this._lang;
const rows = this._allParts; const rows = this._allParts;
const currency = this._budget?.currency_symbol || ""; const currency = this._currencySymbol;
return html` return html`
<div class="breadcrumb"> <div class="breadcrumb">
<ha-icon-button @click=${() => this._showAllObjects()}> <ha-icon-button @click=${() => this._showAllObjects()}>
@@ -3127,15 +3043,10 @@ export class MaintenanceSupporterPanel extends LitElement {
}) })
: confirm(`${t("delete_group_confirm", this._lang).replace("{name}", name)}`); : confirm(`${t("delete_group_confirm", this._lang).replace("{name}", name)}`);
if (!ok) return; if (!ok) return;
try { await this._runAction({
await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/group/delete",
type: "maintenance_supporter/group/delete", group_id: groupId,
group_id: groupId, });
});
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
} }
/** Budget as KPI tiles in the stats strip (#125) replaces the old /** Budget as KPI tiles in the stats strip (#125) replaces the old
@@ -3148,7 +3059,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const b = this._budget; const b = this._budget;
if (!b) return nothing; if (!b) return nothing;
const L = this._lang; const L = this._lang;
const cs = b.currency_symbol || DEFAULT_CURRENCY_SYMBOL; const cs = this._currencySymbol;
const tile = (label: string, spent: number, budget: number | null) => { const tile = (label: string, spent: number, budget: number | null) => {
if (budget !== null) { if (budget !== null) {
const pct = Math.min(100, Math.max(0, (spent / budget) * 100)); const pct = Math.min(100, Math.max(0, (spent / budget) * 100));
@@ -3415,7 +3326,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.entryId=${obj.entry_id} .entryId=${obj.entry_id}
.parts=${obj.parts || []} .parts=${obj.parts || []}
.canWrite=${!isOperator} .canWrite=${!isOperator}
.currencySymbol=${this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL} .currencySymbol=${this._currencySymbol}
@parts-changed=${() => this._loadData()} @parts-changed=${() => this._loadData()}
></maintenance-parts-section> ></maintenance-parts-section>
</div> </div>
@@ -3506,7 +3417,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _gsDismissed(): Set<string> { private _gsDismissed(): Set<string> {
try { try {
return new Set(JSON.parse(localStorage.getItem(LS_KEYS.gettingStartedDismissed) || "[]")); return new Set(JSON.parse(lsGet(LS_KEYS.gettingStartedDismissed) || "[]"));
} catch { } catch {
return new Set(); return new Set();
} }
@@ -3515,7 +3426,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _dismissGettingStarted(id: string): void { private _dismissGettingStarted(id: string): void {
const next = this._gsDismissed(); const next = this._gsDismissed();
next.add(id); next.add(id);
try { localStorage.setItem(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ } try { lsSet(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ }
this.requestUpdate(); this.requestUpdate();
} }
@@ -3612,7 +3523,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const next = new Set(this._collapsedSections); const next = new Set(this._collapsedSections);
if (next.has(key)) next.delete(key); else next.add(key); if (next.has(key)) next.delete(key); else next.add(key);
this._collapsedSections = next; this._collapsedSections = next;
try { localStorage.setItem(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ } try { lsSet(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ }
} }
/** Build the context the history renderers need from panel state. */ /** Build the context the history renderers need from panel state. */
@@ -3639,7 +3550,7 @@ export class MaintenanceSupporterPanel extends LitElement {
hass: this.hass, hass: this.hass,
filter: this._historyFilter, filter: this._historyFilter,
search: this._historySearch, search: this._historySearch,
currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL, currencySymbol: this._currencySymbol,
setFilter: (f) => { this._historyFilter = f; }, setFilter: (f) => { this._historyFilter = f; },
setSearch: (s) => { this._historySearch = s; }, setSearch: (s) => { this._historySearch = s; },
openEdit: (entry) => this._openHistoryEdit(entry), openEdit: (entry) => this._openHistoryEdit(entry),
@@ -3674,7 +3585,7 @@ export class MaintenanceSupporterPanel extends LitElement {
moreMenuOpen: this._moreMenuOpen, moreMenuOpen: this._moreMenuOpen,
activeTab: this._activeTab, activeTab: this._activeTab,
features: this._features, features: this._features,
currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL, currencySymbol: this._currencySymbol,
collapsedSections: this._collapsedSections, collapsedSections: this._collapsedSections,
costDurationToggle: this._costDurationToggle, costDurationToggle: this._costDurationToggle,
suggestionDismissed: this._dismissedSuggestions.has(`${entryId}_${taskId}`), suggestionDismissed: this._dismissedSuggestions.has(`${entryId}_${taskId}`),
@@ -79,6 +79,17 @@ export function t(key: string, lang?: string): string {
return STORE[l]?.[key] ?? STORE.en[key] ?? key; return STORE[l]?.[key] ?? STORE.en[key] ?? key;
} }
/**
* The UI language for a hass object the ONE rule every component's `_lang`
* getter delegates to. Before this helper the 23 hand-copied getters had
* drifted into three variants (`|| "en"`, `?? navigator.language...`, and a
* `locale.language` probe), so sibling components could resolve different
* languages from the same hass.
*/
export function langOf(hass?: { language?: string }): string {
return hass?.language || "en";
}
/** True when *lang*'s table is in memory (English is always bundled). */ /** True when *lang*'s table is in memory (English is always bundled). */
export function isLocaleLoaded(lang?: string): boolean { export function isLocaleLoaded(lang?: string): boolean {
const l = normLang(lang); const l = normLang(lang);
@@ -483,6 +483,7 @@
"settings_export_json": "Exportovat JSON", "settings_export_json": "Exportovat JSON",
"settings_export_yaml": "Exportovat YAML", "settings_export_yaml": "Exportovat YAML",
"settings_export_csv": "Exportovat CSV", "settings_export_csv": "Exportovat CSV",
"settings_export_settings": "Exportovat nastavení (JSON)",
"settings_import_csv": "Importovat CSV", "settings_import_csv": "Importovat CSV",
"settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…", "settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…",
"settings_import_btn": "Importovat", "settings_import_btn": "Importovat",
@@ -484,6 +484,7 @@
"settings_export_json": "Eksporter JSON", "settings_export_json": "Eksporter JSON",
"settings_export_yaml": "Eksporter YAML", "settings_export_yaml": "Eksporter YAML",
"settings_export_csv": "Eksporter CSV", "settings_export_csv": "Eksporter CSV",
"settings_export_settings": "Eksportér indstillinger (JSON)",
"settings_import_csv": "Importer CSV", "settings_import_csv": "Importer CSV",
"settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…", "settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…",
"settings_import_btn": "Importer", "settings_import_btn": "Importer",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON exportieren", "settings_export_json": "JSON exportieren",
"settings_export_yaml": "YAML exportieren", "settings_export_yaml": "YAML exportieren",
"settings_export_csv": "CSV exportieren", "settings_export_csv": "CSV exportieren",
"settings_export_settings": "Einstellungen exportieren (JSON)",
"settings_import_csv": "CSV importieren", "settings_import_csv": "CSV importieren",
"settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…", "settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…",
"settings_import_btn": "Importieren", "settings_import_btn": "Importieren",
@@ -484,6 +484,7 @@
"settings_export_json": "Export JSON", "settings_export_json": "Export JSON",
"settings_export_yaml": "Export YAML", "settings_export_yaml": "Export YAML",
"settings_export_csv": "Export CSV", "settings_export_csv": "Export CSV",
"settings_export_settings": "Export settings (JSON)",
"settings_import_csv": "Import CSV", "settings_import_csv": "Import CSV",
"settings_import_placeholder": "Paste JSON or CSV content here…", "settings_import_placeholder": "Paste JSON or CSV content here…",
"settings_import_btn": "Import", "settings_import_btn": "Import",
@@ -483,6 +483,7 @@
"settings_export_json": "Exportar JSON", "settings_export_json": "Exportar JSON",
"settings_export_yaml": "Exportar YAML", "settings_export_yaml": "Exportar YAML",
"settings_export_csv": "Exportar CSV", "settings_export_csv": "Exportar CSV",
"settings_export_settings": "Exportar ajustes (JSON)",
"settings_import_csv": "Importar CSV", "settings_import_csv": "Importar CSV",
"settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…", "settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…",
"settings_import_btn": "Importar", "settings_import_btn": "Importar",
@@ -484,6 +484,7 @@
"settings_export_json": "Vie JSON", "settings_export_json": "Vie JSON",
"settings_export_yaml": "Vie YAML", "settings_export_yaml": "Vie YAML",
"settings_export_csv": "Vie CSV", "settings_export_csv": "Vie CSV",
"settings_export_settings": "Vie asetukset (JSON)",
"settings_import_csv": "Tuo CSV", "settings_import_csv": "Tuo CSV",
"settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…", "settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…",
"settings_import_btn": "Tuo", "settings_import_btn": "Tuo",
@@ -483,6 +483,7 @@
"settings_export_json": "Exporter JSON", "settings_export_json": "Exporter JSON",
"settings_export_yaml": "Exporter YAML", "settings_export_yaml": "Exporter YAML",
"settings_export_csv": "Exporter CSV", "settings_export_csv": "Exporter CSV",
"settings_export_settings": "Exporter les réglages (JSON)",
"settings_import_csv": "Importer CSV", "settings_import_csv": "Importer CSV",
"settings_import_placeholder": "Collez le contenu JSON ou CSV ici…", "settings_import_placeholder": "Collez le contenu JSON ou CSV ici…",
"settings_import_btn": "Importer", "settings_import_btn": "Importer",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON निर्यात करें", "settings_export_json": "JSON निर्यात करें",
"settings_export_yaml": "YAML निर्यात करें", "settings_export_yaml": "YAML निर्यात करें",
"settings_export_csv": "CSV निर्यात करें", "settings_export_csv": "CSV निर्यात करें",
"settings_export_settings": "सेटिंग्स निर्यात करें (JSON)",
"settings_import_csv": "CSV आयात करें", "settings_import_csv": "CSV आयात करें",
"settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…", "settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…",
"settings_import_btn": "आयात करें", "settings_import_btn": "आयात करें",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON exportálása", "settings_export_json": "JSON exportálása",
"settings_export_yaml": "YAML exportálása", "settings_export_yaml": "YAML exportálása",
"settings_export_csv": "CSV exportálása", "settings_export_csv": "CSV exportálása",
"settings_export_settings": "Beállítások exportálása (JSON)",
"settings_import_csv": "CSV importálása", "settings_import_csv": "CSV importálása",
"settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…", "settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…",
"settings_import_btn": "Importálás", "settings_import_btn": "Importálás",
@@ -483,6 +483,7 @@
"settings_export_json": "Esporta JSON", "settings_export_json": "Esporta JSON",
"settings_export_yaml": "Esporta YAML", "settings_export_yaml": "Esporta YAML",
"settings_export_csv": "Esporta CSV", "settings_export_csv": "Esporta CSV",
"settings_export_settings": "Esporta impostazioni (JSON)",
"settings_import_csv": "Importa CSV", "settings_import_csv": "Importa CSV",
"settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…", "settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…",
"settings_import_btn": "Importa", "settings_import_btn": "Importa",
@@ -484,6 +484,7 @@
"settings_export_json": "JSONをエクスポート", "settings_export_json": "JSONをエクスポート",
"settings_export_yaml": "YAMLをエクスポート", "settings_export_yaml": "YAMLをエクスポート",
"settings_export_csv": "CSVをエクスポート", "settings_export_csv": "CSVをエクスポート",
"settings_export_settings": "設定をエクスポート(JSON",
"settings_import_csv": "CSVをインポート", "settings_import_csv": "CSVをインポート",
"settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…", "settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…",
"settings_import_btn": "インポート", "settings_import_btn": "インポート",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON 내보내기", "settings_export_json": "JSON 내보내기",
"settings_export_yaml": "YAML 내보내기", "settings_export_yaml": "YAML 내보내기",
"settings_export_csv": "CSV 내보내기", "settings_export_csv": "CSV 내보내기",
"settings_export_settings": "설정 내보내기 (JSON)",
"settings_import_csv": "CSV 가져오기", "settings_import_csv": "CSV 가져오기",
"settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…", "settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…",
"settings_import_btn": "가져오기", "settings_import_btn": "가져오기",
@@ -484,6 +484,7 @@
"settings_export_json": "Eksporter JSON", "settings_export_json": "Eksporter JSON",
"settings_export_yaml": "Eksporter YAML", "settings_export_yaml": "Eksporter YAML",
"settings_export_csv": "Eksporter CSV", "settings_export_csv": "Eksporter CSV",
"settings_export_settings": "Eksporter innstillinger (JSON)",
"settings_import_csv": "Importer CSV", "settings_import_csv": "Importer CSV",
"settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…", "settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…",
"settings_import_btn": "Importer", "settings_import_btn": "Importer",
@@ -483,6 +483,7 @@
"settings_export_json": "JSON exporteren", "settings_export_json": "JSON exporteren",
"settings_export_yaml": "YAML exporteren", "settings_export_yaml": "YAML exporteren",
"settings_export_csv": "CSV exporteren", "settings_export_csv": "CSV exporteren",
"settings_export_settings": "Instellingen exporteren (JSON)",
"settings_import_csv": "CSV importeren", "settings_import_csv": "CSV importeren",
"settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…", "settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…",
"settings_import_btn": "Importeren", "settings_import_btn": "Importeren",
@@ -483,6 +483,7 @@
"settings_export_json": "Eksportuj JSON", "settings_export_json": "Eksportuj JSON",
"settings_export_yaml": "Eksportuj YAML", "settings_export_yaml": "Eksportuj YAML",
"settings_export_csv": "Eksportuj CSV", "settings_export_csv": "Eksportuj CSV",
"settings_export_settings": "Eksportuj ustawienia (JSON)",
"settings_import_csv": "Importuj CSV", "settings_import_csv": "Importuj CSV",
"settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…", "settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…",
"settings_import_btn": "Importuj", "settings_import_btn": "Importuj",
@@ -484,6 +484,7 @@
"settings_export_json": "Exportar JSON", "settings_export_json": "Exportar JSON",
"settings_export_yaml": "Exportar YAML", "settings_export_yaml": "Exportar YAML",
"settings_export_csv": "Exportar CSV", "settings_export_csv": "Exportar CSV",
"settings_export_settings": "Exportar configurações (JSON)",
"settings_import_csv": "Importar CSV", "settings_import_csv": "Importar CSV",
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…", "settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
"settings_import_btn": "Importar", "settings_import_btn": "Importar",
@@ -483,6 +483,7 @@
"settings_export_json": "Exportar JSON", "settings_export_json": "Exportar JSON",
"settings_export_yaml": "Exportar YAML", "settings_export_yaml": "Exportar YAML",
"settings_export_csv": "Exportar CSV", "settings_export_csv": "Exportar CSV",
"settings_export_settings": "Exportar definições (JSON)",
"settings_import_csv": "Importar CSV", "settings_import_csv": "Importar CSV",
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…", "settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
"settings_import_btn": "Importar", "settings_import_btn": "Importar",
@@ -483,6 +483,7 @@
"settings_export_json": "Экспорт JSON", "settings_export_json": "Экспорт JSON",
"settings_export_yaml": "Экспорт YAML", "settings_export_yaml": "Экспорт YAML",
"settings_export_csv": "Экспорт CSV", "settings_export_csv": "Экспорт CSV",
"settings_export_settings": "Экспорт настроек (JSON)",
"settings_import_csv": "Импорт CSV", "settings_import_csv": "Импорт CSV",
"settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…", "settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…",
"settings_import_btn": "Импортировать", "settings_import_btn": "Импортировать",
@@ -483,6 +483,7 @@
"settings_export_json": "Exportera JSON", "settings_export_json": "Exportera JSON",
"settings_export_yaml": "Exportera YAML", "settings_export_yaml": "Exportera YAML",
"settings_export_csv": "Exportera CSV", "settings_export_csv": "Exportera CSV",
"settings_export_settings": "Exportera inställningar (JSON)",
"settings_import_csv": "Importera CSV", "settings_import_csv": "Importera CSV",
"settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…", "settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…",
"settings_import_btn": "Importera", "settings_import_btn": "Importera",
@@ -484,6 +484,7 @@
"settings_export_json": "JSON dışa aktar", "settings_export_json": "JSON dışa aktar",
"settings_export_yaml": "YAML dışa aktar", "settings_export_yaml": "YAML dışa aktar",
"settings_export_csv": "CSV dışa aktar", "settings_export_csv": "CSV dışa aktar",
"settings_export_settings": "Ayarları dışa aktar (JSON)",
"settings_import_csv": "CSV içe aktar", "settings_import_csv": "CSV içe aktar",
"settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…", "settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…",
"settings_import_btn": "İçe aktar", "settings_import_btn": "İçe aktar",
@@ -483,6 +483,7 @@
"settings_export_json": "Експортувати JSON", "settings_export_json": "Експортувати JSON",
"settings_export_yaml": "Експортувати YAML", "settings_export_yaml": "Експортувати YAML",
"settings_export_csv": "Експортувати CSV", "settings_export_csv": "Експортувати CSV",
"settings_export_settings": "Експорт налаштувань (JSON)",
"settings_import_csv": "Імпортувати CSV", "settings_import_csv": "Імпортувати CSV",
"settings_import_placeholder": "Вставте вміст JSON або CSV сюди…", "settings_import_placeholder": "Вставте вміст JSON або CSV сюди…",
"settings_import_btn": "Імпортувати", "settings_import_btn": "Імпортувати",

Some files were not shown because too many files have changed in this diff Show More