3462 lines
151 KiB
Python
3462 lines
151 KiB
Python
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()
|