from __future__ import annotations import asyncio import logging import os import traceback from homeassistant.core import HomeAssistant, ServiceCall, callback # type: ignore[reportMissingImports] from homeassistant.config_entries import ConfigEntry # type: ignore[reportMissingImports] from homeassistant.helpers import config_validation as cv # type: ignore[reportMissingImports] from homeassistant.helpers.storage import Store # type: ignore[reportMissingImports] from homeassistant.helpers.dispatcher import async_dispatcher_send, async_dispatcher_connect # type: ignore[reportMissingImports] from homeassistant.helpers.event import async_track_point_in_utc_time, async_track_state_change_event, async_track_time_interval # type: ignore[reportMissingImports] from homeassistant.util import dt as dt_util # type: ignore[reportMissingImports] from datetime import timedelta from homeassistant.components.http import HomeAssistantView # type: ignore[reportMissingImports] from homeassistant.const import UnitOfTemperature # type: ignore[reportMissingImports] from typing import Any, Optional from .const import DOMAIN, STORAGE_KEY, STORAGE_VERSION, SIGNAL_UPDATED, BACKUP_STORAGE_KEY CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _LOGGER = logging.getLogger(__name__) def _norm_instance_id(raw: Any) -> str: """Normalize an instance id used to namespace schedules/settings. Must be stable, JSON-safe and reasonably human editable. """ try: s = str(raw or "").strip() except Exception: s = "" if not s: return "default" # Keep a conservative charset to avoid surprises in storage keys and URLs. out = [] for ch in s: if ch.isalnum() or ch in ("_", "-", "."): out.append(ch) else: out.append("_") s2 = "".join(out) s2 = s2[:64] return s2 or "default" def _ensure_instances_loaded(hass: HomeAssistant) -> None: """Ensure hass.data[DOMAIN] has instances + active_instance_id. This is defensive in case older installs call services before async_setup_entry finished. """ try: data = hass.data.setdefault(DOMAIN, {}) if not isinstance(data.get("instances"), dict): data["instances"] = {"default": {"schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}}} if not isinstance(data.get("active_instance_id"), str): data["active_instance_id"] = "default" except Exception: pass def _c_to_f(c: float) -> float: return (float(c) * 9.0 / 5.0) + 32.0 def _f_to_c(f: float) -> float: return (float(f) - 32.0) * 5.0 / 9.0 def _dbg_write_sync(path: str, msg: str) -> None: with open(path, "a", encoding="utf-8") as fh: fh.write(msg + "\n") def _dbg(msg: str) -> None: """Write lightweight debug info to local debug.log without blocking the event loop.""" try: base = os.path.dirname(__file__) path = os.path.join(base, "debug.log") try: loop = asyncio.get_running_loop() except RuntimeError: _dbg_write_sync(path, msg) return # Fire-and-forget file write in a worker thread. loop.create_task(asyncio.to_thread(_dbg_write_sync, path, msg)) except Exception: # Debug logging must never interfere with the integration. pass def build_state_payload(hass: HomeAssistant, versions_only: bool = False, instance_id: str | None = None) -> dict[str, Any]: """Build a JSON-serializable snapshot of the current timeline state.""" if not hass or DOMAIN not in hass.data: # Return safe defaults if integration not yet loaded _dbg("build_state_payload: hass or DOMAIN missing, returning defaults") return { "version": 1, "settings_version": 1, "colors_version": 1, "weekday_version": 1, "profile_version": 1, "backup_version": 1, } if versions_only else { "version": 1, "settings_version": 1, "colors_version": 1, "weekday_version": 1, "profile_version": 1, "backup_version": 1, "schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}, "backup": {"slots": [], "slot_index": 0, "schedules": {}, "settings": {}, "weekdays": {}, "profiles": {}, "version": 1, "last_backup_ts": None, "partial_flags": None}, } data = hass.data[DOMAIN] _ensure_instances_loaded(hass) instances = data.get("instances") if isinstance(data.get("instances"), dict) else {} active_iid = _norm_instance_id(data.get("active_instance_id") or "default") if active_iid not in instances and instances: # Pick first available instance if active id is missing. try: active_iid = sorted(instances.keys())[0] except Exception: active_iid = "default" req_iid = _norm_instance_id(instance_id) if instance_id else active_iid if req_iid not in instances and instances: req_iid = active_iid inst = instances.get(req_iid, {}) if isinstance(instances.get(req_iid), dict) else {} payload: dict[str, Any] = { "version": int(data.get("version", 1)), "settings_version": int(data.get("settings_version", data.get("version", 1))), "colors_version": int(data.get("colors_version", data.get("version", 1))), "weekday_version": int(data.get("weekday_version", data.get("version", 1))), "profile_version": int(data.get("profile_version", data.get("version", 1))), "backup_version": int(data.get("backup_version", 1)), "active_instance_id": active_iid, "instance_id": req_iid, "instances": sorted(list(instances.keys())) if isinstance(instances, dict) else ["default"], } if versions_only: return payload payload.update({ # Return data for the requested instance (or the active instance when not specified). "schedules": inst.get("schedules", {}) if isinstance(inst, dict) else data.get("schedules", {}), "weekdays": inst.get("weekdays", {}) if isinstance(inst, dict) else data.get("weekday_schedules", {}), "profiles": inst.get("profiles", {}) if isinstance(inst, dict) else data.get("profile_schedules", {}), "settings": inst.get("settings", {}) if isinstance(inst, dict) else data.get("settings", {}), "colors": inst.get("colors", {}) if isinstance(inst, dict) else data.get("colors", {}), "backup": { "slots": data.get("backup_slots", []), "slot_index": data.get("backup_slot_index", 0), "schedules": data.get("backup_schedules", {}), "settings": data.get("backup_settings", {}), "weekdays": data.get("backup_weekdays", {}), "profiles": data.get("backup_profiles", {}), "version": data.get("backup_version", 1), "last_backup_ts": data.get("backup_last_ts"), "partial_flags": data.get("backup_partial_flags"), }, }) return payload class ThermostatTimelineStateView(HomeAssistantView): url = "/api/thermostat_timeline/state" name = "api:thermostat_timeline_state" requires_auth = True def __init__(self, hass: HomeAssistant): self.hass = hass async def get(self, request): try: _dbg("API /state requested") iid = None try: iid = request.query.get("instance_id") except Exception: iid = None payload = build_state_payload(self.hass, versions_only=False, instance_id=iid) _LOGGER.debug("API /state requested, version=%s", payload.get("version")) _dbg(f"API /state returning version={payload.get('version')}") return self.json(payload) except Exception as e: _LOGGER.error("API /state failed: %s", e, exc_info=True) _dbg("API /state failed: " + str(e) + "\n" + traceback.format_exc()) return self.json({"error": str(e)}, status_code=500) class ThermostatTimelineVersionView(HomeAssistantView): url = "/api/thermostat_timeline/version" name = "api:thermostat_timeline_version" requires_auth = True def __init__(self, hass: HomeAssistant): self.hass = hass async def get(self, request): try: _dbg("API /version requested") payload = build_state_payload(self.hass, versions_only=True) _LOGGER.debug("API /version requested, returning: %s", payload) _dbg(f"API /version returning: {payload}") return self.json(payload) except Exception as e: _LOGGER.error("API /version failed: %s", e, exc_info=True) _dbg("API /version failed: " + str(e) + "\n" + traceback.format_exc()) return self.json({"error": str(e)}, status_code=500) async def async_setup(hass: HomeAssistant, config: dict) -> bool: # YAML fallback: hvis brugeren har 'thermostat_timeline:' i configuration.yaml if DOMAIN in config: # Opret/indlæs config entry via IMPORT hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": "import"}, data={} ) ) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}.json") data = await store.async_load() or {} backup_store = Store(hass, STORAGE_VERSION, f"{BACKUP_STORAGE_KEY}.json") backup_data = await backup_store.async_load() or {} _dbg("async_setup_entry: loaded data version=" + str(data.get("version"))) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN]["store"] = store # Ensure Lovelace card JS is available and resource is registered (best-effort) try: from .frontend import ensure_frontend await ensure_frontend(hass) except Exception: _LOGGER.debug("%s: ensure_frontend failed", DOMAIN, exc_info=True) # --- Multi-instance store support (backwards compatible) --- instances: dict[str, Any] = {} active_iid = _norm_instance_id(data.get("active_instance_id") or data.get("active_instance") or "default") raw_instances = data.get("instances") if isinstance(raw_instances, dict): for k, v in raw_instances.items(): if not isinstance(v, dict): continue iid = _norm_instance_id(k) instances[iid] = { "schedules": v.get("schedules", {}) if isinstance(v.get("schedules"), dict) else {}, "weekdays": v.get("weekdays", {}) if isinstance(v.get("weekdays"), dict) else {}, "profiles": v.get("profiles", {}) if isinstance(v.get("profiles"), dict) else {}, "settings": v.get("settings", {}) if isinstance(v.get("settings"), dict) else {}, "colors": v.get("colors", {}) if isinstance(v.get("colors"), dict) else {}, } if not instances: # Legacy flat format: migrate into default instance. instances = { "default": { "schedules": data.get("schedules", {}) if isinstance(data.get("schedules"), dict) else {}, "weekdays": data.get("weekdays", {}) if isinstance(data.get("weekdays"), dict) else {}, "profiles": data.get("profiles", {}) if isinstance(data.get("profiles"), dict) else {}, "settings": data.get("settings", {}) if isinstance(data.get("settings"), dict) else {}, "colors": data.get("colors", {}) if isinstance(data.get("colors"), dict) else {}, } } active_iid = "default" if active_iid not in instances: try: active_iid = sorted(instances.keys())[0] except Exception: active_iid = "default" hass.data[DOMAIN]["instances"] = instances hass.data[DOMAIN]["active_instance_id"] = active_iid # Keep legacy top-level keys pointing at the active instance for minimal change elsewhere. active_store = instances.get(active_iid, {}) if isinstance(instances.get(active_iid), dict) else {} hass.data[DOMAIN]["schedules"] = active_store.get("schedules", {}) hass.data[DOMAIN]["weekday_schedules"] = active_store.get("weekdays", {}) or {} hass.data[DOMAIN]["profile_schedules"] = active_store.get("profiles", {}) or {} hass.data[DOMAIN]["settings"] = active_store.get("settings", {}) hass.data[DOMAIN]["colors"] = active_store.get("colors", {}) or {} hass.data[DOMAIN]["version"] = int(data.get("version", 1)) # Track a separate version for settings-only changes (for the new settings sensor) hass.data[DOMAIN]["settings_version"] = int(data.get("settings_version", data.get("version", 1))) # Track a separate version for colors-only changes hass.data[DOMAIN]["colors_version"] = int(data.get("colors_version", data.get("version", 1))) # Track a separate version for weekday-only changes hass.data[DOMAIN]["weekday_version"] = int(data.get("weekday_version", data.get("version", 1))) # Track a separate version for profiles-only changes hass.data[DOMAIN]["profile_version"] = int(data.get("profile_version", data.get("version", 1))) # Backup payloads hass.data[DOMAIN]["backup_schedules"] = backup_data.get("schedules", {}) hass.data[DOMAIN]["backup_settings"] = backup_data.get("settings", {}) hass.data[DOMAIN]["backup_weekdays"] = backup_data.get("weekdays", {}) or {} hass.data[DOMAIN]["backup_profiles"] = backup_data.get("profiles", {}) or {} hass.data[DOMAIN]["backup_version"] = int(backup_data.get("version", 1)) hass.data[DOMAIN]["backup_last_ts"] = backup_data.get("last_backup_ts") # Track whether the backup is partial and which sections it includes hass.data[DOMAIN]["backup_partial_flags"] = backup_data.get("partial_flags") slots = backup_data.get("slots", []) if not isinstance(slots, list): slots = [] # Normalize: only dict entries or None norm_slots = [] for entry in slots: if isinstance(entry, dict): norm_slots.append(entry) else: norm_slots.append(None) hass.data[DOMAIN]["backup_slots"] = norm_slots hass.data[DOMAIN]["backup_slot_index"] = len([s for s in norm_slots if s]) _dbg("async_setup_entry: setup complete, version=" + str(hass.data[DOMAIN]["version"])) async def _save_and_broadcast(): # Broadcast first so UI updates immediately async_dispatcher_send(hass, SIGNAL_UPDATED) try: _ensure_instances_loaded(hass) instances = hass.data[DOMAIN].get("instances", {}) active_iid = _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default") active_store = instances.get(active_iid, {}) if isinstance(instances, dict) else {} await store.async_save({ # New multi-instance store "instances": instances, "active_instance_id": active_iid, # Legacy top-level mirror (active instance) "schedules": active_store.get("schedules", hass.data[DOMAIN].get("schedules", {})), "weekdays": active_store.get("weekdays", hass.data[DOMAIN].get("weekday_schedules", {})), "profiles": active_store.get("profiles", hass.data[DOMAIN].get("profile_schedules", {})), "settings": active_store.get("settings", hass.data[DOMAIN].get("settings", {})), "colors": active_store.get("colors", hass.data[DOMAIN].get("colors", {})), "version": hass.data[DOMAIN]["version"], "settings_version": hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN]["version"]), "colors_version": hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN]["version"]), "weekday_version": hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN]["version"]), "profile_version": hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN]["version"]), }) except Exception: # Storage failure shouldn't block UI update _LOGGER.warning("%s: failed to save store after broadcast", DOMAIN) async def _save_backup(): # Broadcast so backup sensor updates async_dispatcher_send(hass, SIGNAL_UPDATED) try: await backup_store.async_save({ "schedules": hass.data[DOMAIN].get("backup_schedules", {}), "settings": hass.data[DOMAIN].get("backup_settings", {}), "weekdays": hass.data[DOMAIN].get("backup_weekdays", {}), "profiles": hass.data[DOMAIN].get("backup_profiles", {}), "version": hass.data[DOMAIN].get("backup_version", 1), "last_backup_ts": hass.data[DOMAIN].get("backup_last_ts"), "partial_flags": hass.data[DOMAIN].get("backup_partial_flags"), "slots": hass.data[DOMAIN].get("backup_slots", []), "slot_index": hass.data[DOMAIN].get("backup_slot_index", 0), }) except Exception: _LOGGER.warning("%s: failed to save backup store", DOMAIN) def _latest_backup_ts(slots: list) -> str | None: """Find newest backup timestamp across slots (iso string).""" newest = None try: for slot in (slots or []): if not isinstance(slot, dict): continue ts = slot.get("ts") or slot.get("created_at") or slot.get("created") if not ts: continue try: dt = dt_util.parse_datetime(ts) except Exception: dt = None if dt: if not newest or dt > newest[0]: newest = (dt, ts) else: # Fallback: keep first seen string when parse fails if not newest: newest = (None, ts) except Exception: return None return newest[1] if newest else None async def set_store(call: ServiceCall): force = bool(call.data.get("force")) _ensure_instances_loaded(hass) instances = hass.data[DOMAIN].setdefault("instances", {}) iid_in_call = "instance_id" in call.data iid = _norm_instance_id(call.data.get("instance_id")) if iid_in_call else _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default") inst = instances.get(iid) if not isinstance(inst, dict): inst = {"schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}} instances[iid] = inst cur_sched = inst.get("schedules", {}) if isinstance(inst.get("schedules"), dict) else {} cur_set = inst.get("settings", {}) if isinstance(inst.get("settings"), dict) else {} cur_colors = inst.get("colors", {}) if isinstance(inst.get("colors"), dict) else {} cur_week = inst.get("weekdays", {}) if isinstance(inst.get("weekdays"), dict) else {} cur_prof = inst.get("profiles", {}) if isinstance(inst.get("profiles"), dict) else {} changed = False sched_changed = False settings_changed = False colors_changed = False weekday_changed = False profile_changed = False prev_active = _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default") activate = False if "activate" in call.data: activate = bool(call.data.get("activate")) elif iid_in_call: # New clients that pass instance_id will default to activating that instance. activate = True if "schedules" in call.data: schedules = call.data.get("schedules") if not isinstance(schedules, dict): _LOGGER.warning("%s.set_store: schedules must be an object when provided", DOMAIN) return if force or schedules != cur_sched: inst["schedules"] = schedules sched_changed = True changed = True if "weekdays" not in call.data: try: new_week = {} for eid, row in (schedules.items() if isinstance(schedules, dict) else []): if not isinstance(row, dict): continue wk = {} if "weekly" in row: wk["weekly"] = row["weekly"] if "weekly_modes" in row: wk["weekly_modes"] = row["weekly_modes"] if wk: new_week[eid] = wk if force or new_week != cur_week: inst["weekdays"] = new_week weekday_changed = True changed = True except Exception: pass if "profiles" not in call.data: try: new_prof = {} for eid, row in (schedules.items() if isinstance(schedules, dict) else []): if not isinstance(row, dict): continue pr = {} if "profiles" in row: pr["profiles"] = row["profiles"] if "activeProfile" in row: pr["activeProfile"] = row["activeProfile"] if pr: new_prof[eid] = pr if force or new_prof != cur_prof: inst["profiles"] = new_prof profile_changed = True changed = True except Exception: pass if "settings" in call.data: settings = call.data.get("settings") if isinstance(settings, dict): merged_settings = dict(cur_set) merged_settings.update(settings) try: sw = merged_settings.get("boiler_switch") if isinstance(sw, str) and sw: dom = sw.split(".")[0] if dom in ("switch", "input_boolean"): merged_settings["boiler_switch_domain"] = dom except Exception: pass if force or merged_settings != cur_set: inst["settings"] = merged_settings settings_changed = True changed = True c_ranges = merged_settings.get("color_ranges") c_global = merged_settings.get("color_global") if c_ranges is not None or c_global is not None: inst["colors"] = { "color_ranges": c_ranges if c_ranges is not None else cur_colors.get("color_ranges", {}), "color_global": c_global if c_global is not None else cur_colors.get("color_global", False), } changed = True settings_changed = True colors_changed = True if "colors" in call.data: colors = call.data.get("colors") if isinstance(colors, dict): if force or colors != cur_colors: inst["colors"] = colors colors_changed = True changed = True if "weekdays" in call.data: weekdays = call.data.get("weekdays") if isinstance(weekdays, dict): if force or weekdays != cur_week: inst["weekdays"] = weekdays weekday_changed = True changed = True try: for eid, wk in (weekdays.items() if isinstance(weekdays, dict) else []): if not isinstance(wk, dict): continue row = dict(inst.get("schedules", {}).get(eid, {})) if "weekly" in wk: row["weekly"] = wk["weekly"] if "weekly_modes" in wk: row["weekly_modes"] = wk["weekly_modes"] inst.setdefault("schedules", {})[eid] = row sched_changed = True except Exception: pass if "profiles" in call.data: profiles = call.data.get("profiles") if isinstance(profiles, dict): if force or profiles != cur_prof: inst["profiles"] = profiles profile_changed = True changed = True try: for eid, pdata in (profiles.items() if isinstance(profiles, dict) else []): if not isinstance(pdata, dict): continue row = dict(inst.get("schedules", {}).get(eid, {})) if "profiles" in pdata: row["profiles"] = pdata["profiles"] if "activeProfile" in pdata: row["activeProfile"] = pdata["activeProfile"] inst.setdefault("schedules", {})[eid] = row sched_changed = True except Exception: pass # Allow activation-only switches (no payload changes). if not changed and not force and not (activate and prev_active != iid): return # Handle activation and keep legacy pointers synced. if activate: hass.data[DOMAIN]["active_instance_id"] = iid if activate or _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default") == iid: hass.data[DOMAIN]["schedules"] = inst.get("schedules", {}) hass.data[DOMAIN]["weekday_schedules"] = inst.get("weekdays", {}) hass.data[DOMAIN]["profile_schedules"] = inst.get("profiles", {}) hass.data[DOMAIN]["settings"] = inst.get("settings", {}) hass.data[DOMAIN]["colors"] = inst.get("colors", {}) if sched_changed: hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1 if settings_changed: hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN].get("version", 1))) + 1 if not sched_changed: hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1 if colors_changed: hass.data[DOMAIN]["colors_version"] = int(hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN].get("version", 1))) + 1 if not sched_changed: hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1 if weekday_changed: hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN].get("version", 1))) + 1 if not sched_changed: hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1 if profile_changed: hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN].get("version", 1))) + 1 if not sched_changed: hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1 if force and not sched_changed and not settings_changed: hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1 hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN]["version"])) + 1 hass.data[DOMAIN]["colors_version"] = int(hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN]["version"])) + 1 hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN]["version"])) + 1 hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN]["version"])) + 1 # Switching active instance should also trigger a refresh. if activate and prev_active != iid: hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1 _LOGGER.info("set_store: version=%s, sched_changed=%s, settings_changed=%s", hass.data[DOMAIN]["version"], sched_changed, settings_changed) await _save_and_broadcast() async def select_instance(call: ServiceCall): """Select the active instance id. Optional: create the instance if missing, and optionally copy from current active. """ _ensure_instances_loaded(hass) instances = hass.data[DOMAIN].setdefault("instances", {}) target = _norm_instance_id(call.data.get("instance_id")) create_if_missing = bool(call.data.get("create_if_missing")) copy_from_active = bool(call.data.get("copy_from_active")) prev_active = _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default") if target not in instances: if not create_if_missing: _LOGGER.warning("%s.select_instance: instance_id '%s' does not exist", DOMAIN, target) return base = {} if copy_from_active and prev_active in instances and isinstance(instances.get(prev_active), dict): base = instances.get(prev_active) or {} instances[target] = { "schedules": (base.get("schedules") or {}) if isinstance(base, dict) else {}, "weekdays": (base.get("weekdays") or {}) if isinstance(base, dict) else {}, "profiles": (base.get("profiles") or {}) if isinstance(base, dict) else {}, "settings": (base.get("settings") or {}) if isinstance(base, dict) else {}, "colors": (base.get("colors") or {}) if isinstance(base, dict) else {}, } hass.data[DOMAIN]["active_instance_id"] = target inst = instances.get(target, {}) if isinstance(instances.get(target), dict) else {} hass.data[DOMAIN]["schedules"] = inst.get("schedules", {}) hass.data[DOMAIN]["weekday_schedules"] = inst.get("weekdays", {}) hass.data[DOMAIN]["profile_schedules"] = inst.get("profiles", {}) hass.data[DOMAIN]["settings"] = inst.get("settings", {}) hass.data[DOMAIN]["colors"] = inst.get("colors", {}) if prev_active != target: hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 0)) + 1 await _save_and_broadcast() async def rename_instance(call: ServiceCall): """Rename an instance id (move data).""" _ensure_instances_loaded(hass) instances = hass.data[DOMAIN].setdefault("instances", {}) old_id = _norm_instance_id(call.data.get("old_instance_id") or call.data.get("old_id")) new_id = _norm_instance_id(call.data.get("new_instance_id") or call.data.get("new_id")) if old_id == new_id: return if old_id not in instances: _LOGGER.warning("%s.rename_instance: old instance '%s' not found", DOMAIN, old_id) return if new_id in instances: _LOGGER.warning("%s.rename_instance: new instance '%s' already exists", DOMAIN, new_id) return instances[new_id] = instances.pop(old_id) if _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default") == old_id: hass.data[DOMAIN]["active_instance_id"] = new_id inst = instances.get(new_id, {}) if isinstance(instances.get(new_id), dict) else {} hass.data[DOMAIN]["schedules"] = inst.get("schedules", {}) hass.data[DOMAIN]["weekday_schedules"] = inst.get("weekdays", {}) hass.data[DOMAIN]["profile_schedules"] = inst.get("profiles", {}) hass.data[DOMAIN]["settings"] = inst.get("settings", {}) hass.data[DOMAIN]["colors"] = inst.get("colors", {}) hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 0)) + 1 await _save_and_broadcast() async def clear_store(call: ServiceCall): # Explicit clear: wipe ALL instances + active and bump versions. hass.data[DOMAIN]["instances"] = {"default": {"schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}}} hass.data[DOMAIN]["active_instance_id"] = "default" hass.data[DOMAIN]["schedules"] = {} hass.data[DOMAIN]["weekday_schedules"] = {} hass.data[DOMAIN]["profile_schedules"] = {} hass.data[DOMAIN]["settings"] = {} hass.data[DOMAIN]["colors"] = {} hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 0)) + 1 hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN]["version"])) + 1 hass.data[DOMAIN]["colors_version"] = int(hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN]["version"])) + 1 hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN]["version"])) + 1 hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN]["version"])) + 1 await _save_and_broadcast() async def factory_reset(call: ServiceCall): """Factory reset thermostat_timeline. Deletes the underlying .storage JSON files and recreates them empty. This is useful when the store has become corrupted or needs a hard reset. """ # 1) Remove persisted files (best-effort) try: await store.async_remove() except Exception: pass try: await backup_store.async_remove() except Exception: pass # 2) Reset runtime state (equivalent to clear_store + clearing backups) hass.data[DOMAIN]["instances"] = {"default": {"schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}}} hass.data[DOMAIN]["active_instance_id"] = "default" hass.data[DOMAIN]["schedules"] = {} hass.data[DOMAIN]["weekday_schedules"] = {} hass.data[DOMAIN]["profile_schedules"] = {} hass.data[DOMAIN]["settings"] = {} hass.data[DOMAIN]["colors"] = {} hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 0)) + 1 hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN]["version"])) + 1 hass.data[DOMAIN]["colors_version"] = int(hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN]["version"])) + 1 hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN]["version"])) + 1 hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN]["version"])) + 1 # Clear backups too hass.data[DOMAIN]["backup_schedules"] = {} hass.data[DOMAIN]["backup_settings"] = {} hass.data[DOMAIN]["backup_weekdays"] = {} hass.data[DOMAIN]["backup_profiles"] = {} hass.data[DOMAIN]["backup_partial_flags"] = None hass.data[DOMAIN]["backup_slot_index"] = 0 hass.data[DOMAIN]["backup_last_ts"] = None hass.data[DOMAIN]["backup_slots"] = [] hass.data[DOMAIN]["backup_version"] = int(hass.data[DOMAIN].get("backup_version", 1)) + 1 # 3) Recreate empty files and broadcast await _save_and_broadcast() await _save_backup() async def backup_now(call: ServiceCall): """Create a backup of the current store. Supports selective sections via optional booleans: - main: base daily schedules (defaultTemp, blocks) - weekday: weekday schedules (weekly, weekly_modes) - presence: presence schedules (advanced away combos per room) - settings: editor/global settings (except color ranges) - holiday: holiday schedules per room - colors: color ranges and color mode - profiles: profile schedules (profiles, activeProfile) If no flags are provided, a full backup is made (backwards compatible). """ want_main = bool(call.data.get("main", True)) want_week = bool(call.data.get("weekday", True)) want_presence = bool(call.data.get("presence", True)) want_settings = bool(call.data.get("settings", True)) want_holiday = bool(call.data.get("holiday", True)) want_colors = bool(call.data.get("colors", True)) want_profiles = bool(call.data.get("profiles", True)) src_sched = hass.data[DOMAIN].get("schedules", {}) or {} src_week = hass.data[DOMAIN].get("weekday_schedules", {}) or {} src_prof = hass.data[DOMAIN].get("profile_schedules", {}) or {} src_set = hass.data[DOMAIN].get("settings", {}) or {} out_sched: dict = {} if any([want_main, want_week, want_presence, want_holiday, want_profiles]): for eid, row in (src_sched.items() if isinstance(src_sched, dict) else []): if not isinstance(row, dict): continue new_row = {} if want_main: if "defaultTemp" in row: new_row["defaultTemp"] = row["defaultTemp"] if "blocks" in row: new_row["blocks"] = row["blocks"] if want_profiles: prof_src = src_prof.get(eid, {}) if isinstance(src_prof, dict) else {} if "profiles" in prof_src: new_row["profiles"] = prof_src["profiles"] elif "profiles" in row: new_row["profiles"] = row["profiles"] if "activeProfile" in prof_src: new_row["activeProfile"] = prof_src["activeProfile"] elif "activeProfile" in row: new_row["activeProfile"] = row["activeProfile"] if want_week: try: wk_src = src_week.get(eid, {}) if isinstance(src_week, dict) else {} except Exception: wk_src = {} if "weekly" in wk_src: new_row["weekly"] = wk_src["weekly"] elif "weekly" in row: new_row["weekly"] = row["weekly"] if "weekly_modes" in wk_src: new_row["weekly_modes"] = wk_src["weekly_modes"] elif "weekly_modes" in row: new_row["weekly_modes"] = row["weekly_modes"] if want_presence and "presence" in row: new_row["presence"] = row["presence"] if want_holiday and "holiday" in row: new_row["holiday"] = row["holiday"] if new_row: out_sched[eid] = new_row out_set: dict = {} if want_settings: for k, v in (src_set.items() if isinstance(src_set, dict) else []): if k in ("color_ranges", "color_global"): continue out_set[k] = v if want_colors: if "color_ranges" in src_set: out_set["color_ranges"] = src_set["color_ranges"] if "color_global" in src_set: out_set["color_global"] = src_set["color_global"] legacy_full = ( "main" not in call.data and "weekday" not in call.data and "presence" not in call.data and "settings" not in call.data and "holiday" not in call.data and "colors" not in call.data and "profiles" not in call.data ) if legacy_full: hass.data[DOMAIN]["backup_schedules"] = dict(src_sched) hass.data[DOMAIN]["backup_settings"] = dict(src_set) hass.data[DOMAIN]["backup_profiles"] = dict(src_prof) hass.data[DOMAIN]["backup_weekdays"] = dict(src_week) hass.data[DOMAIN]["backup_partial_flags"] = None else: hass.data[DOMAIN]["backup_schedules"] = out_sched hass.data[DOMAIN]["backup_settings"] = out_set hass.data[DOMAIN]["backup_profiles"] = {k: v for k, v in (src_prof.items() if isinstance(src_prof, dict) else [])} if want_profiles else {} hass.data[DOMAIN]["backup_weekdays"] = {k: v for k, v in (src_week.items() if isinstance(src_week, dict) else [])} hass.data[DOMAIN]["backup_partial_flags"] = { "main": want_main, "weekday": want_week, "presence": want_presence, "settings": want_settings, "holiday": want_holiday, "colors": want_colors, "profiles": want_profiles, } # Maintain append-only backup list (unbounded) try: slots = hass.data[DOMAIN].get("backup_slots", []) if not isinstance(slots, list): slots = [] # Colors come from live colors store (matches colors sensor) backup_colors = hass.data[DOMAIN].get("colors", {}) or {} entry = { "ts": dt_util.utcnow().isoformat(), "version": hass.data[DOMAIN].get("version", 1), "settings_version": hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN].get("version", 1)), "weekday_version": hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN].get("version", 1)), "profile_version": hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN].get("version", 1)), "colors_version": hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN].get("version", 1)), "partial_flags": hass.data[DOMAIN].get("backup_partial_flags"), "sections": { "schedules": hass.data[DOMAIN].get("backup_schedules", {}), "settings": hass.data[DOMAIN].get("backup_settings", {}), "weekdays": hass.data[DOMAIN].get("backup_weekdays", {}), "profiles": hass.data[DOMAIN].get("backup_profiles", {}), "colors": backup_colors, }, } # Append new entry (skip None placeholders) slots = [s for s in slots if s is None or isinstance(s, dict)] slots.append(entry) hass.data[DOMAIN]["backup_slot_index"] = len(slots) hass.data[DOMAIN]["backup_slots"] = slots except Exception: pass hass.data[DOMAIN]["backup_version"] = int(hass.data[DOMAIN].get("backup_version", 1)) + 1 try: hass.data[DOMAIN]["backup_last_ts"] = dt_util.utcnow().isoformat() except Exception: pass await _save_backup() async def delete_backup(call: ServiceCall): """Delete a specific backup slot or clear all backups when no slot is provided.""" slots = hass.data[DOMAIN].get("backup_slots", []) if not isinstance(slots, list): slots = [] slot_num = call.data.get("slot") changed = False if slot_num is not None: try: idx = int(slot_num) - 1 except Exception: idx = -1 if 0 <= idx < len(slots): try: slots.pop(idx) except Exception: pass else: changed = True else: # Clear all backup payloads and slots hass.data[DOMAIN]["backup_schedules"] = {} hass.data[DOMAIN]["backup_settings"] = {} hass.data[DOMAIN]["backup_weekdays"] = {} hass.data[DOMAIN]["backup_profiles"] = {} hass.data[DOMAIN]["backup_partial_flags"] = None hass.data[DOMAIN]["backup_slot_index"] = 0 hass.data[DOMAIN]["backup_last_ts"] = None slots = [] changed = True if not changed: return hass.data[DOMAIN]["backup_slots"] = slots hass.data[DOMAIN]["backup_version"] = int(hass.data[DOMAIN].get("backup_version", 1)) + 1 hass.data[DOMAIN]["backup_slot_index"] = len(slots) hass.data[DOMAIN]["backup_last_ts"] = _latest_backup_ts(slots) await _save_backup() async def import_backup(call: ServiceCall): """Import backup slots from a JSON payload and append as new slots. Accepted inputs: - slots: [ {slot objects} ] - data: { schedules, settings, weekdays, profiles, slots?, partial_flags? } If 'slots' is provided inside data, those are used. Otherwise a single slot is synthesized from schedules/settings/weekdays/profiles. """ try: payload = call.data or {} slots_in = [] if isinstance(payload.get("slots"), list): slots_in = payload.get("slots") elif isinstance(payload.get("data"), dict): d = payload.get("data") if isinstance(d.get("slots"), list): slots_in = d.get("slots") else: # Build a single slot from provided sections sections = { "schedules": d.get("schedules", {}) or {}, "settings": d.get("settings", {}) or {}, "weekdays": d.get("weekdays", {}) or {}, "profiles": d.get("profiles", {}) or {}, "colors": (d.get("colors") or {}), } slot = { "ts": dt_util.utcnow().isoformat(), "version": hass.data[DOMAIN].get("version", 1), "settings_version": hass.data[DOMAIN].get("settings_version", 1), "weekday_version": hass.data[DOMAIN].get("weekday_version", 1), "profile_version": hass.data[DOMAIN].get("profile_version", 1), "colors_version": hass.data[DOMAIN].get("colors_version", 1), "partial_flags": d.get("partial_flags") or None, "sections": sections, "imported": True, } slots_in = [slot] else: # Try synthesizing from top-level sections (backward compat) sections = { "schedules": payload.get("schedules", {}) or {}, "settings": payload.get("settings", {}) or {}, "weekdays": payload.get("weekdays", {}) or {}, "profiles": payload.get("profiles", {}) or {}, "colors": (payload.get("colors") or {}), } if any(len(sections.get(k, {})) for k in ("schedules","settings","weekdays","profiles")): slots_in = [{ "ts": dt_util.utcnow().isoformat(), "version": hass.data[DOMAIN].get("version", 1), "settings_version": hass.data[DOMAIN].get("settings_version", 1), "weekday_version": hass.data[DOMAIN].get("weekday_version", 1), "profile_version": hass.data[DOMAIN].get("profile_version", 1), "colors_version": hass.data[DOMAIN].get("colors_version", 1), "partial_flags": payload.get("partial_flags") or None, "sections": sections, }] if not isinstance(slots_in, list) or not slots_in: return # Normalize and append slots = hass.data[DOMAIN].get("backup_slots", []) if not isinstance(slots, list): slots = [] for s in slots_in: if not isinstance(s, dict): continue sec = s.get("sections") if isinstance(s.get("sections"), dict) else None if sec is None: # Skip invalid slot continue # Ensure timestamp strings if not isinstance(s.get("ts"), str) or not s.get("ts"): s["ts"] = dt_util.utcnow().isoformat() # Mark as imported (for UI labeling) try: if "imported" not in s: s["imported"] = True except Exception: pass slots.append(s) hass.data[DOMAIN]["backup_slots"] = [x for x in slots if (x is None or isinstance(x, dict))] hass.data[DOMAIN]["backup_slot_index"] = len(hass.data[DOMAIN]["backup_slots"]) hass.data[DOMAIN]["backup_version"] = int(hass.data[DOMAIN].get("backup_version", 1)) + 1 hass.data[DOMAIN]["backup_last_ts"] = _latest_backup_ts(hass.data[DOMAIN]["backup_slots"]) or dt_util.utcnow().isoformat() await _save_backup() # Also broadcast so UI refreshes async_dispatcher_send(hass, SIGNAL_UPDATED) except Exception: _LOGGER.warning("%s: import_backup failed", DOMAIN, exc_info=True) async def restore_now(call: ServiceCall): """Restore from backup. Optional mode: 'replace' (default) or 'merge'. Optional section flags like backup_now: main, weekday, presence, settings, holiday, colors, profiles. Optional slot (1-based) to restore from rolling backup slots. If backup contains partial_flags and no mode is provided, merge is used by default. """ # Always operate on the active instance as well as the legacy pointers. _ensure_instances_loaded(hass) instances = hass.data[DOMAIN].setdefault("instances", {}) active_iid = _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default") inst = instances.get(active_iid) if not isinstance(inst, dict): inst = {"schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}} instances[active_iid] = inst slot_num = call.data.get("slot") slot_entry = None if slot_num is not None: try: idx = int(slot_num) - 1 slots = hass.data[DOMAIN].get("backup_slots", []) if isinstance(hass.data[DOMAIN].get("backup_slots", []), list) else [] if 0 <= idx < len(slots): slot_entry = slots[idx] except Exception: slot_entry = None mode = str(call.data.get("mode", "")).lower().strip() flags = hass.data[DOMAIN].get("backup_partial_flags") or {} # Allow caller to override flags for key in ("main","weekday","presence","settings","holiday","colors","profiles"): if key in call.data: flags[key] = bool(call.data.get(key)) sections = None if slot_entry and isinstance(slot_entry, dict): sections = slot_entry.get("sections", {}) if isinstance(slot_entry.get("sections"), dict) else {} flags = slot_entry.get("partial_flags") or flags backup_sched = (sections.get("schedules") if sections else None) or hass.data[DOMAIN].get("backup_schedules", {}) or {} backup_set = (sections.get("settings") if sections else None) or hass.data[DOMAIN].get("backup_settings", {}) or {} backup_week = (sections.get("weekdays") if sections else None) or hass.data[DOMAIN].get("backup_weekdays", {}) or {} backup_prof = (sections.get("profiles") if sections else None) or hass.data[DOMAIN].get("backup_profiles", {}) or {} # If no flags and mode empty, keep legacy behavior (replace) if not flags and mode not in ("merge", "replace"): # Legacy: full replace hass.data[DOMAIN]["schedules"] = dict(backup_sched) hass.data[DOMAIN]["settings"] = dict(backup_set) hass.data[DOMAIN]["weekday_schedules"] = dict(backup_week) hass.data[DOMAIN]["profile_schedules"] = dict(backup_prof) # Mirror into active instance so persistence uses the restored data inst["schedules"] = dict(backup_sched) inst["weekdays"] = dict(backup_week) inst["profiles"] = dict(backup_prof) inst["settings"] = dict(backup_set) # Keep colors in sync with settings if present try: inst["colors"] = { "color_ranges": (backup_set.get("color_ranges") if isinstance(backup_set, dict) else {}) or {}, "color_global": bool((backup_set.get("color_global") if isinstance(backup_set, dict) else False)), } except Exception: pass hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 1)) + 1 hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", 1)) + 1 hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", 1)) + 1 hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", 1)) + 1 await _save_and_broadcast() return do_merge = (mode == "merge") or (not mode and bool(flags)) if not do_merge: # Explicit replace hass.data[DOMAIN]["schedules"] = dict(backup_sched) hass.data[DOMAIN]["settings"] = dict(backup_set) hass.data[DOMAIN]["weekday_schedules"] = dict(backup_week) hass.data[DOMAIN]["profile_schedules"] = dict(backup_prof) # Mirror into active instance so persistence uses the restored data inst["schedules"] = dict(backup_sched) inst["weekdays"] = dict(backup_week) inst["profiles"] = dict(backup_prof) inst["settings"] = dict(backup_set) # Sync colors from settings if present try: inst["colors"] = { "color_ranges": (backup_set.get("color_ranges") if isinstance(backup_set, dict) else {}) or {}, "color_global": bool((backup_set.get("color_global") if isinstance(backup_set, dict) else False)), } except Exception: pass hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 1)) + 1 hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", 1)) + 1 hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", 1)) + 1 hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", 1)) + 1 await _save_and_broadcast() return # Merge: only update selected sections/keys cur_sched = hass.data[DOMAIN].get("schedules", {}) or {} cur_set = hass.data[DOMAIN].get("settings", {}) or {} cur_week = hass.data[DOMAIN].get("weekday_schedules", {}) or {} cur_prof = hass.data[DOMAIN].get("profile_schedules", {}) or {} want_main = bool(flags.get("main", False)) want_week = bool(flags.get("weekday", False)) want_presence = bool(flags.get("presence", False)) want_settings = bool(flags.get("settings", False)) want_holiday = bool(flags.get("holiday", False)) want_colors = bool(flags.get("colors", False)) want_profiles = bool(flags.get("profiles", False)) if any([want_main, want_week, want_presence, want_holiday, want_profiles]): for eid, brow in (backup_sched.items() if isinstance(backup_sched, dict) else []): if not isinstance(brow, dict): continue row = dict(cur_sched.get(eid, {})) if want_main: if "defaultTemp" in brow: row["defaultTemp"] = brow["defaultTemp"] if "blocks" in brow: row["blocks"] = brow["blocks"] if want_profiles: prof_src = backup_prof.get(eid, {}) if isinstance(backup_prof, dict) else {} if "profiles" in prof_src: row["profiles"] = prof_src["profiles"] elif "profiles" in brow: row["profiles"] = brow["profiles"] if "activeProfile" in prof_src: row["activeProfile"] = prof_src["activeProfile"] elif "activeProfile" in brow: row["activeProfile"] = brow["activeProfile"] if want_week: wk_src = backup_week.get(eid, {}) if isinstance(backup_week, dict) else {} if "weekly" in wk_src: row["weekly"] = wk_src["weekly"] elif "weekly" in brow: row["weekly"] = brow["weekly"] if "weekly_modes" in wk_src: row["weekly_modes"] = wk_src["weekly_modes"] elif "weekly_modes" in brow: row["weekly_modes"] = brow["weekly_modes"] if want_presence and "presence" in brow: row["presence"] = brow["presence"] if want_holiday and "holiday" in brow: row["holiday"] = brow["holiday"] cur_sched[eid] = row if want_settings or want_colors: # Merge settings keys for k, v in (backup_set.items() if isinstance(backup_set, dict) else []): if (k in ("color_ranges", "color_global")) and not want_colors: continue if (k not in ("color_ranges", "color_global")) and not want_settings: continue cur_set[k] = v if want_week: hass.data[DOMAIN]["weekday_schedules"] = {k: v for k, v in (backup_week.items() if isinstance(backup_week, dict) else [])} else: hass.data[DOMAIN]["weekday_schedules"] = cur_week if want_profiles: hass.data[DOMAIN]["profile_schedules"] = {k: v for k, v in (backup_prof.items() if isinstance(backup_prof, dict) else [])} else: hass.data[DOMAIN]["profile_schedules"] = cur_prof hass.data[DOMAIN]["schedules"] = cur_sched hass.data[DOMAIN]["settings"] = cur_set # Mirror merge results into active instance for persistence inst["schedules"] = dict(cur_sched) inst["settings"] = dict(cur_set) inst["weekdays"] = dict(hass.data[DOMAIN]["weekday_schedules"]) inst["profiles"] = dict(hass.data[DOMAIN]["profile_schedules"]) try: if want_colors: inst["colors"] = { "color_ranges": (backup_set.get("color_ranges") if isinstance(backup_set, dict) else {}) or inst.get("colors", {}).get("color_ranges", {}), "color_global": bool((backup_set.get("color_global") if isinstance(backup_set, dict) else inst.get("colors", {}).get("color_global", False))), } except Exception: pass hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 1)) + 1 hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", 1)) + 1 if want_week: hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", 1)) + 1 if want_profiles: hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", 1)) + 1 await _save_and_broadcast() async def patch_entity(call: ServiceCall): eid = str(call.data.get("entity_id","")).strip() d = call.data.get("data", {}) if not eid or not isinstance(d, dict): _LOGGER.warning("%s.patch_entity: invalid args", DOMAIN) return cur = dict(hass.data[DOMAIN]["schedules"].get(eid, {})) if "defaultTemp" in d: cur["defaultTemp"] = d["defaultTemp"] if "blocks" in d and isinstance(d["blocks"], list): cur["blocks"] = d["blocks"] hass.data[DOMAIN]["schedules"][eid] = cur hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1 await _save_and_broadcast() async def clear(call: ServiceCall): hass.data[DOMAIN]["schedules"] = {} hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1 await _save_and_broadcast() hass.services.async_register(DOMAIN, "set_store", set_store) hass.services.async_register(DOMAIN, "clear_store", clear_store) hass.services.async_register(DOMAIN, "factory_reset", factory_reset) hass.services.async_register(DOMAIN, "select_instance", select_instance) hass.services.async_register(DOMAIN, "rename_instance", rename_instance) hass.services.async_register(DOMAIN, "backup_now", backup_now) hass.services.async_register(DOMAIN, "delete_backup", delete_backup) hass.services.async_register(DOMAIN, "import_backup", import_backup) hass.services.async_register(DOMAIN, "restore_now", restore_now) hass.services.async_register(DOMAIN, "patch_entity", patch_entity) hass.services.async_register(DOMAIN, "clear", clear) # no apply_now service (removed) # Expose lightweight HTTP views for clients (works over HA Cloud) try: hass.http.register_view(ThermostatTimelineStateView(hass)) hass.http.register_view(ThermostatTimelineVersionView(hass)) except Exception: _LOGGER.warning("%s: failed to register HTTP views", DOMAIN) # ---- Background Auto-Apply Manager (multi-instance) ---- mgr = MultiInstanceAutoApplyManager(hass) hass.data[DOMAIN]["manager"] = mgr await mgr.async_start() # ---- Open Window Detection (background, multi-instance) ---- owd = MultiInstanceOpenWindowManager(hass, apply_multi=mgr) # Keep legacy key for compatibility (points to the multi wrapper). hass.data[DOMAIN]["open_window_manager"] = owd await owd.async_start() # ---- Presence Sensor Manager (background, multi-instance) ---- psm = MultiInstancePresenceSensorManager(hass, apply_multi=mgr) hass.data[DOMAIN]["presence_sensor_manager"] = psm await psm.async_start() # ---- Backup Manager (auto backup) ---- bkm = BackupManager(hass, backup_cb=backup_now) hass.data[DOMAIN]["backup_manager"] = bkm await bkm.async_start() return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True class AutoApplyManager: def __init__(self, hass: HomeAssistant, instance_id: str = "default"): self.hass = hass self._instance_id = _norm_instance_id(instance_id) self._unsub_timer = None self._unsub_persons = None self._unsub_boiler = None self._unsub_boiler_interval = None self._unsub_pause_sensor = None self._last_pause_sensor_on: bool | None = None self._last_applied = {} # eid -> {"min": int, "temp": float} self._next_is_resume = False async def async_start(self): # Apply once on startup and schedule next if enabled await self._maybe_apply_now(force=True) await self._maybe_control_boiler(force=True) await self._schedule_next() # Re-apply on store updates @callback def _on_store_update(): self.hass.async_create_task(self._on_store_changed()) async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_store_update) # Watch person.* states if away mode is used self._reset_person_watch() self._reset_boiler_watch() self._reset_boiler_interval() self._reset_pause_sensor_watch() async def _on_store_changed(self): # Respect apply_on_edit toggle: only apply immediately on store changes when enabled _s, settings = self._get_data() if bool(settings.get("apply_on_edit", True)): await self._maybe_apply_now(force=True) await self._maybe_control_boiler(force=True) await self._schedule_next() self._reset_person_watch() self._reset_boiler_watch() self._reset_boiler_interval() self._reset_pause_sensor_watch() def _pause_sensor_on(self, settings: dict) -> bool: """Return True when pause is requested via binary sensor.""" try: if not bool(settings.get("pause_sensor_enabled")): return False eid = str(settings.get("pause_sensor_entity") or "").strip() if not eid or not eid.startswith("binary_sensor."): return False st = self.hass.states.get(eid) s = str(st.state if st else "").lower().strip() return s == "on" except Exception: return False def _get_data(self): d = self.hass.data.get(DOMAIN, {}) try: instances = d.get("instances") if isinstance(d.get("instances"), dict) else None if isinstance(instances, dict): inst = instances.get(self._instance_id) if isinstance(inst, dict): schedules = inst.get("schedules", {}) if isinstance(inst.get("schedules"), dict) else {} settings = inst.get("settings", {}) if isinstance(inst.get("settings"), dict) else {} return schedules, settings except Exception: pass # Fallback to legacy active-instance pointers schedules = d.get("schedules", {}) settings = d.get("settings", {}) return schedules, settings def _owd_mgr(self): """Return the Open Window manager for this instance (if any).""" try: d = self.hass.data.get(DOMAIN, {}) m = d.get("open_window_managers") if isinstance(m, dict): owd = m.get(self._instance_id) if owd is not None: return owd return d.get("open_window_manager") except Exception: return None def _auto_apply_enabled(self) -> bool: _s, settings = self._get_data() if not bool(settings.get("auto_apply_enabled")): return False # Pause via binary sensor (indefinite while sensor is on) try: if self._pause_sensor_on(settings): return False except Exception: pass # Pause gates try: if bool(settings.get("pause_indef")): return False pu = settings.get("pause_until_ms") if isinstance(pu, (int, float)): # compare in ms now_ms = dt_util.utcnow().timestamp() * 1000.0 if float(pu) > now_ms: return False except Exception: pass return True def _paused_until_dt(self): _s, settings = self._get_data() try: if bool(settings.get("pause_indef")): return None pu = settings.get("pause_until_ms") if isinstance(pu, (int, float)) and float(pu) > 0: # Ignore expired timestamps to avoid scheduling timers in the past, # which can create an immediate-fire feedback loop. now_ms = dt_util.utcnow().timestamp() * 1000.0 if float(pu) <= (now_ms + 250.0): return None return dt_util.utc_from_timestamp(float(pu) / 1000.0) except Exception: pass return None def _now_min(self) -> int: now = dt_util.now() return now.hour * 60 + now.minute def _today_key(self) -> str: # Monday=0 ... Sunday=6 -> map to keys idx = (dt_util.now().weekday()) # 0..6 return ["mon","tue","wed","thu","fri","sat","sun"][idx] def _today_iso(self) -> str: """Return today's date in HA local timezone as YYYY-MM-DD.""" try: return dt_util.now().date().isoformat() except Exception: return "" def _season_key(self, settings: dict) -> str: try: return "summer" if str(settings.get("seasonal_mode") or "winter").lower().strip() == "summer" else "winter" except Exception: return "winter" def _seasonal_row(self, row: dict, settings: dict) -> dict: """Return row view using seasonal data when enabled.""" try: if not bool(settings.get("seasonal_enabled")): return row seasons = row.get("seasons") if isinstance(row, dict) else None if not isinstance(seasons, dict): return row key = self._season_key(settings) srow = seasons.get(key) if not isinstance(srow, dict): return row out = dict(row) # Blocks: if missing in season, treat as empty (do not inherit). if isinstance(srow.get("blocks"), list): out["blocks"] = srow.get("blocks") else: out["blocks"] = [] # Weekly/weekly_modes/presence: if missing, remove to avoid inheriting base data. if "weekly" in srow: out["weekly"] = srow.get("weekly") else: out.pop("weekly", None) if "weekly_modes" in srow: out["weekly_modes"] = srow.get("weekly_modes") else: out.pop("weekly_modes", None) if "presence" in srow: out["presence"] = srow.get("presence") else: out.pop("presence", None) return out except Exception: return row def _is_holiday_active(self, settings: dict) -> bool: """Determine whether Holiday mode is active. Uses settings written by the frontend card: - holidays_enabled: bool - holidays_source: 'calendar' | 'manual' - holidays_entity: entity id (calendar.* or binary_sensor.*) - holidays_dates: ['YYYY-MM-DD', ...] """ try: if not bool(settings.get("holidays_enabled")): return False src = str(settings.get("holidays_source") or "calendar").lower().strip() if src == "manual": dates = settings.get("holidays_dates") if not isinstance(dates, list) or not dates: return False today = self._today_iso() return any(str(d) == today for d in dates) # calendar/binary_sensor entity eid = str(settings.get("holidays_entity") or "").strip() if not eid: return False st = self.hass.states.get(eid) s = str(st.state if st else "").lower() return s == "on" except Exception: return False def _effective_blocks_today(self, row: dict, settings: dict): # Apply seasonal selection first (affects blocks/weekly/presence buckets). try: row = self._seasonal_row(row, settings) except Exception: pass # Holidays override have highest precedence when enabled and active. # Note: only apply holiday override when this room actually has holiday blocks. try: if self._is_holiday_active(settings): hol = row.get("holiday") or {} blk = hol.get("blocks") if isinstance(hol, dict) else None if isinstance(blk, list) and len(blk) > 0: return blk except Exception: pass # Presence (advanced away) overrides if enabled and an active combo exists try: away = settings.get("away") or {} if bool(away.get("advanced_enabled")): key = self._active_presence_combo_key(settings) if key: pres = row.get("presence") or {} blk = (pres.get(key) or {}).get("blocks") if isinstance(blk, list) and len(blk) > 0: return blk # Presence combo is active but this room has no presence schedule: # return a virtual all‑day block at Away temperature try: val = (away.get("target_c") if isinstance(away, dict) else None) target = float(val) if isinstance(val, (int, float, str)) else None if target is not None: return [{"id": "__presence_away__", "startMin": 0, "endMin": 1440, "temp": target}] except Exception: pass except Exception: pass # Profiles override take precedence when enabled (per-room activeProfile) try: if settings.get("profiles_enabled"): ap = row.get("activeProfile") if ap: profs = row.get("profiles") or {} blk = (profs.get(ap) or {}).get("blocks") # Treat empty profile blocks as "no override". # If a manual profile is active globally but a room has no # blocks defined for that profile, the room must follow its # normal schedule instead of falling back to defaultTemp. if isinstance(blk, list) and len(blk) > 0: return blk except Exception: pass # If row has weekly structure, use today's day list, otherwise default blocks try: wk = row.get("weekly") if wk and isinstance(wk, dict): days = wk.get("days") or {} arr = days.get(self._today_key()) if isinstance(arr, list): return arr except Exception: pass return row.get("blocks") or [] def _presence_persons(self, settings: dict) -> list[str]: try: away = settings.get("away") or {} persons = away.get("persons") return list(persons) if isinstance(persons, list) else [] except Exception: return [] def _presence_combo_key(self, home: list[str], away: list[str]) -> str: try: h = sorted([str(x) for x in (home or [])]) a = sorted([str(x) for x in (away or [])]) return f"H:{','.join(h)}|A:{','.join(a)}" except Exception: return "H:|A:" def _active_presence_combo_key(self, settings: dict) -> str | None: try: away = settings.get("away") or {} if not bool(away.get("advanced_enabled")): return None persons = self._presence_persons(settings) if not persons: return None home, not_home = [], [] for p in persons: st = self.hass.states.get(p) if st and str(st.state).lower() == "home": home.append(p) else: not_home.append(p) key = self._presence_combo_key(home, not_home) combos = (away.get("combos") or {}) if isinstance(away, dict) else {} meta = combos.get(key) if meta and bool(meta.get("enabled")): return key return None except Exception: return None def _all_targets(self, schedules: dict, merges: dict) -> set[str]: out = set() for primary in schedules.keys(): out.add(primary) linked = merges.get(primary) or [] for e in linked: out.add(e) return out def _desired_for(self, eid: str, schedules: dict, settings: dict, now_min: int) -> Optional[float]: # Resolve primary (merged) merges = settings.get("merges") or {} primary = None if eid in schedules: primary = eid else: # find primary that lists eid for p, lst in (merges.items() if isinstance(merges, dict) else []): try: if eid in (lst or []): primary = p break except Exception: continue if primary is None: primary = eid row = schedules.get(primary) if not isinstance(row, dict): return None # Determine which blocks are effective for today blocks = self._effective_blocks_today(row, settings) # Track if advanced presence is active globally and whether this room has a presence schedule for the active combo presence_key_active = self._active_presence_combo_key(settings) room_has_presence_blocks = False try: if presence_key_active: pres = row.get("presence") or {} bl = (pres.get(presence_key_active) or {}).get("blocks") room_has_presence_blocks = isinstance(bl, list) and len(bl) > 0 except Exception: room_has_presence_blocks = False hit = None for b in blocks: try: if now_min >= int(b.get("startMin", -1)) and now_min < int(b.get("endMin", -1)): hit = b break except Exception: continue # Base desired setpoint if hit is not None: v = hit.get("temp") if isinstance(hit, dict) else None if isinstance(v, (int, float, str)): want = float(v) else: want = float(row.get("defaultTemp", 20)) else: # When advanced presence is active but this room has no presence schedule, # use Away temperature instead of the room default. if presence_key_active and not room_has_presence_blocks: try: away = settings.get("away") or {} val = away.get("target_c") if isinstance(val, (int, float, str)): want = float(val) else: want = float(row.get("defaultTemp", 20)) except Exception: want = float(row.get("defaultTemp", 20)) else: want = float(row.get("defaultTemp", 20)) # Away override (disabled when advanced presence is enabled) try: away = settings.get("away") or {} if bool(away.get("advanced_enabled")): pass # advanced presence replaces simple away clamp entirely elif away.get("enabled") and isinstance(away.get("persons"), list): anyone_home = False for p in away["persons"]: st = self.hass.states.get(p) if st and str(st.state).lower() == "home": anyone_home = True break if not anyone_home and ("target_c" in away): target_c = float(away.get("target_c", want)) want = min(want, target_c) except Exception: pass # Per-room presence sensor override (simple presence entity) try: # Only when feature is enabled in settings if bool(settings.get("presence_sensor_enabled")): # Map of primary climate/input_number to binary_sensor entity id pmap = settings.get("presence_sensors") or {} if isinstance(pmap, dict): s_eid = str(pmap.get(primary) or "").strip() if s_eid and s_eid.startswith("binary_sensor."): st = self.hass.states.get(s_eid) s = str(st.state if st else "").lower().strip() if s == "on": # Temperature override when presence is ON tmap = settings.get("presence_sensor_temps") or {} try: val = tmap.get(primary) if isinstance(val, (int, float, str)): want = float(val) except Exception: pass except Exception: pass # Clamp try: mn = settings.get("min_temp") mx = settings.get("max_temp") if mn is not None: want = max(want, float(mn)) if mx is not None: want = min(want, float(mx)) except Exception: pass return want async def _maybe_apply_now(self, force: bool = False, boundary_only: bool = False, reconcile: bool = False): if not self._auto_apply_enabled(): return schedules, settings = self._get_data() merges = settings.get("merges") or {} now_min = self._now_min() targets = self._all_targets(schedules, merges) for eid in targets: # Supported targets: climate.* and input_number.* if not isinstance(eid, str) or not (eid.startswith("climate.") or eid.startswith("input_number.")): continue if not self.hass.states.get(eid): continue # Open Window Detection: skip applying schedules while a room is suspended try: owd = self._owd_mgr() if owd and hasattr(owd, "is_entity_suspended") and owd.is_entity_suspended(eid): continue except Exception: pass # If we are at a boundary tick (timer), only apply for entities that have a boundary now if boundary_only: try: primary = None if eid in schedules: primary = eid else: for p, lst in (merges.items() if isinstance(merges, dict) else []): try: if eid in (lst or []): primary = p break except Exception: continue if primary is None: primary = eid row = schedules.get(primary) or {} if not self._entity_has_boundary_now(row, now_min): continue except Exception: # if in doubt, skip to avoid overriding manual changes unnecessarily continue desired = self._desired_for(eid, schedules, settings, now_min) if desired is None: continue # Manual override guard: if the thermostat target differs from the # scheduled value, do not force it back until the next block boundary. # Guard is only active from 5 min after block start until 5 min # before block end, to keep a buffer around boundaries. # (Boundary ticks and resume reconciles are allowed.) if not boundary_only and not reconcile: try: # Check if guard window is active for this entity right now guard_active = False try: primary = None if eid in schedules: primary = eid else: for p, lst in (merges.items() if isinstance(merges, dict) else []): try: if eid in (lst or []): primary = p break except Exception: continue if primary is None: primary = eid row = schedules.get(primary) or {} blocks = self._effective_blocks_today(row, settings) for b in blocks: try: s = int(b.get("startMin", -1)) e = int(b.get("endMin", -1)) if s < 0 or e < 0: continue if s <= now_min < e: if (e - s) >= 10: guard_active = (now_min >= (s + 5)) and (now_min <= (e - 5)) else: guard_active = False break except Exception: continue except Exception: guard_active = False if not guard_active: pass st = self.hass.states.get(eid) cur = None if st is not None: if eid.startswith("input_number."): try: cur = float(st.state) except Exception: cur = None else: for attr in ("temperature", "target_temperature", "target_temp"): v = st.attributes.get(attr) if isinstance(v, (int, float)): cur = float(v) break if cur is not None: try: u = self.hass.config.units.temperature_unit ha_is_f = u == UnitOfTemperature.FAHRENHEIT or "F" in str(u).upper() except Exception: ha_is_f = False cur_c = _f_to_c(cur) if ha_is_f else float(cur) # Convert desired to °C (storage is canonical °C, but keep legacy fallback) desired_c = float(desired) su = str((settings or {}).get("storage_temp_unit") or "").upper().strip() if su == "F": desired_c = _f_to_c(desired_c) elif su != "C": legacy_u = str((settings or {}).get("temp_unit") or "").upper().strip() if legacy_u == "F" and desired_c > 45.0: desired_c = _f_to_c(desired_c) if guard_active and abs(cur_c - desired_c) > 0.05: continue except Exception: pass # Only apply when desired setpoint changes for this entity. # This prevents overriding manual thermostat changes just because # another room hits a new block (or any global refresh runs). # # Exceptions: # - On resume from pause we reconcile to the desired setpoint even # if it hasn't changed (reconcile=True). last = self._last_applied.get(eid) or {} try: lt = last.get("temp") last_temp = float(lt) if isinstance(lt, (int, float, str)) else None except Exception: last_temp = None if (not reconcile) and (last_temp is not None) and abs(last_temp - float(desired)) < 0.05: continue # If current equals desired, just update cache st = self.hass.states.get(eid) cur = None if eid.startswith("input_number."): try: cur = float(st.state) except Exception: cur = None else: for attr in ("temperature","target_temperature","target_temp"): v = st.attributes.get(attr) if isinstance(v, (int, float)): cur = float(v) break if cur is not None and abs(cur - float(desired)) < 0.05: self._last_applied[eid] = {"min": now_min, "temp": float(desired)} continue ok = await self._apply_setpoint(eid, float(desired)) if ok: self._last_applied[eid] = {"min": now_min, "temp": float(desired)} else: _LOGGER.warning("Auto-apply failed for %s", eid) async def async_apply_entity_now(self, eid: str, reconcile: bool = False) -> None: """Apply the current desired setpoint for a single entity (best-effort). Used by other managers (e.g. Open Window Detection) to reconcile a room back to schedule without forcing a global re-apply. """ try: if not isinstance(eid, str): return if not (eid.startswith("climate.") or eid.startswith("input_number.")): return if not self.hass.states.get(eid): return if not self._auto_apply_enabled(): return # Respect Open Window Detection suspension try: owd = self._owd_mgr() if owd and hasattr(owd, "is_entity_suspended") and owd.is_entity_suspended(eid): return except Exception: pass schedules, settings = self._get_data() now_min = self._now_min() desired = self._desired_for(eid, schedules, settings, now_min) if desired is None: return if not reconcile: last = self._last_applied.get(eid) or {} try: lt = last.get("temp") last_temp = float(lt) if isinstance(lt, (int, float, str)) else None except Exception: last_temp = None if (last_temp is not None) and abs(last_temp - float(desired)) < 0.05: return ok = await self._apply_setpoint(eid, float(desired)) if ok: self._last_applied[eid] = {"min": now_min, "temp": float(desired)} except Exception: return def _next_boundary_dt(self): schedules, settings = self._get_data() now = dt_util.now() now_min = self._now_min() best_delta = None # consider all block boundaries today for eid, row in schedules.items(): try: blocks = self._effective_blocks_today(row, settings) for b in blocks: for t in (int(b.get("startMin", -1)), int(b.get("endMin", -1))): if t < 0: continue delta = (t - now_min) % 1440 if delta == 0: delta = 1 if best_delta is None or delta < best_delta: best_delta = delta except Exception: continue # always include midnight rollover delta_mid = (1440 - now_min) % 1440 if delta_mid == 0: delta_mid = 1 if best_delta is None or delta_mid < best_delta: best_delta = delta_mid # build dt in UTC return dt_util.as_utc(now + timedelta(minutes=best_delta or 1)) async def _schedule_next(self): # cancel previous if self._unsub_timer: self._unsub_timer() self._unsub_timer = None if not self._auto_apply_enabled(): # If paused indefinitely, do not schedule anything _s, settings = self._get_data() if bool(settings.get("pause_indef")): return # If paused until a time, schedule wake-up then wake = self._paused_until_dt() if not wake: return boundary = None else: wake = self._paused_until_dt() boundary = self._next_boundary_dt() # choose earliest available if wake and boundary: self._next_is_resume = wake <= boundary when = wake if self._next_is_resume else boundary elif wake: self._next_is_resume = True when = wake else: self._next_is_resume = False when = boundary if not when: return # Never schedule in the past (HA will execute immediately), which can # otherwise turn stale timestamps into a hot loop. Timers are minute- # granularity here, so clamp to at least +1 minute. try: when = dt_util.as_utc(when) except Exception: pass try: now_utc = dt_util.utcnow() if when <= now_utc: when = now_utc + timedelta(minutes=1) except Exception: pass @callback def _cb(_now): self.hass.async_create_task(self._timer_fire()) self._unsub_timer = async_track_point_in_utc_time(self.hass, _cb, when) async def _timer_fire(self): # If this wake was caused by pause expiry, reconcile immediately across all entities. resume = bool(self._next_is_resume) await self._maybe_apply_now(force=True, boundary_only=(not resume), reconcile=resume) await self._maybe_control_boiler(force=True) self._next_is_resume = False await self._schedule_next() def _reset_boiler_watch(self): if self._unsub_boiler: self._unsub_boiler() self._unsub_boiler = None _schedules, settings = self._get_data() try: if not bool(settings.get("boiler_enabled")): return # Backwards compat: if the new schedule-driven keys are absent, # keep the legacy sensor-based behavior. use_schedule_mode = ( ("boiler_rooms" in settings) or ("boiler_on_offset" in settings) or ("boiler_off_offset" in settings) or ("boiler_multi_enabled" in settings) or ("boiler_room_settings" in settings) ) if use_schedule_mode: schedules, _settings = self._get_data() merges = (settings.get("merges") or {}) if isinstance(settings, dict) else {} rooms = None if bool(settings.get("boiler_multi_enabled")): try: room_cfgs = settings.get("boiler_room_settings") if isinstance(room_cfgs, dict): rooms = [] for eid, rc in room_cfgs.items(): if not (isinstance(eid, str) and eid.startswith("climate.")): continue if not (isinstance(rc, dict) and bool(rc.get("enabled"))): continue sw = str(rc.get("switch") or "").strip() if not sw: continue if "." not in sw: dom = str(rc.get("switch_domain") or "").strip().lower() if dom in ("switch", "input_boolean"): sw = f"{dom}.{sw}" if "." not in sw: continue rooms.append(eid) if not rooms: return except Exception: rooms = None if rooms is None: br = settings.get("boiler_rooms") if isinstance(br, list): rooms = [str(x).strip() for x in br if str(x).strip()] if len(rooms) == 0: return if rooms is None: rooms = sorted(list(self._all_targets(schedules if isinstance(schedules, dict) else {}, merges))) watch = [] for eid in rooms: if isinstance(eid, str) and eid.startswith("climate."): watch.append(eid) if not watch: return @callback def _ch(_event): self.hass.async_create_task(self._maybe_control_boiler(force=True)) self._unsub_boiler = async_track_state_change_event(self.hass, watch, _ch) return # Legacy mode: react when boiler sensor updates sens = settings.get("boiler_temp_sensor") if not isinstance(sens, str) or not sens: return @callback def _ch(_event): # React quickly when boiler sensor updates self.hass.async_create_task(self._maybe_control_boiler(force=True)) self._unsub_boiler = async_track_state_change_event(self.hass, [sens], _ch) except Exception: self._unsub_boiler = None def _reset_boiler_interval(self) -> None: if self._unsub_boiler_interval: try: self._unsub_boiler_interval() except Exception: pass self._unsub_boiler_interval = None _schedules, settings = self._get_data() try: if not bool(settings.get("boiler_enabled")): return @callback def _tick(_now): # Periodic sanity check (requested): independent of UI being open. self.hass.async_create_task(self._maybe_control_boiler(force=True)) self._unsub_boiler_interval = async_track_time_interval(self.hass, _tick, timedelta(minutes=10)) except Exception: self._unsub_boiler_interval = None def _reset_pause_sensor_watch(self) -> None: if self._unsub_pause_sensor: try: self._unsub_pause_sensor() except Exception: pass self._unsub_pause_sensor = None _schedules, settings = self._get_data() try: if not bool(settings.get("pause_sensor_enabled")): self._last_pause_sensor_on = None return eid = str(settings.get("pause_sensor_entity") or "").strip() if not eid or not eid.startswith("binary_sensor."): self._last_pause_sensor_on = None return # Prime last state self._last_pause_sensor_on = self._pause_sensor_on(settings) @callback def _ch(event): try: ns = event.data.get("new_state") s = str(ns.state if ns else "").lower().strip() now_on = s == "on" except Exception: return if self._last_pause_sensor_on is not None and now_on == self._last_pause_sensor_on: return self._last_pause_sensor_on = now_on async def _handle() -> None: # When pausing: just reschedule (cancels timers). # When resuming: reconcile once and schedule. if now_on: await self._schedule_next() return await self._maybe_apply_now(force=True, reconcile=True) await self._maybe_control_boiler(force=True) await self._schedule_next() self.hass.async_create_task(_handle()) self._unsub_pause_sensor = async_track_state_change_event(self.hass, [eid], _ch) except Exception: self._unsub_pause_sensor = None self._last_pause_sensor_on = None @staticmethod def _f_to_c(v: float) -> float: return (float(v) - 32.0) * 5.0 / 9.0 async def _maybe_control_boiler(self, force: bool = False) -> None: """Control boiler switch (switch.* or input_boolean.*). New (schedule-driven) mode: - ON if any included room current temp is below its desired schedule temp minus boiler_on_offset. - OFF when all included rooms are at/above desired schedule temp plus boiler_off_offset. Offsets are stored as °C deltas (independent of HA display unit). Backwards compat: - If schedule-driven keys are not present, fall back to legacy boiler sensor min/max logic. """ try: if not self._auto_apply_enabled(): return _schedules, settings = self._get_data() if not bool(settings.get("boiler_enabled")): return use_schedule_mode = ( ("boiler_rooms" in settings) or ("boiler_on_offset" in settings) or ("boiler_off_offset" in settings) or ("boiler_multi_enabled" in settings) or ("boiler_room_settings" in settings) ) if use_schedule_mode: schedules, settings = self._get_data() if not isinstance(schedules, dict): return # HA display unit for climate attributes def _ha_is_f() -> bool: try: u = self.hass.config.units.temperature_unit return u == UnitOfTemperature.FAHRENHEIT or "F" in str(u).upper() except Exception: return False ha_is_f = _ha_is_f() def _to_c_from_ha(val_ha: float) -> float: return _f_to_c(val_ha) if ha_is_f else float(val_ha) try: on_offset_c = float(settings.get("boiler_on_offset", 0.0) or 0.0) except Exception: on_offset_c = 0.0 try: off_offset_c = float(settings.get("boiler_off_offset", 0.0) or 0.0) except Exception: off_offset_c = 0.0 # Multi-boiler mode if bool(settings.get("boiler_multi_enabled")): room_cfgs = settings.get("boiler_room_settings") if not isinstance(room_cfgs, dict): return def _clamp_offset(v): try: n = float(v) return max(-10.0, min(10.0, n)) except Exception: return None groups = {} for eid, rc in room_cfgs.items(): try: if not (isinstance(eid, str) and eid.startswith("climate.")): continue if not (isinstance(rc, dict) and bool(rc.get("enabled"))): continue sw = str(rc.get("switch") or "").strip() if not sw: continue if "." not in sw: dom = str(rc.get("switch_domain") or "").strip().lower() if dom in ("switch", "input_boolean"): sw = f"{dom}.{sw}" if "." not in sw: continue sw_domain = sw.split(".", 1)[0] if sw_domain not in ("switch", "input_boolean"): continue on_off = _clamp_offset(rc.get("on_offset")) off_off = _clamp_offset(rc.get("off_offset")) entry = { "eid": eid, "on_off": on_off if on_off is not None else on_offset_c, "off_off": off_off if off_off is not None else off_offset_c, } groups.setdefault(sw, []).append(entry) except Exception: continue if not groups: return now_min = self._now_min() for sw, rooms in groups.items(): try: if not rooms: continue st_sw = self.hass.states.get(sw) cur = str(st_sw.state).lower().strip() if st_sw else "" any_below = False all_above = True any_seen = False any_unknown = False for r in rooms: try: desired_c = self._desired_for(r["eid"], schedules, settings, now_min) if desired_c is None: any_unknown = True continue st = self.hass.states.get(r["eid"]) if not st: any_unknown = True continue attrs = st.attributes or {} cur_ha = attrs.get("current_temperature") if cur_ha is None: cur_ha = attrs.get("temperature") if cur_ha is None: any_unknown = True continue cur_c = _to_c_from_ha(float(cur_ha)) any_seen = True if cur_c < (float(desired_c) - float(r.get("on_off", on_offset_c))): any_below = True all_above = False break if cur_c < (float(desired_c) + float(r.get("off_off", off_offset_c))): all_above = False except Exception: any_unknown = True continue if not any_seen: continue want = None if any_below: want = "on" else: if any_unknown: continue if all_above: want = "off" else: continue if want == "on" and cur == "on": continue if want == "off" and cur == "off": continue sw_domain = sw.split(".", 1)[0] if sw_domain not in ("switch", "input_boolean"): continue await self.hass.services.async_call( sw_domain, "turn_on" if want == "on" else "turn_off", {"entity_id": sw}, blocking=False, ) except Exception: continue return # Single-boiler mode sw = settings.get("boiler_switch") if not isinstance(sw, str) or "." not in sw: return sw_domain = sw.split(".", 1)[0] if sw_domain not in ("switch", "input_boolean"): return # Determine current boiler state up-front (used by single mode) st_sw = self.hass.states.get(sw) cur = str(st_sw.state).lower().strip() if st_sw else "" merges = settings.get("merges") or {} rooms = None br = settings.get("boiler_rooms") if isinstance(br, list): rooms = [str(x).strip() for x in br if str(x).strip()] if len(rooms) == 0: return if rooms is None: rooms = sorted(list(self._all_targets(schedules, merges if isinstance(merges, dict) else {}))) now_min = self._now_min() any_below = False all_above = True compared = 0 missing = 0 for eid in rooms: try: if not (isinstance(eid, str) and eid.startswith("climate.")): missing += 1 continue desired_c = self._desired_for(eid, schedules, settings, now_min) if desired_c is None: missing += 1 continue st = self.hass.states.get(eid) if not st: missing += 1 continue attrs = st.attributes or {} cur_ha = attrs.get("current_temperature") if cur_ha is None: # some devices only expose "temperature" cur_ha = attrs.get("temperature") if cur_ha is None: missing += 1 continue cur_c = _to_c_from_ha(float(cur_ha)) compared += 1 if cur_c < (float(desired_c) - on_offset_c): any_below = True all_above = False elif cur_c < (float(desired_c) + off_offset_c): all_above = False except Exception: missing += 1 continue if compared <= 0: return want = None if any_below: want = "on" else: # Conservative OFF: require complete data for all included rooms. if missing > 0: return if all_above: want = "off" else: return if want == "on" and cur == "on": return if want == "off" and cur == "off": return await self.hass.services.async_call( sw_domain, "turn_on" if want == "on" else "turn_off", {"entity_id": sw}, blocking=False, ) return # ---- Legacy mode (sensor min/max hysteresis) ---- sens = settings.get("boiler_temp_sensor") if not isinstance(sens, str) or "." not in sens: return st_sens = self.hass.states.get(sens) if not st_sens: return try: raw = float(st_sens.state) except Exception: return unit = str(settings.get("temp_unit") or "C").upper() bmin_raw = settings.get("boiler_min_temp") bmax_raw = settings.get("boiler_max_temp") if bmin_raw is None: min_c = 20.0 else: min_v = float(bmin_raw) min_c = self._f_to_c(min_v) if unit == "F" else min_v if bmax_raw is None: max_c = 25.0 else: max_v = float(bmax_raw) max_c = self._f_to_c(max_v) if unit == "F" else max_v lo = min(min_c, max_c) hi = max(min_c, max_c) val_c = raw try: u = st_sens.attributes.get("unit_of_measurement") if isinstance(u, str) and ("°F" in u or u.strip().upper() == "F"): val_c = self._f_to_c(raw) except Exception: pass want = None if val_c < lo: want = "on" elif val_c > hi: want = "off" else: return if want == "on" and cur == "on": return if want == "off" and cur == "off": return await self.hass.services.async_call( sw_domain, "turn_on" if want == "on" else "turn_off", {"entity_id": sw}, blocking=False, ) except Exception: # Boiler control must never interfere with normal schedule apply return def _reset_person_watch(self): # Recreate person watcher based on settings.away.persons (both simple away and advanced presence) if self._unsub_persons: self._unsub_persons() self._unsub_persons = None _schedules, settings = self._get_data() away = settings.get("away") or {} persons = away.get("persons") if isinstance(away, dict) else None if (away.get("enabled") or away.get("advanced_enabled")) and isinstance(persons, list) and persons: @callback def _ch(event): # Apply immediately when presence changes. # Use reconcile to bypass manual-override guard so away/presence # transitions take effect even mid-block. self.hass.async_create_task(self._maybe_apply_now(force=True, reconcile=True)) self.hass.async_create_task(self._schedule_next()) self._unsub_persons = async_track_state_change_event(self.hass, persons, _ch) def _entity_has_boundary_now(self, row: dict, now_min: int) -> bool: """Return True if the entity has a block boundary at (or just before) now. The timer intentionally schedules a few seconds/minutes past the exact boundary to avoid immediate execution collisions. That means the check here must tolerate a small offset, otherwise we miss the boundary and never apply. We therefore consider the current minute and the previous minute as being "at the boundary". """ try: _s, settings = self._get_data() blocks = self._effective_blocks_today(row, settings) # allow a 1‑minute grace window (handle wraparound at midnight) prev_min = (now_min - 1) % 1440 for b in blocks: try: s = int(b.get("startMin", -1)) e = int(b.get("endMin", -1)) if s in (now_min, prev_min) or e in (now_min, prev_min): return True except Exception: continue except Exception: pass return False async def _apply_setpoint(self, eid: str, desired: float) -> bool: """Apply desired setpoint robustly across HVAC modes. Strategy: - If device exposes range (target_temp_low/high) and mode is heat_cool/auto, set a band around desired. - If preset supports a manual/hold, switch to it first. - If current mode is off/dry/fan_only, try switching to heat, else cool. - Fallback to single temperature set. """ try: # Store contract: temperatures are canonical °C. # Backwards compat: older clients may have stored °F when temp_unit == 'F'. try: schedules, settings = self._get_data() except Exception: settings = {} schedules = {} # Optional: for some thermostats we need to send a climate.turn_on command as well. # This is configured per primary room entity (merged entities inherit the primary room setting). do_turn_on = False turn_on_order = "before" # 'before' | 'after' turn_on_delay_s = 1.0 if isinstance(eid, str) and eid.startswith("climate."): try: merges = settings.get("merges") or {} primary = None if isinstance(schedules, dict) and eid in schedules: primary = eid else: for p, lst in (merges.items() if isinstance(merges, dict) else []): try: if eid in (lst or []): primary = p break except Exception: continue if primary is None: primary = eid cfg_map = settings.get("turn_on") or {} cfg = cfg_map.get(primary) if isinstance(cfg_map, dict) else None if isinstance(cfg, dict): do_turn_on = bool(cfg.get("enabled")) o = str(cfg.get("order") or "before").lower().strip() turn_on_order = "after" if o == "after" else "before" # Allow optional global delay override (seconds) try: v = float(settings.get("turn_on_delay_s", turn_on_delay_s)) # Keep it short and safe turn_on_delay_s = max(0.0, min(5.0, v)) except Exception: turn_on_delay_s = 1.0 except Exception: do_turn_on = False async def _turn_on_and_delay() -> None: if not (do_turn_on and isinstance(eid, str) and eid.startswith("climate.")): return try: await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False) except Exception: return try: if turn_on_delay_s: await asyncio.sleep(turn_on_delay_s) except Exception: return def _ha_is_f() -> bool: try: u = self.hass.config.units.temperature_unit return u == UnitOfTemperature.FAHRENHEIT or "F" in str(u).upper() except Exception: return False ha_is_f = _ha_is_f() def _to_ha_from_c(val_c: float) -> float: return _c_to_f(val_c) if ha_is_f else float(val_c) def _to_c_from_ha(val_ha: float) -> float: return _f_to_c(val_ha) if ha_is_f else float(val_ha) def _store_to_c(val: float) -> float: v = float(val) su = str((settings or {}).get("storage_temp_unit") or "").upper().strip() if su == "F": return _f_to_c(v) if su == "C": return v # Legacy heuristic: only treat as °F when display unit is F *and* values look like °F. legacy_u = str((settings or {}).get("temp_unit") or "").upper().strip() if legacy_u == "F" and v > 45.0: return _f_to_c(v) return v desired_c = _store_to_c(desired) st = self.hass.states.get(eid) if not st: return False # input_number support: set value directly (no HVAC modes/presets) if isinstance(eid, str) and eid.startswith("input_number."): attrs = st.attributes or {} min_v = attrs.get("min") max_v = attrs.get("max") # input_number min/max are in HA's unit system val = float(_to_ha_from_c(desired_c)) try: if isinstance(min_v, (int, float)): val = max(val, float(min_v)) if isinstance(max_v, (int, float)): val = min(val, float(max_v)) except Exception: pass await self.hass.services.async_call( "input_number", "set_value", {"entity_id": eid, "value": float(val)}, blocking=False ) return True attrs = st.attributes or {} hvac_mode = str(attrs.get("hvac_mode", st.state)).lower() hvac_modes = [str(x).lower() for x in (attrs.get("hvac_modes") or [])] preset_mode = str(attrs.get("preset_mode", "") or "").lower() preset_modes = [str(x).lower() for x in (attrs.get("preset_modes") or [])] min_t = attrs.get("min_temp") max_t = attrs.get("max_temp") min_c = None max_c = None try: if isinstance(min_t, (int, float)): min_c = _to_c_from_ha(float(min_t)) if isinstance(max_t, (int, float)): max_c = _to_c_from_ha(float(max_t)) except Exception: min_c = None max_c = None def _clamp_c(val_c: float) -> float: try: if isinstance(min_c, (int, float)): val_c = max(val_c, float(min_c)) if isinstance(max_c, (int, float)): val_c = min(val_c, float(max_c)) except Exception: pass return val_c # If preset has a manual/hold and not active, switch to it try: want_preset = None for cand in ("manual", "hold"): if cand in preset_modes: want_preset = cand break if want_preset and preset_mode != want_preset: await self.hass.services.async_call( "climate", "set_preset_mode", {"entity_id": eid, "preset_mode": want_preset}, blocking=False ) except Exception: pass # If in a mode that ignores temperature, try switching to a workable one try: if hvac_mode in ("off", "dry", "fan_only"): new_mode = None if "heat" in hvac_modes: new_mode = "heat" elif "cool" in hvac_modes: new_mode = "cool" if new_mode: await self.hass.services.async_call( "climate", "set_hvac_mode", {"entity_id": eid, "hvac_mode": new_mode}, blocking=False ) hvac_mode = new_mode except Exception: pass # Decide whether to use range or single setpoint has_low = isinstance(attrs.get("target_temp_low"), (int, float)) or ("target_temp_low" in attrs) has_high = isinstance(attrs.get("target_temp_high"), (int, float)) or ("target_temp_high" in attrs) use_range = (hvac_mode in ("heat_cool", "auto")) and has_low and has_high if use_range: # Build a narrow band around desired band = 1.0 try: # Allow global override via settings.range_band_c _s, settings = self._get_data() band = float(settings.get("range_band_c", 1.0)) except Exception: pass half = max(0.2, band / 2.0) low_c = _clamp_c(desired_c - half) high_c = _clamp_c(desired_c + half) if high_c <= low_c: # Ensure valid ordering high_c = min(_clamp_c(low_c + 0.5), _clamp_c(desired_c + 0.5)) data = { "entity_id": eid, "target_temp_low": float(_to_ha_from_c(low_c)), "target_temp_high": float(_to_ha_from_c(high_c)), } if turn_on_order == "before": await _turn_on_and_delay() await self.hass.services.async_call("climate", "set_temperature", data, blocking=False) if turn_on_order == "after": # Delay should be between set_temp and turn_on try: if turn_on_delay_s: await asyncio.sleep(turn_on_delay_s) except Exception: pass try: if do_turn_on: await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False) except Exception: pass return True # Fallback: single temperature data = {"entity_id": eid, "temperature": float(_to_ha_from_c(_clamp_c(desired_c)))} if turn_on_order == "before": await _turn_on_and_delay() await self.hass.services.async_call("climate", "set_temperature", data, blocking=False) if turn_on_order == "after": try: if turn_on_delay_s: await asyncio.sleep(turn_on_delay_s) except Exception: pass try: if do_turn_on: await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False) except Exception: pass return True except Exception: return False class MultiInstanceAutoApplyManager: """Run auto-apply/boiler control for all known instance_id namespaces. This makes the card's "Configuration ID" feature work fully in the background: each instance gets its own timers/watchers and runs independently. """ def __init__(self, hass: HomeAssistant): self.hass = hass self._managers: dict[str, AutoApplyManager] = {} self._unsub = None def get(self, instance_id: str) -> AutoApplyManager | None: try: return self._managers.get(_norm_instance_id(instance_id)) except Exception: return None async def async_start(self) -> None: await self._sync() @callback def _on_update(): # New instances can appear via set_store; ensure they get background managers. self.hass.async_create_task(self._sync()) async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update) async def _sync(self) -> None: try: _ensure_instances_loaded(self.hass) d = self.hass.data.get(DOMAIN, {}) instances = d.get("instances") if isinstance(d.get("instances"), dict) else {} for iid in (instances.keys() if isinstance(instances, dict) else []): try: iidn = _norm_instance_id(iid) if iidn in self._managers: continue mgr = AutoApplyManager(self.hass, instance_id=iidn) self._managers[iidn] = mgr await mgr.async_start() except Exception: continue # Expose for other components d["instance_managers"] = self._managers except Exception: return class MultiInstanceOpenWindowManager: """Run Open Window Detection independently per instance.""" def __init__(self, hass: HomeAssistant, apply_multi: MultiInstanceAutoApplyManager): self.hass = hass self._apply_multi = apply_multi self._managers: dict[str, OpenWindowManager] = {} self._unsub = None def get(self, instance_id: str) -> 'OpenWindowManager' | None: try: return self._managers.get(_norm_instance_id(instance_id)) except Exception: return None async def async_start(self) -> None: await self._sync() @callback def _on_update(): self.hass.async_create_task(self._sync()) async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update) async def _sync(self) -> None: try: _ensure_instances_loaded(self.hass) d = self.hass.data.get(DOMAIN, {}) instances = d.get("instances") if isinstance(d.get("instances"), dict) else {} for iid in (instances.keys() if isinstance(instances, dict) else []): try: iidn = _norm_instance_id(iid) if iidn in self._managers: continue mgr = self._apply_multi.get(iidn) if not mgr: continue owd = OpenWindowManager(self.hass, apply_mgr=mgr, instance_id=iidn) self._managers[iidn] = owd await owd.async_start() except Exception: continue d["open_window_managers"] = self._managers except Exception: return class OpenWindowManager: """Background Open Window Detection. Uses settings.open_window from the shared store. """ def __init__(self, hass: HomeAssistant, apply_mgr: AutoApplyManager, instance_id: str = "default"): self.hass = hass self._mgr = apply_mgr self._instance_id = _norm_instance_id(instance_id) self._unsub = None self._room_sensors: dict[str, list[str]] = {} self._open_delay_s: int = 0 self._close_delay_s: int = 0 self._room_tasks: dict[tuple[str, str], asyncio.Task] = {} # (room, kind) self._suspended_entities: set[str] = set() def is_entity_suspended(self, eid: str) -> bool: try: return isinstance(eid, str) and eid in self._suspended_entities except Exception: return False def _settings(self) -> dict: try: d = self.hass.data.get(DOMAIN, {}) or {} insts = d.get("instances") if isinstance(d.get("instances"), dict) else None if isinstance(insts, dict): inst = insts.get(self._instance_id) if isinstance(inst, dict): s = inst.get("settings") if isinstance(s, dict): return s except Exception: pass return (self.hass.data.get(DOMAIN, {}) or {}).get("settings", {}) or {} def _enabled(self, settings: dict | None = None) -> bool: try: settings = settings if isinstance(settings, dict) else self._settings() ow = settings.get("open_window") or {} if not (isinstance(ow, dict) and bool(ow.get("enabled"))): return False # Only run when background control is active if not bool(settings.get("auto_apply_enabled")): return False # Respect pause via binary sensor try: if bool(settings.get("pause_sensor_enabled")): eid = str(settings.get("pause_sensor_entity") or "").strip() if eid and eid.startswith("binary_sensor."): st = self.hass.states.get(eid) if str(st.state if st else "").lower().strip() == "on": return False except Exception: pass # Respect pause gates if bool(settings.get("pause_indef")): return False pu = settings.get("pause_until_ms") if isinstance(pu, (int, float)): now_ms = dt_util.utcnow().timestamp() * 1000.0 if float(pu) > now_ms: return False return True except Exception: return False @staticmethod def _clamp_int(v, default: int, lo: int, hi: int) -> int: try: n = int(float(v)) except Exception: n = int(default) return max(lo, min(hi, n)) @staticmethod def _is_open_state(state: str) -> bool: s = str(state or "").strip().lower() return s in ("on", "open", "true", "1") def _sensor_open(self, eid: str) -> bool: try: st = self.hass.states.get(eid) return self._is_open_state(st.state if st else "") except Exception: return False def _entities_for_room(self, room_eid: str, settings: dict) -> list[str]: out = [room_eid] try: merges = settings.get("merges") or {} linked = merges.get(room_eid) or [] if isinstance(linked, list): out.extend([x for x in linked if isinstance(x, str) and x]) except Exception: pass seen = set() uniq = [] for e in out: if e in seen: continue seen.add(e) uniq.append(e) return uniq def _cancel_task(self, room: str, kind: str) -> None: t = self._room_tasks.pop((room, kind), None) if t: try: t.cancel() except Exception: pass async def async_start(self) -> None: @callback def _on_update(): self.hass.async_create_task(self._rebuild()) async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update) await self._rebuild() async def _rebuild(self) -> None: # Unsubscribe old watcher try: if self._unsub: self._unsub() self._unsub = None except Exception: self._unsub = None # Cancel pending tasks for k, t in list(self._room_tasks.items()): try: t.cancel() except Exception: pass self._room_tasks.clear() settings = self._settings() ow = settings.get("open_window") or {} self._open_delay_s = self._clamp_int(ow.get("open_delay_min"), 2, 0, 1440) * 60 self._close_delay_s = self._clamp_int(ow.get("close_delay_min"), 5, 0, 1440) * 60 sensors_map = ow.get("sensors") if isinstance(ow, dict) else None room_sensors: dict[str, list[str]] = {} flat: list[str] = [] if isinstance(sensors_map, dict): for room, arr in sensors_map.items(): if not isinstance(room, str) or not room: continue if not isinstance(arr, list) or not arr: continue clean = [x for x in arr if isinstance(x, str) and x.startswith("binary_sensor.")] if not clean: continue room_sensors[room] = clean flat.extend(clean) self._room_sensors = room_sensors # If disabled, resume anything we previously turned off if not self._enabled(settings): await self._resume_all(settings) return flat = sorted(set(flat)) if flat: @callback def _ch(_event): self.hass.async_create_task(self._evaluate_all()) self._unsub = async_track_state_change_event(self.hass, flat, _ch) await self._evaluate_all() async def _evaluate_all(self) -> None: try: settings = self._settings() if not self._enabled(settings): await self._resume_all(settings) return for room in list(self._room_sensors.keys()): await self._evaluate_room(room, settings) except Exception: return async def _evaluate_room(self, room: str, settings: dict) -> None: sensors = self._room_sensors.get(room) or [] if not sensors: await self._resume_room(room, settings) return open_now = any(self._sensor_open(s) for s in sensors) is_susp = any(e in self._suspended_entities for e in self._entities_for_room(room, settings)) if open_now: self._cancel_task(room, "resume") if not is_susp and (room, "suspend") not in self._room_tasks: self._room_tasks[(room, "suspend")] = self.hass.async_create_task(self._delayed_suspend(room)) else: self._cancel_task(room, "suspend") if is_susp and (room, "resume") not in self._room_tasks: self._room_tasks[(room, "resume")] = self.hass.async_create_task(self._delayed_resume(room)) async def _delayed_suspend(self, room: str) -> None: try: if self._open_delay_s: await asyncio.sleep(self._open_delay_s) settings = self._settings() if not self._enabled(settings): return sensors = self._room_sensors.get(room) or [] if sensors and any(self._sensor_open(s) for s in sensors): await self._suspend_room(room, settings) except asyncio.CancelledError: return except Exception: return async def _delayed_resume(self, room: str) -> None: try: if self._close_delay_s: await asyncio.sleep(self._close_delay_s) settings = self._settings() if not self._enabled(settings): return sensors = self._room_sensors.get(room) or [] if sensors and any(self._sensor_open(s) for s in sensors): return await self._resume_room(room, settings) except asyncio.CancelledError: return except Exception: return async def _suspend_room(self, room: str, settings: dict) -> None: try: entities = self._entities_for_room(room, settings) for eid in entities: self._suspended_entities.add(eid) for eid in entities: if isinstance(eid, str) and eid.startswith("climate."): await self.hass.services.async_call("climate", "turn_off", {"entity_id": eid}, blocking=False) except Exception: return async def _resume_room(self, room: str, settings: dict) -> None: try: entities = self._entities_for_room(room, settings) for eid in entities: try: self._suspended_entities.discard(eid) except Exception: pass for eid in entities: if isinstance(eid, str) and eid.startswith("climate."): await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False) try: await self._mgr.async_apply_entity_now(eid, reconcile=True) except Exception: pass except Exception: return async def _resume_all(self, settings: dict) -> None: try: if not self._suspended_entities: return to_resume = list(self._suspended_entities) self._suspended_entities.clear() for eid in to_resume: if isinstance(eid, str) and eid.startswith("climate."): await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False) try: await self._mgr.async_apply_entity_now(eid, reconcile=True) except Exception: pass except Exception: return class MultiInstancePresenceSensorManager: """Runs PresenceSensorManager per instance for per-room presence entity delays. This mirrors the card's frontend presence feature in the background so overrides apply even when the card is closed. """ def __init__(self, hass: HomeAssistant, apply_multi: MultiInstanceAutoApplyManager): self.hass = hass self._apply_multi = apply_multi self._managers: dict[str, PresenceSensorManager] = {} def get(self, instance_id: str) -> 'PresenceSensorManager' | None: try: return self._managers.get(_norm_instance_id(instance_id)) except Exception: return None async def async_start(self) -> None: await self._sync() @callback def _on_update(): self.hass.async_create_task(self._sync()) async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update) async def _sync(self) -> None: try: _ensure_instances_loaded(self.hass) d = self.hass.data.get(DOMAIN, {}) instances = d.get("instances") if isinstance(d.get("instances"), dict) else {} for iid in (instances.keys() if isinstance(instances, dict) else []): try: iidn = _norm_instance_id(iid) if iidn in self._managers: continue mgr = self._apply_multi.get(iidn) if not mgr: continue psm = PresenceSensorManager(self.hass, apply_mgr=mgr, instance_id=iidn) self._managers[iidn] = psm await psm.async_start() except Exception: continue d["presence_sensor_managers"] = self._managers except Exception: return class PresenceSensorManager: """Background handler for simple per-room presence sensors with ON/OFF delays.""" def __init__(self, hass: HomeAssistant, apply_mgr: AutoApplyManager, instance_id: str = "default"): self.hass = hass self._mgr = apply_mgr self._instance_id = _norm_instance_id(instance_id) self._unsub = None self._room_sensor: dict[str, str] = {} self._delays_s: dict[str, tuple[int, int]] = {} # room -> (on_s, off_s) self._tasks: dict[tuple[str, str], asyncio.Task] = {} # (room, kind) def _settings(self) -> dict: try: d = self.hass.data.get(DOMAIN, {}) or {} insts = d.get("instances") if isinstance(d.get("instances"), dict) else None if isinstance(insts, dict): inst = insts.get(self._instance_id) if isinstance(inst, dict): s = inst.get("settings") if isinstance(s, dict): return s except Exception: pass return (self.hass.data.get(DOMAIN, {}) or {}).get("settings", {}) or {} def _enabled(self, settings: dict | None = None) -> bool: try: settings = settings if isinstance(settings, dict) else self._settings() if not bool(settings.get("presence_sensor_enabled")): return False if not bool(settings.get("auto_apply_enabled")): return False # Respect pause via sensor and pause gates try: if bool(settings.get("pause_sensor_enabled")): eid = str(settings.get("pause_sensor_entity") or "").strip() if eid and eid.startswith("binary_sensor."): st = self.hass.states.get(eid) if str(st.state if st else "").lower().strip() == "on": return False except Exception: pass if bool(settings.get("pause_indef")): return False pu = settings.get("pause_until_ms") if isinstance(pu, (int, float)): now_ms = dt_util.utcnow().timestamp() * 1000.0 if float(pu) > now_ms: return False return True except Exception: return False async def async_start(self) -> None: @callback def _on_update(): self.hass.async_create_task(self._rebuild()) async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update) await self._rebuild() def _cancel_task(self, room: str, kind: str) -> None: t = self._tasks.pop((room, kind), None) if t: try: t.cancel() except Exception: pass async def _rebuild(self) -> None: # Unsubscribe previous watchers and cancel tasks try: if self._unsub: self._unsub() self._unsub = None except Exception: self._unsub = None for k, t in list(self._tasks.items()): try: t.cancel() except Exception: pass self._tasks.clear() settings = self._settings() if not self._enabled(settings): return # Build maps rooms_list = settings.get("entities") or [] pmap = settings.get("presence_sensors") or {} dmap = settings.get("presence_sensor_delays") or {} room_sensor: dict[str, str] = {} delays_s: dict[str, tuple[int, int]] = {} flat: list[str] = [] if isinstance(pmap, dict): for room in (rooms_list if isinstance(rooms_list, list) else []): try: if not isinstance(room, str) or not room: continue s_eid = str(pmap.get(room) or "").strip() if not s_eid or not s_eid.startswith("binary_sensor."): continue room_sensor[room] = s_eid flat.append(s_eid) # delays stored in seconds by the editor cfg = dmap.get(room) if isinstance(dmap, dict) else None on_s = int(float(cfg.get("on_s", 180))) if isinstance(cfg, dict) else 180 off_s = int(float(cfg.get("off_s", 180))) if isinstance(cfg, dict) else 180 on_s = max(0, on_s) off_s = max(0, off_s) delays_s[room] = (on_s, off_s) except Exception: continue self._room_sensor = room_sensor self._delays_s = delays_s if not flat: return @callback def _ch(event): try: eid = str(event.data.get("entity_id") or "") # Find the room for this sensor for room, se in list(self._room_sensor.items()): if se == eid: ns = event.data.get("new_state") s = str(ns.state if ns else "").lower().strip() self.hass.async_create_task(self._schedule_room(room, s)) break except Exception: return self._unsub = async_track_state_change_event(self.hass, sorted(set(flat)), _ch) # Initial evaluation: if sensors already stable past delay, apply now try: for room, se in self._room_sensor.items(): st = self.hass.states.get(se) if not st: continue s = str(st.state or "").lower().strip() try: changed = st.last_changed if hasattr(changed, "timestamp"): age_s = max(0.0, (dt_util.utcnow() - changed).total_seconds()) else: age_s = 0.0 except Exception: age_s = 0.0 on_s, off_s = self._delays_s.get(room, (180, 180)) need = on_s if s == "on" else off_s if age_s >= need: # Apply immediately based on current state self.hass.async_create_task(self._mgr._maybe_apply_now(force=True)) except Exception: pass async def _schedule_room(self, room: str, state: str) -> None: try: on_s, off_s = self._delays_s.get(room, (180, 180)) if state == "on": self._cancel_task(room, "off") if (room, "on") in self._tasks: return async def _fire_on(): try: if on_s: await asyncio.sleep(on_s) # still on? se = self._room_sensor.get(room) st = self.hass.states.get(se) if se else None if str(st.state if st else "").lower().strip() != "on": return await self._mgr._maybe_apply_now(force=True) await self._mgr._schedule_next() except asyncio.CancelledError: return except Exception: return finally: self._tasks.pop((room, "on"), None) self._tasks[(room, "on")] = self.hass.async_create_task(_fire_on()) elif state == "off": self._cancel_task(room, "on") if (room, "off") in self._tasks: return async def _fire_off(): try: if off_s: await asyncio.sleep(off_s) se = self._room_sensor.get(room) st = self.hass.states.get(se) if se else None if str(st.state if st else "").lower().strip() != "off": return await self._mgr._maybe_apply_now(force=True) await self._mgr._schedule_next() except asyncio.CancelledError: return except Exception: return finally: self._tasks.pop((room, "off"), None) self._tasks[(room, "off")] = self.hass.async_create_task(_fire_off()) except Exception: return class BackupManager: def __init__(self, hass: HomeAssistant, backup_cb): self.hass = hass self._unsub_timer = None self._backup_cb = backup_cb def _settings(self): d = self.hass.data.get(DOMAIN, {}) return d.get("settings", {}) def _enabled(self) -> bool: s = self._settings() return bool(s.get("backup_auto_enabled", False)) def _interval_min(self) -> int: s = self._settings() # Prefer days; fallback to minutes for backward compat try: if "backup_interval_days" in s: d = max(1, min(365, int(s.get("backup_interval_days", 1)))) return d * 1440 except Exception: pass try: v = int(s.get("backup_interval_min", 1440)) return max(1440, min(365*1440, v)) except Exception: return 1440 async def async_start(self): await self._schedule_next() # Re-schedule on any store update @callback def _on_update(): self.hass.async_create_task(self._schedule_next()) async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update) async def _schedule_next(self): if self._unsub_timer: self._unsub_timer() self._unsub_timer = None if not self._enabled(): return mins = self._interval_min() when = dt_util.utcnow() + timedelta(minutes=mins) @callback def _cb(_now): self.hass.async_create_task(self._do_backup()) self._unsub_timer = async_track_point_in_utc_time(self.hass, _cb, when) async def _do_backup(self): try: await self._backup_cb(ServiceCall(DOMAIN, "backup_now", {})) except Exception: _LOGGER.warning("%s: auto backup failed", DOMAIN) await self._schedule_next()