New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,431 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .store import SlideshowStore
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[str] = ["camera", "sensor", "button", "number", "select", "text", "switch"]
CARD_STATIC_PATH = "/album_slideshow_static"
CARD_FILE = "album-slideshow-card.js"
async def _async_register_card(hass: HomeAssistant) -> None:
"""Serve the Lovelace card JS and register it as a frontend module.
Idempotent: only the first config entry to load triggers registration
for the HA session. The card lets dashboards cross-fade between slides
in the browser (GPU compositor) instead of forcing the camera entity
to render a JPEG burst on the event loop.
"""
if hass.data.get(DOMAIN, {}).get("card_registered"):
return
integration_dir = os.path.dirname(__file__)
www_dir = os.path.join(integration_dir, "www")
card_path = os.path.join(www_dir, CARD_FILE)
if not os.path.isfile(card_path):
# Some HACS upgrade paths (and broken zip extractors) drop the
# ``www/`` subdirectory. Try to recover by checking whether the
# integration root has the file under a literal-backslash name
# (a symptom of zips written with Windows path separators) or
# directly at the root, and salvage it into ``www/`` so the
# rest of the registration can proceed.
recovered = await hass.async_add_executor_job(
_recover_card_from_root, integration_dir, www_dir, card_path
)
if not recovered:
_LOGGER.warning(
"Album Slideshow card missing on disk (%s). Re-install"
" the integration via HACS (3-dot menu -> Redownload)"
" or copy %s/%s into the album_slideshow folder."
" The custom:album-slideshow-card type will not be"
" available until this is fixed.",
card_path,
"www",
CARD_FILE,
)
return
try:
from homeassistant.components.http import StaticPathConfig
await hass.http.async_register_static_paths(
[StaticPathConfig(CARD_STATIC_PATH, www_dir, False)]
)
except Exception: # noqa: BLE001 - many possible failure modes here
_LOGGER.exception(
"Failed to register static path for Album Slideshow card"
)
return
# Cache-bust the card URL with the integration version so dashboards
# always pick up the script that matches the running integration
# rather than a stale copy from a previous release.
version = await hass.async_add_executor_job(
_read_manifest_version, integration_dir
)
card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
if version:
card_url = f"{card_url}?v={version}"
# Prefer registering the card as a Lovelace resource for storage-mode
# dashboards. Resources are loaded as part of the Lovelace bootstrap,
# before any dashboard renders custom cards, which removes the race
# where the dashboard can hit "Custom element doesn't exist" if the
# browser hasn't finished loading the module yet (a real risk after
# HA restarts and integration upgrades that bust the cache).
#
# The storage-mode resources collection is only consumed by
# storage-mode dashboards; YAML-mode dashboards (whether the user
# has ``lovelace.mode: yaml`` globally or per-dashboard
# ``mode: yaml`` entries) read only the resources declared in their
# own YAML and would otherwise never load the card. That's why we
# *always* also call ``add_extra_js_url``: the frontend injects a
# ``<script>`` tag on every Lovelace render regardless of mode, so
# it's the universal fallback. Module loads are deduplicated by URL
# and the card's ``customElements.define()`` calls have an
# idempotency guard, so a storage-mode dashboard picking up both
# paths is a harmless no-op.
resource_registered = await _try_register_lovelace_resource(hass, card_url)
if resource_registered:
hass.data.setdefault(DOMAIN, {})[
"lovelace_resource_registered"
] = True
try:
from homeassistant.components.frontend import add_extra_js_url
add_extra_js_url(hass, card_url)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to add Album Slideshow card via add_extra_js_url"
" (URL %s); the card may still load if a Lovelace resource"
" was registered above.",
card_url,
)
hass.data.setdefault(DOMAIN, {})["card_registered"] = True
if resource_registered:
_LOGGER.info(
"Album Slideshow card registered (Lovelace resource +"
" add_extra_js_url) at %s",
card_url,
)
else:
_LOGGER.info(
"Album Slideshow card registered via add_extra_js_url at"
" %s; Lovelace resources collection was not available so"
" no resource entry was created.",
card_url,
)
async def _try_register_lovelace_resource(
hass: HomeAssistant, card_url: str
) -> bool:
"""Register the card as a Lovelace resource for storage-mode dashboards.
Returns ``True`` if the resource was added (or already present at the
requested version); ``False`` if Lovelace isn't loaded, the dashboard
is in YAML mode, or anything else went wrong - in which case the
caller is expected to fall back to ``add_extra_js_url``.
The resource collection API is internal to HA and has changed shape
between versions, so we feel our way through it with ``getattr`` and
swallow any unexpected exception. The downside of getting this wrong
is one extra script tag in the dashboard, not a crash.
"""
try:
lovelace_data = hass.data.get("lovelace")
if lovelace_data is None:
_LOGGER.debug(
"Lovelace data not yet present; cannot register resource"
)
return False
# In recent HA the lovelace key is a LovelaceData object exposing
# ``resources``; in older versions it was a dict with the same key.
if isinstance(lovelace_data, dict):
resources = lovelace_data.get("resources")
else:
resources = getattr(lovelace_data, "resources", None)
if resources is None or not hasattr(resources, "async_create_item"):
# YAML-mode dashboards expose a ResourceYAMLCollection that is
# read-only; users edit ``configuration.yaml`` themselves.
_LOGGER.debug(
"Lovelace resources unavailable or read-only;"
" falling back to add_extra_js_url"
)
return False
# Make sure the storage collection has loaded its file. Some HA
# versions lazy-load on first ``async_items()`` access; calling
# ``async_load`` explicitly is safe either way.
if hasattr(resources, "async_load"):
try:
await resources.async_load()
except Exception: # noqa: BLE001
# Storage collection may be in an unloaded state with no
# file yet - treated the same as "no items".
pass
items = []
if hasattr(resources, "async_items"):
try:
items = list(resources.async_items())
except Exception: # noqa: BLE001
items = []
base = card_url.split("?", 1)[0]
same_version_present = False
stale_ids: list[str] = []
for item in items:
if isinstance(item, dict):
url = item.get("url", "") or ""
item_id = item.get("id")
else:
url = getattr(item, "url", "") or ""
item_id = getattr(item, "id", None)
if url.split("?", 1)[0] != base:
continue
if url == card_url:
same_version_present = True
elif item_id:
stale_ids.append(item_id)
# Strip stale registrations (older versions of the card pinned via
# an out-of-date ``?v=...`` query) so the dashboard doesn't pull
# both the new and the old script.
for item_id in stale_ids:
try:
await resources.async_delete_item(item_id)
except Exception: # noqa: BLE001
_LOGGER.debug(
"Could not delete stale Lovelace resource id=%s",
item_id,
exc_info=True,
)
if not same_version_present:
await resources.async_create_item(
{"url": card_url, "res_type": "module"}
)
return True
except Exception: # noqa: BLE001
_LOGGER.debug(
"Lovelace resource registration failed; will fall back to"
" add_extra_js_url",
exc_info=True,
)
return False
def _read_manifest_version(integration_dir: str) -> str | None:
try:
with open(
os.path.join(integration_dir, "manifest.json"),
"r",
encoding="utf-8",
) as fh:
return json.load(fh).get("version")
except Exception: # noqa: BLE001
return None
def _recover_card_from_root(
integration_dir: str, www_dir: str, card_path: str
) -> bool:
"""Salvage the card file from a broken extraction.
PowerShell's ``Compress-Archive`` writes zip entries with backslash
separators, which Linux unzip implementations may treat as literal
filenames. The resulting layout is::
custom_components/album_slideshow/www\\album-slideshow-card.js
instead of the expected ``www/album-slideshow-card.js``. Move it to
the right place so subsequent installs don't need a re-download.
"""
candidates = [
os.path.join(integration_dir, f"www\\{CARD_FILE}"),
os.path.join(integration_dir, CARD_FILE),
]
for src in candidates:
if os.path.isfile(src):
try:
os.makedirs(www_dir, exist_ok=True)
os.replace(src, card_path)
_LOGGER.info(
"Recovered Album Slideshow card from %s", src
)
return True
except OSError:
_LOGGER.exception(
"Found candidate card at %s but could not move it"
" to %s",
src,
card_path,
)
return False
return False
async def _async_cleanup_legacy_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
registry = er.async_get(hass)
# Server-side transitions were tried in earlier 0.7-rc builds and
# removed before the first public 0.7 pre-release because the
# resource cost on low-end hardware was too high. Drop any leftover
# transition entities so users don't see stale disabled rows under
# the device.
legacy_unique_ids = {
f"{entry.entry_id}_max_items",
f"{entry.entry_id}_transition",
f"{entry.entry_id}_transition_duration_ms",
f"{entry.entry_id}_transition_fps",
}
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
if entity.unique_id in legacy_unique_ids:
registry.async_remove(entity.entity_id)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Register the Lovelace card during HA bootstrap.
Running this in ``async_setup`` (not only in ``async_setup_entry``)
registers the card's static path and frontend resource at
integration load time, well before any config entry finishes
setting up. The earlier the resource is registered, the smaller
the window in which a dashboard can render a card before the
script's ``customElements.define()`` has run, which is what
produces the intermittent "Custom element doesn't exist:
album-slideshow-card" errors on slow devices (tablets) that open
the dashboard during HA startup.
We also retry the Lovelace storage-resource registration once HA
finishes starting, because ``hass.data['lovelace']`` may not yet
be populated when ``async_setup`` runs. The storage-resource path
is the only mechanism that *gates* dashboard render on resource
load (``add_extra_js_url`` just injects a script tag with no
ordering guarantee), so we want it to succeed even if we beat
Lovelace to the punch on the first attempt.
"""
hass.data.setdefault(DOMAIN, {})
await _async_register_card(hass)
async def _retry_lovelace_resource(_event) -> None:
if hass.data.get(DOMAIN, {}).get("lovelace_resource_registered"):
return
integration_dir = os.path.dirname(__file__)
version = await hass.async_add_executor_job(
_read_manifest_version, integration_dir
)
card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
if version:
card_url = f"{card_url}?v={version}"
if await _try_register_lovelace_resource(hass, card_url):
hass.data.setdefault(DOMAIN, {})[
"lovelace_resource_registered"
] = True
_LOGGER.info(
"Album Slideshow card registered as Lovelace resource"
" on HA started (late retry; Lovelace was not yet"
" ready during integration setup)"
)
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, _retry_lovelace_resource
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
from .coordinator import AlbumCoordinator
hass.data.setdefault(DOMAIN, {})
# Domain-wide concurrency limit on compose work. Multiple album cameras
# share HA's small executor pool; without coordination they can all
# decode + render in parallel and saturate the loop. One ticket means
# at most one album does PIL work at a time, queueing the rest.
if "compose_semaphore" not in hass.data[DOMAIN]:
hass.data[DOMAIN]["compose_semaphore"] = asyncio.Semaphore(1)
# Register the Lovelace card once per HA session. The card runs the
# GPU-composited transitions in the browser so the camera entity
# itself never has to render a transition burst on the event loop.
await _async_register_card(hass)
await _async_cleanup_legacy_entities(hass, entry)
store = SlideshowStore()
coordinator = AlbumCoordinator(hass, entry, store)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = {
"coordinator": coordinator,
"store": store,
"camera": None,
}
async def _next_slide(call) -> None:
entry_id = call.data.get(ATTR_ENTRY_ID)
if not entry_id:
return
data = hass.data.get(DOMAIN, {}).get(entry_id)
if not data:
return
cam = data.get("camera")
if cam:
await cam.async_force_next()
async def _refresh_album(call) -> None:
entry_id = call.data.get(ATTR_ENTRY_ID)
if not entry_id:
return
data = hass.data.get(DOMAIN, {}).get(entry_id)
if not data:
return
cam = data.get("camera")
if cam:
await cam.async_force_refresh()
if not hass.services.has_service(DOMAIN, SERVICE_NEXT_SLIDE):
hass.services.async_register(DOMAIN, SERVICE_NEXT_SLIDE, _next_slide)
if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM):
hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
store.notify()
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
domain_data = hass.data.get(DOMAIN, {})
domain_data.pop(entry.entry_id, None)
# Drop the shared semaphore once the last album is gone so it's
# re-created if the integration is re-added later.
entry_keys = [
k for k in domain_data.keys() if k != "compose_semaphore"
]
if not entry_keys:
domain_data.pop("compose_semaphore", None)
return unload_ok
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,68 @@
from __future__ import annotations
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .coordinator import AlbumCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
async_add_entities(
[
NextSlideButton(hass, entry, coordinator),
RefreshAlbumButton(hass, entry, coordinator),
]
)
class _BaseButton(ButtonEntity):
_attr_has_entity_name = True
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
self.hass = hass
self.entry = entry
self.coordinator = coordinator
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
class NextSlideButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_next_button"
self._attr_name = "Next slide"
self._attr_icon = "mdi:skip-next"
async def async_press(self) -> None:
await self.hass.services.async_call(
DOMAIN,
SERVICE_NEXT_SLIDE,
{ATTR_ENTRY_ID: self.entry.entry_id},
blocking=False,
)
class RefreshAlbumButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_refresh_button"
self._attr_name = "Refresh album"
self._attr_icon = "mdi:refresh"
async def async_press(self) -> None:
await self.hass.services.async_call(
DOMAIN,
SERVICE_REFRESH_ALBUM,
{ATTR_ENTRY_ID: self.entry.entry_id},
blocking=False,
)
+922
View File
@@ -0,0 +1,922 @@
from __future__ import annotations
import asyncio
from collections import OrderedDict
import logging
import random
from pathlib import Path
import async_timeout
from PIL import Image
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
DOMAIN,
MAX_RESOLUTION_SHORT_EDGE,
ORIENTATION_MISMATCH_PAIR,
ORIENTATION_MISMATCH_AVOID,
ORDER_ALBUM,
ORDER_RANDOM,
PROVIDER_GOOGLE_SHARED,
)
from . import image_processing as ip
from . import playlist
from .coordinator import AlbumCoordinator, MediaItem
from .store import SlideshowStore
_LOGGER = logging.getLogger(__name__)
# Cap a single download at 64 MB. Larger images are rejected before decode
# to protect low-memory devices. This is well above any realistic camera
# JPEG; RAW/NEF/etc. aren't supported as camera frames anyway.
_MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024
# Only these content types are accepted as image bodies. If a server returns
# HTML (captive portal, 404 page rendered as 200, etc.) we reject it early.
_ACCEPTED_IMAGE_PREFIX = ("image/",)
# Max candidates we'll scan when searching for a mismatched-orientation
# pairing partner. Metadata-only checks are nearly free; decode-only checks
# (no metadata available) are expensive.
_PAIR_SEARCH_LIMIT = 12
_SKIP_SEARCH_LIMIT = 30
def _ts_to_iso(ts_ms: int | None) -> str | None:
"""Convert epoch milliseconds to an ISO-8601 string in UTC, or None."""
if not isinstance(ts_ms, int):
return None
from datetime import datetime, timezone
try:
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat()
except (OverflowError, OSError, ValueError):
return None
class _DownloadCache:
"""Byte-budget LRU cache for downloaded image data, O(1) per operation."""
def __init__(self, max_bytes: int) -> None:
self._cache: "OrderedDict[str, bytes]" = OrderedDict()
self._total_bytes: int = 0
self._max_bytes: int = max(max_bytes, 1)
@property
def total_bytes(self) -> int:
return self._total_bytes
def get(self, url: str) -> bytes | None:
data = self._cache.get(url)
if data is None:
return None
self._cache.move_to_end(url)
return data
def put(self, url: str, data: bytes) -> None:
if len(data) > self._max_bytes:
# Item exceeds the entire cache budget; skip caching but don't raise.
return
if url in self._cache:
self._total_bytes -= len(self._cache[url])
del self._cache[url]
self._cache[url] = data
self._total_bytes += len(data)
self._evict()
def resize(self, max_bytes: int) -> None:
self._max_bytes = max(max_bytes, 1)
self._evict()
def _evict(self) -> None:
while self._total_bytes > self._max_bytes and self._cache:
_, data = self._cache.popitem(last=False)
self._total_bytes -= len(data)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
cam = AlbumSlideshowCamera(hass, entry, coordinator, store)
hass.data[DOMAIN][entry.entry_id]["camera"] = cam
async_add_entities([cam])
class AlbumSlideshowCamera(Camera):
_attr_should_poll = False
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
super().__init__()
self.hass = hass
self.entry = entry
self.coordinator = coordinator
self.store = store
self._attr_name = f"Album Slideshow {entry.title}"
self._attr_unique_id = f"{entry.entry_id}_camera"
self._rng = random.Random()
self._index = 0
self._random_order: list[int] = []
self._random_pos = 0
self._download_cache = _DownloadCache(
max_bytes=store.image_cache_mb * 1024 * 1024
)
self._recent_urls: list[str] = []
self._last_is_portrait: bool | None = None
# When the current frame is a paired image, this is [taken_a, taken_b]
# ISO strings (top/left first); None for single frames.
self._last_captured_at_pair: list[str | None] | None = None
# Full per-half caption metadata for a paired frame: a list of two
# dicts (top/left first) each carrying captured_at / location /
# latitude / longitude. None for single frames. Lets the Lovelace
# card overlay an accurate caption on each half of a pair.
self._last_pair_frames: list[dict] | None = None
# ``horizontal`` (side-by-side, left/right) or ``vertical`` (stacked,
# top/bottom) for a paired frame; None for single frames.
self._last_pair_orientation: str | None = None
# Cached effective playlist (after date filter + ordering). Invalidated
# by any store change or coordinator update.
self._effective_cache: tuple[int, list[MediaItem]] | None = None
self._framebuffer: bytes | None = None
# MJPEG subscribers. Each open stream owns an asyncio.Queue of JPEG
# byte payloads. The render loop pushes the latest still as soon
# as it's encoded; if a subscriber falls behind we drop frames
# for that subscriber rather than block the whole loop.
self._mjpeg_subscribers: set[asyncio.Queue[bytes]] = set()
# Monotonic counter incremented every time a new still is committed.
# Exposed as the ``frame_id`` state attribute so the Lovelace card
# has an unambiguous "new frame ready" signal even when other
# attributes happen not to change between slides.
self._frame_id: int = 0
self._interrupt_event: asyncio.Event = asyncio.Event()
self._force_next: bool = False
self._consecutive_failures: int = 0
self._render_task: asyncio.Task | None = None
def _on_coordinator_update() -> None:
self._effective_cache = None
self._interrupt_event.set()
self.async_write_ha_state()
coordinator.async_add_listener(_on_coordinator_update)
def _on_store_change() -> None:
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
self._effective_cache = None
self._interrupt_event.set()
self.async_write_ha_state()
store.add_listener(_on_store_change)
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
# Restore last framebuffer (if the store kept one) so the camera has
# something to show immediately after a restart, rather than a broken
# image placeholder while the first render completes.
restored = getattr(self.store, "last_frame", None)
if isinstance(restored, (bytes, bytearray)) and restored:
self._framebuffer = bytes(restored)
# Stagger the first render across multiple albums so they don't all
# decode + encode at the same instant on HA startup. Deterministic
# offset based on entry_id keeps the pattern stable across
# restarts. Up to ~3 s spread across albums.
startup_delay = (hash(self.entry.entry_id) % 3000) / 1000.0
self._render_task = self.hass.async_create_background_task(
self._render_loop(initial_delay=startup_delay),
name="album_slideshow_render_loop",
)
async def async_will_remove_from_hass(self) -> None:
if self._render_task is not None:
self._render_task.cancel()
try:
await self._render_task
except asyncio.CancelledError:
pass
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
@property
def icon(self) -> str:
if self.coordinator.provider == PROVIDER_GOOGLE_SHARED:
return "mdi:google-photos"
return "mdi:folder-multiple-image"
@property
def extra_state_attributes(self):
data = self.coordinator.data or {}
items: list[MediaItem] = self._effective_items()
cur = items[self._index] if items and 0 <= self._index < len(items) else None
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
captured_at_pair = self._last_captured_at_pair
return {
"album_title": data.get("title"),
"media_count": len(items),
"media_count_total": len(data.get("items", []) or []),
"current_index": self._index,
"current_filename": getattr(cur, "filename", None),
"current_url": getattr(cur, "url", None),
"current_is_portrait": self._last_is_portrait,
"captured_at": captured_at_pair if captured_at_pair else captured_at,
"captured_at_primary": captured_at,
"uploaded_at": _ts_to_iso(getattr(cur, "uploaded_at", None)),
"byte_size": getattr(cur, "byte_size", None),
# GPS + reverse-geocoded label come from EXIF for local-folder
# entries; Google albums leave these as ``None``.
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"location": getattr(cur, "location", None),
# Structured per-image caption metadata. A single-element list for
# normal slides; two elements (top/left first) for paired slides,
# so the card can overlay an accurate date/location on each half.
# ``pair_orientation`` tells the card how the two halves are laid
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
"caption_frames": self._caption_frames(cur, captured_at),
"pair_orientation": self._last_pair_orientation,
"slide_interval": int(self.store.slide_interval),
"fill_mode": self.store.fill_mode,
"portrait_mode": self.store.portrait_mode,
"order_mode": self.store.order_mode,
"date_filter": self.store.date_filter,
"paused": bool(self.store.paused),
"refresh_hours": int(self.store.refresh_hours),
"aspect_ratio": self.store.aspect_ratio,
"pair_divider_px": int(self.store.pair_divider_px),
"pair_divider_color": self.store.pair_divider_color,
"frame_id": self._frame_id,
"pagination_debug": data.get("pagination_debug"),
}
def _caption_frames(self, cur, captured_at: str | None) -> list[dict]:
"""Per-image caption metadata for the current slide.
Returns a list with one dict for a normal slide, or two (top/left
first) for a paired slide. Each dict carries ``captured_at`` (ISO
string or ``None``), ``location`` (human label or ``None``), and
``latitude`` / ``longitude``. The card reads this to overlay an
accurate caption on each image, including each half of a pair.
"""
if self._last_pair_frames:
return self._last_pair_frames
return [
{
"captured_at": captured_at,
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
}
]
@property
def entity_picture(self) -> str | None:
"""Return the camera proxy URL with a per-frame cache-buster.
HA core's default ``entity_picture`` only changes when the access
token rotates (about every five minutes). Browsers happily serve
the cached image to the more-info dialog and other surfaces in
between rotations, so they end up showing the previous slide
while a fresh slide is already in the framebuffer. Appending the
``frame_id`` invalidates that cache as soon as a new slide is
committed, no matter where in HA the picture is rendered.
"""
base = super().entity_picture
if not base:
return base
sep = "&" if "?" in base else "?"
return f"{base}{sep}frame={self._frame_id}"
@property
def cache_usage_mb(self) -> float:
return round(self._download_cache.total_bytes / (1024 * 1024), 1)
def _effective_items(self) -> list[MediaItem]:
"""Return the playlist after applying the date filter and order mode.
Cached until the coordinator or store changes (see invalidations
wired up in __init__).
"""
data = self.coordinator.data or {}
raw: list[MediaItem] = data.get("items", []) or []
cache_key = (
id(raw),
self.store.date_filter,
self.store.order_mode,
)
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
return self._effective_cache[1]
filtered = playlist.filter_items(
raw,
mode=self.store.date_filter,
)
ordered = playlist.order_items(filtered, self.store.order_mode)
self._effective_cache = (hash(cache_key), ordered)
return ordered
async def async_force_next(self) -> None:
self._force_next = True
self._interrupt_event.set()
self.async_write_ha_state()
async def async_force_refresh(self) -> None:
await self.coordinator.async_request_refresh()
async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
return self._framebuffer
async def handle_async_mjpeg_stream(self, request):
"""Stream the slideshow as multipart MJPEG.
Each open client gets a bounded asyncio.Queue that the render loop
pushes JPEG payloads into when a new still is committed. Visible
transitions are now handled by the Lovelace card on the client
side, so this stream just emits the latest still per slide change.
"""
# Imported lazily so the module still loads in test environments
# that stub out homeassistant without installing aiohttp.
from aiohttp import web
boundary = "frame"
response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
"Cache-Control": "no-cache, private",
"Pragma": "no-cache",
},
)
await response.prepare(request)
# Bounded queue: a slow client should fall behind on slide commits
# rather than balloon memory.
queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=4)
self._mjpeg_subscribers.add(queue)
# Push the held still immediately so the client renders something
# before the next slide change.
if self._framebuffer is not None:
try:
queue.put_nowait(self._framebuffer)
except asyncio.QueueFull:
pass
try:
while True:
payload = await queue.get()
try:
await response.write(
b"--" + boundary.encode() + b"\r\n"
b"Content-Type: image/jpeg\r\n"
b"Content-Length: " + str(len(payload)).encode() + b"\r\n\r\n"
+ payload + b"\r\n"
)
except (ConnectionResetError, asyncio.CancelledError):
raise
except Exception as err:
_LOGGER.debug("Album Slideshow: mjpeg client write failed: %s", err)
break
except (ConnectionResetError, asyncio.CancelledError):
pass
finally:
self._mjpeg_subscribers.discard(queue)
try:
await response.write_eof()
except Exception:
pass
return response
# Older HA cores may dispatch via the alt name; alias for compatibility.
async def async_handle_async_mjpeg_stream(self, request):
return await self.handle_async_mjpeg_stream(request)
async def _wait_or_interrupt(self, timeout: float) -> bool:
"""Wait up to ``timeout`` seconds, returning True if interrupted.
Safe wrapper around clear() + wait_for() - callers don't have to
worry about the ordering of the two operations. The clear() runs
synchronously before the awaitable is created, so no interrupt can
be lost on the single-threaded event loop.
"""
self._interrupt_event.clear()
try:
await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
async def _render_loop(self, initial_delay: float = 0.0) -> None:
"""Background task: render slides into _framebuffer, advance on timer or interrupt."""
if initial_delay > 0:
try:
await asyncio.sleep(initial_delay)
except asyncio.CancelledError:
raise
should_advance = False # Don't advance on the very first render
while True:
try:
await self._render_cycle(advance=should_advance)
self._consecutive_failures = 0
except asyncio.CancelledError:
raise
except Exception as err:
self._consecutive_failures += 1
backoff = min(2 ** self._consecutive_failures, 60)
_LOGGER.warning(
"Album Slideshow: render cycle failed (attempt %d), retrying in %ds: %s",
self._consecutive_failures, backoff, err,
)
try:
await asyncio.sleep(backoff)
except asyncio.CancelledError:
raise
should_advance = True # Skip the broken image on retry
continue
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
if interrupted:
should_advance = self._force_next
self._force_next = False
else:
# Paused slideshows hold the current frame until the user
# un-pauses or hits "next slide" explicitly.
should_advance = not bool(self.store.paused)
async def _render_cycle(self, advance: bool) -> None:
"""Render one frame.
The slideshow is just "advance index, compose, encode, broadcast".
Visible transitions are handled by the Lovelace card client-side,
so this path stays minimal: at most one PIL decode + encode per
slide change.
Compose work is serialised across all albums via a domain-wide
semaphore so 4 cameras don't all decode + encode at once.
"""
items: list[MediaItem] = self._effective_items()
if not items:
return
count = len(items)
if advance:
self._do_advance(count, items)
async with self._compose_semaphore:
composed, meta = await self._compose_for_index(items)
if composed is None:
return
try:
await self._commit_composed(composed, meta)
finally:
ip.safe_close(composed)
async def _commit_composed(self, composed: Image.Image, meta: dict) -> None:
"""Encode the composed slide into the framebuffer and broadcast.
Encodes off the loop so the JPEG encode (30-80 ms at 1080p, more
at 4K) doesn't block HA.
"""
encoded = await self.hass.async_add_executor_job(
ip.encode_image, composed
)
self._framebuffer = encoded
self.store.last_frame = encoded
self._frame_id += 1
if meta:
self._last_is_portrait = meta.get("is_portrait")
else:
self._last_is_portrait = None
self._last_captured_at_pair = meta.get("captured_at_pair") if meta else None
self._last_pair_frames = meta.get("pair_frames") if meta else None
self._last_pair_orientation = meta.get("pair_orientation") if meta else None
self._broadcast_frame(encoded)
self.async_write_ha_state()
@property
def _compose_semaphore(self) -> asyncio.Semaphore:
"""Return the domain-wide compose semaphore, creating it on demand.
``__init__.py`` populates it during setup, but defensive
initialisation here means a partially-loaded integration can
still render without crashing.
"""
domain_data = self.hass.data.setdefault(DOMAIN, {})
sem = domain_data.get("compose_semaphore")
if sem is None:
sem = asyncio.Semaphore(1)
domain_data["compose_semaphore"] = sem
return sem
def _broadcast_frame(self, payload: bytes) -> None:
"""Push a frame to every active MJPEG subscriber.
Slow subscribers get their frame dropped rather than backing up the
queue; the next still emission will catch them up.
"""
for queue in list(self._mjpeg_subscribers):
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
# Drain one and retry once so a wedged client still sees
# the latest frame eventually instead of forever stale.
try:
queue.get_nowait()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
pass
def _do_advance(self, count: int, items: list) -> None:
"""Advance _index to the next slide and commit random-order position."""
if count <= 0:
self._index = 0
return
self._index %= count
order_mode = self.store.order_mode
# Sequential modes (album order + sorted-by-time orderings) walk in
# order. The list is already pre-sorted by ``order_items``, so we
# only need to step forward.
if order_mode != ORDER_RANDOM:
self._index = (self._index + 1) % count
return
self._index = self._next_random_index(count)
cur_url = items[self._index].url
self._recent_urls.append(cur_url)
keep = min(20, max(1, count - 1))
if len(self._recent_urls) > keep:
self._recent_urls = self._recent_urls[-keep:]
def _peek_advance(self, count: int, items: list) -> None:
"""Advance _index without committing to random-order bookkeeping.
Used by the orientation-avoid search so that rejected candidates
don't burn through the random cycle and cause premature repeats.
"""
if count <= 0:
self._index = 0
return
self._index = (self._index + 1) % count
async def _compose_for_index(
self, items: list[MediaItem]
) -> tuple[Image.Image | None, dict | None]:
"""Compose the slide at ``self._index`` into a PIL image.
Returns ``(composed, meta)`` where ``meta`` carries the orientation
and paired-capture metadata that ``_commit_composed`` will publish
as state attributes. Returns ``(None, None)`` if compose failed.
Pure compose - does NOT mutate ``self._framebuffer`` or
``self._last_*`` state. The caller commits via ``_commit_composed``.
"""
fill_mode = self.store.fill_mode
portrait_mode = self.store.portrait_mode
divider = max(0, int(self.store.pair_divider_px))
divider_fill, transparent_divider = ip.parse_divider_color(self.store.pair_divider_color)
max_short_edge = MAX_RESOLUTION_SHORT_EDGE.get(self.store.max_resolution)
width, height = ip.resolve_output_size(None, None, self.store.aspect_ratio, max_short_edge)
cur = items[self._index]
is_portrait_canvas = height > width
# Metadata fast path: if we can resolve orientation without downloading,
# we may short-circuit the mismatch handling before any bytes are read.
meta_portrait = ip.is_portrait_item_by_metadata(cur)
if (
meta_portrait is not None
and meta_portrait != is_portrait_canvas
and portrait_mode == ORIENTATION_MISMATCH_AVOID
):
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
cur_bytes = await self._fetch_bytes(cur.url)
if not cur_bytes:
raise RuntimeError(f"Failed to fetch image: {cur.url}")
img = await self.hass.async_add_executor_job(
ip.open_image, cur_bytes, (width, height)
)
try:
cur_is_portrait = ip.is_portrait_item(cur, img)
orientation_mismatch = cur_is_portrait != is_portrait_canvas
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_AVOID:
ip.safe_close(img)
img = None
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_PAIR:
pair = await self._find_next_mismatch_image(
items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
)
other_img = pair[0] if pair else None
other_item = pair[1] if pair else None
pair_meta: list[str | None] | None = None
pair_frames: list[dict] | None = None
try:
if other_img is not None:
composed = await self.hass.async_add_executor_job(
ip.pair_images, img, other_img, width, height, fill_mode,
is_portrait_canvas, divider, divider_fill, transparent_divider,
)
pair_frames = [
{
"captured_at": _ts_to_iso(getattr(cur, "captured_at", None)),
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
},
{
"captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
"location": getattr(other_item, "location", None),
"latitude": getattr(other_item, "latitude", None),
"longitude": getattr(other_item, "longitude", None),
},
]
pair_meta = [f["captured_at"] for f in pair_frames]
else:
composed = await self.hass.async_add_executor_job(
ip.render_image, img, fill_mode, width, height,
)
finally:
ip.safe_close(other_img)
meta = {
"is_portrait": cur_is_portrait,
"captured_at_pair": pair_meta,
"pair_frames": pair_frames,
# ``pair_images`` stacks images top/bottom on a portrait
# canvas and places them left/right on a landscape canvas.
"pair_orientation": (
("vertical" if is_portrait_canvas else "horizontal")
if pair_frames
else None
),
}
return composed, meta
composed = await self.hass.async_add_executor_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
"is_portrait": cur_is_portrait,
"captured_at_pair": None,
}
finally:
ip.safe_close(img)
async def _compose_skip_mismatch(
self,
items: list[MediaItem],
width: int,
height: int,
fill_mode: str,
is_portrait_canvas: bool,
) -> tuple[Image.Image | None, dict | None]:
"""Skip-mismatch variant of ``_compose_for_index``.
Walks forward (peek-advancing for non-matches) until it finds an
image whose orientation matches the canvas, then composes it.
"""
count = len(items)
if count <= 0:
return None, None
start = self._index
for _ in range(min(count, _SKIP_SEARCH_LIMIT)):
cur = items[self._index]
meta_portrait = ip.is_portrait_item_by_metadata(cur)
if meta_portrait is not None:
if meta_portrait != is_portrait_canvas:
self._peek_advance(count, items)
continue
if self._index != start:
self._do_advance(count, items)
return await self._compose_single(cur, width, height, fill_mode)
b = await self._fetch_bytes(cur.url)
if not b:
self._peek_advance(count, items)
continue
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
try:
if ip.is_portrait_item(cur, img) != is_portrait_canvas:
self._peek_advance(count, items)
continue
if self._index != start:
self._do_advance(count, items)
composed = await self.hass.async_add_executor_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
"is_portrait": is_portrait_canvas,
"captured_at_pair": None,
}
finally:
ip.safe_close(img)
self._index = start
return await self._compose_single(items[self._index], width, height, fill_mode)
async def _compose_single(
self,
item: MediaItem,
width: int,
height: int,
fill_mode: str,
) -> tuple[Image.Image | None, dict | None]:
b = await self._fetch_bytes(item.url)
if not b:
return None, None
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
try:
cur_is_portrait = ip.is_portrait_item(item, img)
composed = await self.hass.async_add_executor_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
"is_portrait": cur_is_portrait,
"captured_at_pair": None,
}
finally:
ip.safe_close(img)
async def _render_current(self, items: list[MediaItem]) -> bytes | None:
"""Compatibility wrapper: compose + encode the current slide.
Kept as a thin wrapper because external code paths (e.g., tests)
may still call it. ``_render_cycle`` no longer does.
"""
composed, _ = await self._compose_for_index(items)
if composed is None:
return None
try:
return await self.hass.async_add_executor_job(ip.encode_image, composed)
finally:
ip.safe_close(composed)
async def _find_next_mismatch_image(
self,
items: list[MediaItem],
is_portrait_canvas: bool,
width: int,
height: int,
limit: int = _PAIR_SEARCH_LIMIT,
) -> tuple[Image.Image, MediaItem] | None:
"""Find an image with the opposite orientation of the canvas.
Uses metadata wherever possible - only candidates without width/height
metadata are downloaded and decoded for their orientation. The returned
PIL image is the caller's to close. The matching ``MediaItem`` is
returned alongside so the caller can attribute timestamps etc.
"""
if not items:
return None
n = len(items)
tries = 0
offset = 1
while tries < limit and offset < n:
idx = (self._index + offset) % n
it = items[idx]
offset += 1
tries += 1
if it.url in self._recent_urls:
continue
meta_portrait = ip.is_portrait_item_by_metadata(it)
if meta_portrait is not None and meta_portrait == is_portrait_canvas:
# Metadata says this one is the wrong orientation for pairing; skip.
continue
b = await self._fetch_bytes(it.url)
if not b:
continue
try:
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
except Exception:
continue
if ip.is_portrait_item(it, img) != is_portrait_canvas:
return img, it
ip.safe_close(img)
return None
def _next_random_index(self, count: int) -> int:
if count <= 1:
self._random_order = [0]
self._random_pos = 0
return 0
needs_new_cycle = len(self._random_order) != count or self._random_pos >= len(self._random_order)
if needs_new_cycle:
self._random_order = list(range(count))
self._rng.shuffle(self._random_order)
self._random_pos = 0
if self._random_order and self._random_order[0] == self._index:
self._random_order.append(self._random_order.pop(0))
idx = self._random_order[self._random_pos]
self._random_pos += 1
return idx
async def _fetch_bytes(self, url: str) -> bytes | None:
cached = self._download_cache.get(url)
if cached is not None:
return cached
if url.startswith("file://"):
try:
p = Path(url[7:])
data = await self.hass.async_add_executor_job(p.read_bytes)
except Exception as err:
_LOGGER.warning("Album Slideshow: failed to read local image: %s", err)
return None
if len(data) > _MAX_DOWNLOAD_BYTES:
_LOGGER.warning(
"Album Slideshow: local image %s is %d bytes, exceeds %d byte limit; skipping",
url, len(data), _MAX_DOWNLOAD_BYTES,
)
return None
else:
data = await self._http_get(url)
if data is None:
return None
self._download_cache.put(url, data)
return data
async def _http_get(self, url: str) -> bytes | None:
session = async_get_clientsession(self.hass)
try:
async with async_timeout.timeout(30):
async with session.get(url) as resp:
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "")
primary = content_type.split(";", 1)[0].strip().lower()
if primary and not primary.startswith(_ACCEPTED_IMAGE_PREFIX):
_LOGGER.debug(
"Album Slideshow: rejecting %s, content-type %r is not an image",
url, primary,
)
return None
content_length = resp.headers.get("Content-Length")
if content_length is not None:
try:
declared = int(content_length)
except ValueError:
declared = -1
if declared > _MAX_DOWNLOAD_BYTES:
_LOGGER.warning(
"Album Slideshow: %s advertises %d bytes, exceeds %d byte limit; skipping",
url, declared, _MAX_DOWNLOAD_BYTES,
)
return None
chunks: list[bytes] = []
total = 0
async for chunk in resp.content.iter_chunked(64 * 1024):
total += len(chunk)
if total > _MAX_DOWNLOAD_BYTES:
_LOGGER.warning(
"Album Slideshow: %s exceeded %d byte limit mid-download; aborting",
url, _MAX_DOWNLOAD_BYTES,
)
return None
chunks.append(chunk)
return b"".join(chunks)
except Exception as err:
_LOGGER.warning("Album Slideshow: failed to fetch image: %s", err)
return None
@@ -0,0 +1,194 @@
from __future__ import annotations
import re
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from .const import (
DOMAIN,
CONF_PROVIDER,
CONF_ALBUM_NAME,
CONF_ALBUM_URL,
CONF_LOCAL_PATH,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
DEFAULT_RECURSIVE,
)
def _normalize_local_path(hass, path: str) -> str:
p = path.strip()
if p.startswith("/local/"):
p = "/config/www/" + p[len("/local/"):]
elif p == "/local":
p = "/config/www"
elif p.startswith("local/"):
p = "/config/www/" + p[len("local/"):]
elif p.startswith("/media/local/"):
p = "/media/" + p[len("/media/local/"):]
elif p.startswith("media/local/"):
p = "/media/" + p[len("media/local/"):]
elif p.startswith("media/"):
p = "/media/" + p[len("media/"):]
elif p == "media":
p = "/media"
if not p.startswith("/"):
p = hass.config.path(p)
return p
ALBUM_URL_RE = re.compile(r"^https?://photos\.app\.goo\.gl/[^/]+/?$")
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
self._provider: str | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Return the options flow handler.
Only local-folder entries expose user-tunable options today (the
reverse-geocode toggle); Google entries get a no-op handler so
that the "Configure" button doesn't appear empty in the UI.
Note: do NOT pass ``config_entry`` to the OptionsFlow constructor.
Since Home Assistant 2024.12 the base class manages
``self.config_entry`` as a property and assigning to it in
``__init__`` raises (the symptom is a 500 when the user clicks
Configure).
"""
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_LOCAL_FOLDER:
return LocalFolderOptionsFlow()
return _NoOptionsFlow()
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
if user_input is not None:
self._provider = user_input[CONF_PROVIDER]
if self._provider == PROVIDER_LOCAL_FOLDER:
return await self.async_step_local_folder()
return await self.async_step_google_shared()
schema = vol.Schema(
{
vol.Required(CONF_PROVIDER, default=PROVIDER_GOOGLE_SHARED): vol.In({
PROVIDER_GOOGLE_SHARED: "Google Photos",
PROVIDER_LOCAL_FOLDER: "Local Folder",
})
}
)
return self.async_show_form(step_id="user", data_schema=schema)
async def async_step_google_shared(self, user_input: dict[str, Any] | None = None) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_ALBUM_URL].strip()
name = user_input[CONF_ALBUM_NAME].strip()
if not ALBUM_URL_RE.match(url):
errors[CONF_ALBUM_URL] = "invalid_album_url"
else:
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_GOOGLE_SHARED,
CONF_ALBUM_URL: url,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_ALBUM_URL): str,
}
)
return self.async_show_form(step_id="google_shared", data_schema=schema, errors=errors)
async def async_step_local_folder(self, user_input: dict[str, Any] | None = None) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
path = _normalize_local_path(self.hass, user_input[CONF_LOCAL_PATH])
name = user_input[CONF_ALBUM_NAME].strip()
recursive = bool(user_input.get(CONF_RECURSIVE, DEFAULT_RECURSIVE))
if not path:
errors[CONF_LOCAL_PATH] = "invalid_path"
else:
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_LOCAL_FOLDER,
CONF_LOCAL_PATH: path,
CONF_RECURSIVE: recursive,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_LOCAL_PATH): str,
vol.Optional(CONF_RECURSIVE, default=DEFAULT_RECURSIVE): bool,
}
)
return self.async_show_form(step_id="local_folder", data_schema=schema, errors=errors)
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
Currently exposes a single toggle: ``reverse_geocode``. Users with
privacy concerns about handing EXIF GPS coordinates to an external
OSM endpoint can turn this off; the GPS coordinates remain available
as ``latitude``/``longitude`` attributes regardless.
``self.config_entry`` is provided by ``OptionsFlow`` as a managed
property (HA 2024.12+); we deliberately do NOT define ``__init__``
or assign to it, since doing so raises in newer cores.
"""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
current = self.config_entry.options.get(
CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE
)
schema = vol.Schema(
{
vol.Required(
CONF_REVERSE_GEOCODE, default=bool(current)
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=schema)
class _NoOptionsFlow(config_entries.OptionsFlow):
"""Fallback options flow for providers that expose nothing tunable."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
return self.async_create_entry(title="", data={})
@@ -0,0 +1,94 @@
DOMAIN = "album_slideshow"
CONF_PROVIDER = "provider"
CONF_ALBUM_URL = "album_url"
CONF_ALBUM_NAME = "album_name"
CONF_LOCAL_PATH = "local_path"
CONF_RECURSIVE = "recursive"
CONF_IMAGE_CACHE_MB = "image_cache_mb"
# Local-folder option: when True (default) the coordinator does best-effort
# reverse geocoding of EXIF GPS coordinates via the public Nominatim
# (OpenStreetMap) endpoint and exposes a human-readable ``location``
# attribute. Users with privacy concerns can disable this from the
# integration's options dialog.
CONF_REVERSE_GEOCODE = "reverse_geocode"
DEFAULT_REVERSE_GEOCODE = True
PROVIDER_GOOGLE_SHARED = "google_shared"
PROVIDER_LOCAL_FOLDER = "local_folder"
FILL_COVER = "cover"
FILL_CONTAIN = "contain"
FILL_BLUR = "blur"
ORIENTATION_MISMATCH_PAIR = "pair"
ORIENTATION_MISMATCH_SINGLE = "single"
ORIENTATION_MISMATCH_AVOID = "avoid"
ORDER_RANDOM = "random"
ORDER_ALBUM = "album_order"
ORDER_NEWEST_TAKEN = "newest_taken"
ORDER_OLDEST_TAKEN = "oldest_taken"
ORDER_NEWEST_ADDED = "newest_added"
ORDER_OLDEST_ADDED = "oldest_added"
ORDER_OPTIONS = [
ORDER_RANDOM,
ORDER_ALBUM,
ORDER_NEWEST_TAKEN,
ORDER_OLDEST_TAKEN,
ORDER_NEWEST_ADDED,
ORDER_OLDEST_ADDED,
]
DATE_FILTER_OFF = "off"
DATE_FILTER_LAST_7 = "last_7_days"
DATE_FILTER_LAST_30 = "last_30_days"
DATE_FILTER_LAST_365 = "last_365_days"
DATE_FILTER_THIS_MONTH = "this_month"
DATE_FILTER_THIS_YEAR = "this_year"
DATE_FILTER_ON_THIS_DAY = "on_this_day"
DATE_FILTER_OPTIONS = [
DATE_FILTER_OFF,
DATE_FILTER_LAST_7,
DATE_FILTER_LAST_30,
DATE_FILTER_LAST_365,
DATE_FILTER_THIS_MONTH,
DATE_FILTER_THIS_YEAR,
DATE_FILTER_ON_THIS_DAY,
]
DEFAULT_DATE_FILTER = DATE_FILTER_OFF
DEFAULT_SLIDE_INTERVAL = 60
DEFAULT_REFRESH_HOURS = 24
DEFAULT_FILL_MODE = FILL_BLUR
DEFAULT_ORIENTATION_MISMATCH_MODE = ORIENTATION_MISMATCH_PAIR
DEFAULT_ORDER_MODE = ORDER_RANDOM
DEFAULT_ASPECT_RATIO = "16:9"
DEFAULT_PAIR_DIVIDER_PX = 8
DEFAULT_PAIR_DIVIDER_COLOR = "#FFFFFF"
DEFAULT_RECURSIVE = True
# Per-album download cache. Multiple albums add up: 4 × 150 MB = 600 MB
# of just-in-case downloaded JPEGs. 75 MB caches roughly 10-20 photos
# at typical Google Photos resolutions, which is enough for the preload
# path. Users with one album and lots of RAM can bump this via the
# Image cache size number entity.
DEFAULT_IMAGE_CACHE_MB = 75
MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"]
DEFAULT_MAX_RESOLUTION = "1080p"
MAX_RESOLUTION_SHORT_EDGE: dict[str, int | None] = {
"480p": 480,
"720p": 720,
"1080p": 1080,
"1440p": 1440,
"4K (2160p)": 2160,
"original": None,
}
PUBLICALBUM_ENDPOINT = "https://www.publicalbum.org/api/v2/webapp/embed-player/jsonrpc"
SERVICE_NEXT_SLIDE = "next_slide"
SERVICE_REFRESH_ALBUM = "refresh_album"
ATTR_ENTRY_ID = "entry_id"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,472 @@
"""Google Photos shared album client that bypasses the ~300 item HTML cap.
Strategy
--------
1. Fetch the share URL HTML (with browser User-Agent so Google serves the
real page, not a JS-only shim).
2. Extract the album media key and auth key from the embedded
``AF_dataServiceRequests`` blob - the same parameters Google's JS uses
when it scrolls and needs more pages.
3. Page through the album by POSTing to the public ``batchexecute`` endpoint
with the ``snAcKc`` RPC (the same one googlephotos.com calls). Each page
carries up to 300 items plus a continuation token; we loop until the
token is empty.
This mirrors the approach used by community projects like
``xob0t/google-photos-toolkit``. It uses only undocumented public endpoints
and no auth.
Brittleness
-----------
Google occasionally reshuffles the per-item array layout. We keep parsing
positional but verify each field at access time and skip malformed entries
rather than failing the whole batch.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from urllib.parse import quote
from .coordinator import MediaItem
_LOGGER = logging.getLogger(__name__)
_BROWSER_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
# AF_dataServiceRequests block in the HTML carries the snAcKc request payload:
# "snAcKc",ext: ... ,request:["<albumKey>",null,null,"<authKey>"]
_REQUEST_RE = re.compile(
r"snAcKc[^}]*?request:\s*\[\s*\"([A-Za-z0-9_-]+)\"\s*,\s*null\s*,\s*null\s*,\s*\"([A-Za-z0-9_-]+)\"",
re.DOTALL,
)
# XSSI prefix Google prepends to batchexecute responses. We strip it before parsing.
_XSSI = ")]}'"
# Hard ceiling - matches the upstream Google album item limit.
_MAX_ITEMS = 20_000
# Per-page item count Google returns. Used only for sanity logging.
_PAGE_SIZE = 300
# Strip any existing ``=w...-h...`` suffix from a Google CDN URL so we can
# attach our own size hint based on the photo's known dimensions.
_SIZE_SUFFIX_RE = re.compile(r"=[wh]\d+(?:-[a-z0-9]+)*$", re.IGNORECASE)
_VIDEO_DURATION_KEY = 76647426 # presence indicates a video; we skip those
_LIVEPHOTO_KEY = 146008172
class _AlbumKeys:
__slots__ = ("album_key", "auth_key")
def __init__(self, album_key: str, auth_key: str) -> None:
self.album_key = album_key
self.auth_key = auth_key
async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tuple[str | None, list[MediaItem]]:
"""Fetch a shared album in full. Returns (title, items).
The HTML page is fetched once - just to recover the album/auth keys and
title. All actual photo enumeration goes through Google's ``batchexecute``
endpoint, which is the only way to reach photos beyond the first ~300.
"""
keys, title = await _fetch_album_keys(session, share_url, timeout=timeout)
if keys is None:
return None, []
items: list[MediaItem] = []
seen_urls: set[str] = set()
page_id: str | None = None
page_no = 0
while True:
page_no += 1
try:
page_items, page_id = await _fetch_album_page(
session, keys, page_id, timeout=timeout
)
except Exception as err:
_LOGGER.warning(
"Album scrape: page %d batchexecute failed (%s); returning %d items so far",
page_no, err, len(items),
)
break
added = 0
for it in page_items:
if it.url in seen_urls:
continue
seen_urls.add(it.url)
items.append(it)
added += 1
if len(items) >= _MAX_ITEMS:
break
_LOGGER.debug(
"Album scrape: page %d returned %d items (%d new), running total %d",
page_no, len(page_items), added, len(items),
)
if not page_id or len(items) >= _MAX_ITEMS or added == 0:
break
_LOGGER.info(
"Album scraper: batchexecute fetched %d photos in %d page(s)",
len(items), page_no,
)
return title, items
# -- Internals ---------------------------------------------------------------
async def _fetch_album_keys(
session, share_url: str, *, timeout: float
) -> tuple[_AlbumKeys | None, str | None]:
"""Fetch the share URL HTML and extract the album/auth keys + title."""
headers = {
"User-Agent": _BROWSER_UA,
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
}
try:
async with session.get(
share_url, headers=headers, timeout=timeout, allow_redirects=True
) as resp:
resp.raise_for_status()
ct = resp.headers.get("Content-Type", "").lower()
if "html" not in ct and "text" not in ct:
_LOGGER.debug("Album scrape: unexpected content-type %r", ct)
return None, None
html = await resp.text()
except Exception as err:
_LOGGER.debug("Album scrape: failed to fetch %s: %s", share_url, err)
return None, None
keys = _extract_keys(html)
if keys is None:
_LOGGER.debug("Album scrape: could not locate album keys in HTML")
return None, None
return keys, _extract_title(html)
def _extract_keys(html: str) -> _AlbumKeys | None:
m = _REQUEST_RE.search(html)
if not m:
return None
return _AlbumKeys(album_key=m.group(1), auth_key=m.group(2))
def _extract_title(html: str) -> str | None:
m = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
if not m:
return None
title = m.group(1).strip()
suffix = " - Google Photos"
if title.endswith(suffix):
title = title[: -len(suffix)].strip()
return title or None
def _extract_first_page_items(html: str) -> list[MediaItem]:
"""Pull the first 300 items out of the AF_initDataCallback blocks.
Returns an empty list if the embedded data can't be parsed - the caller
will still get the rest via batchexecute pagination.
"""
candidates: list[list[Any]] = []
for blob in _iter_af_data_blobs(html):
try:
tree = json.loads(blob)
except json.JSONDecodeError:
continue
candidates.extend(_collect_album_item_lists(tree))
if not candidates:
return []
best = max(candidates, key=len)
items: list[MediaItem] = []
seen: set[str] = set()
for raw in best:
item = _parse_album_item(raw)
if item is None or item.url in seen:
continue
seen.add(item.url)
items.append(item)
return items
def _next_page_token_for_first_page(
first_items: list[MediaItem], first_page_size: int
) -> str | None:
"""The first page's nextPageId isn't easy to find in the HTML AF blob.
Strategy: if the first page is exactly the standard page size we use a
sentinel empty token so the caller fetches page 2 with ``pageId=None``.
The batchexecute endpoint, given ``pageId=None``, returns page 1 again
along with the real continuation token, which we then use for the rest.
Slightly wasteful (we re-fetch page 1) but robust to layout changes.
"""
if first_page_size >= _PAGE_SIZE:
return "" # sentinel: drives the first batchexecute call
return None
async def _fetch_album_page(
session,
keys: _AlbumKeys,
page_id: str | None,
*,
timeout: float,
) -> tuple[list[MediaItem], str | None]:
"""Call snAcKc once. ``page_id=""`` is treated as ``None`` (initial fetch)."""
pid = page_id or None
inner = json.dumps([keys.album_key, pid, None, keys.auth_key])
envelope = json.dumps([[["snAcKc", inner, None, "generic"]]])
form = f"f.req={quote(envelope)}"
url = (
"https://photos.google.com/u/0/_/PhotosUi/data/batchexecute"
f"?rpcids=snAcKc&source-path=/share/{quote(keys.album_key)}"
)
headers = {
"User-Agent": _BROWSER_UA,
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"Accept": "*/*",
"Origin": "https://photos.google.com",
"Referer": f"https://photos.google.com/share/{keys.album_key}?key={keys.auth_key}",
}
async with session.post(url, data=form, headers=headers, timeout=timeout) as resp:
resp.raise_for_status()
body = await resp.text()
return _parse_batchexecute_album_page(body)
def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | None]:
"""Parse a batchexecute response for one snAcKc call.
Format (per line, after the XSSI prefix):
[["wrb.fr", "snAcKc", "<json-encoded inner>", null, null, "generic"], ...]
The inner data shape (per gptk-toolkit):
data[1] = list of album items
data[2] = nextPageId (str) or null
"""
text = body.lstrip()
if text.startswith(_XSSI):
text = text[len(_XSSI):]
text = text.lstrip()
line = text.split("\n", 1)[0].strip()
if not line:
return [], None
try:
outer = json.loads(line)
except json.JSONDecodeError:
return [], None
inner_json: str | None = None
for entry in outer:
if isinstance(entry, list) and len(entry) >= 3 and entry[0] == "wrb.fr":
inner_json = entry[2]
break
if not isinstance(inner_json, str):
return [], None
try:
inner = json.loads(inner_json)
except json.JSONDecodeError:
return [], None
raw_items = inner[1] if len(inner) > 1 and isinstance(inner[1], list) else []
next_page = inner[2] if len(inner) > 2 and isinstance(inner[2], str) else None
if next_page == "":
next_page = None
items: list[MediaItem] = []
for raw in raw_items:
item = _parse_album_item(raw)
if item is not None:
items.append(item)
return items, next_page
def _parse_album_item(raw: Any) -> MediaItem | None:
"""Parse a single album item array.
Layout used in both AF blocks and snAcKc responses:
[mediaKey, [url, w, h, ..., [byte_size]], captured_ms, dedupKey,
timezoneOffsetMin, uploaded_ms, ..., {<numeric_keys>: ...}]
"""
if not isinstance(raw, list) or len(raw) < 2:
return None
visual = raw[1]
if not isinstance(visual, list) or len(visual) < 3:
return None
url = visual[0]
if not isinstance(url, str) or not url.startswith("http"):
return None
width = visual[1] if isinstance(visual[1], int) else None
height = visual[2] if isinstance(visual[2], int) else None
# Skip videos: their last element is a dict with key 76647426 (duration).
if raw and isinstance(raw[-1], dict):
if _VIDEO_DURATION_KEY in raw[-1] or "76647426" in raw[-1]:
# Note: live photos also carry a duration but are still images;
# we treat the presence of duration as "video". If a user reports
# missing live photos we can revisit by checking 146008172 too.
return None
captured_at = raw[2] if len(raw) > 2 and _looks_like_timestamp_ms(raw[2]) else None
uploaded_at = raw[5] if len(raw) > 5 and _looks_like_timestamp_ms(raw[5]) else None
# File size, when present, lives at visual[-1][0] as a single int.
byte_size: int | None = None
if visual and isinstance(visual[-1], list) and visual[-1]:
candidate = visual[-1][0]
if isinstance(candidate, int) and candidate > 0:
byte_size = candidate
return MediaItem(
url=_normalise_size(url, width, height),
width=width,
height=height,
mime_type=None,
filename=None,
captured_at=captured_at,
uploaded_at=uploaded_at,
byte_size=byte_size,
)
# Plausible epoch-ms range: 2000-01-01 to 2100-01-01.
_MIN_TS_MS = 946_684_800_000
_MAX_TS_MS = 4_102_444_800_000
def _looks_like_timestamp_ms(value: Any) -> bool:
return isinstance(value, int) and _MIN_TS_MS <= value <= _MAX_TS_MS
# -- AF block parsing (for the initial 300 items embedded in the HTML) ------
_AF_BLOCK_RE = re.compile(r"AF_initDataCallback\s*\(\s*\{", re.DOTALL)
_DATA_KEY_RE = re.compile(r"[\s,{]data\s*:\s*\[", re.DOTALL)
def _iter_af_data_blobs(html: str):
"""Yield the JSON text of every AF_initDataCallback ``data:`` value."""
for m in _AF_BLOCK_RE.finditer(html):
block_end = _balanced_close(html, m.end() - 1, "{", "}")
if block_end is None:
continue
block = html[m.end() - 1: block_end + 1]
data_match = _DATA_KEY_RE.search(block)
if data_match is None:
continue
open_pos = data_match.end() - 1
close_pos = _balanced_close(block, open_pos, "[", "]")
if close_pos is None:
continue
yield block[open_pos: close_pos + 1]
def _balanced_close(s: str, open_idx: int, open_char: str, close_char: str) -> int | None:
if open_idx >= len(s) or s[open_idx] != open_char:
return None
depth = 0
in_string: str | None = None
i = open_idx
n = len(s)
while i < n:
c = s[i]
if in_string is not None:
if c == "\\":
i += 2
continue
if c == in_string:
in_string = None
i += 1
continue
if c in ("'", '"'):
in_string = c
i += 1
continue
if c == open_char:
depth += 1
elif c == close_char:
depth -= 1
if depth == 0:
return i
i += 1
return None
def _collect_album_item_lists(node: Any, _out: list[list[Any]] | None = None) -> list[list[Any]]:
"""Walk the AF data tree, collecting lists of album-item-shaped entries."""
out = _out if _out is not None else []
if isinstance(node, list):
if _list_looks_like_album_items(node):
out.append(node)
for child in node:
_collect_album_item_lists(child, out)
elif isinstance(node, dict):
for child in node.values():
_collect_album_item_lists(child, out)
return out
def _list_looks_like_album_items(lst: list[Any]) -> bool:
if not lst:
return False
sample = lst[:20]
for item in sample:
if _parse_album_item(item) is None:
return False
return True
# -- URL normalisation -------------------------------------------------------
def _normalise_size(url: str, width: int | None, height: int | None) -> str:
"""Strip any existing size suffix and request a 4K-capped version."""
base = _SIZE_SUFFIX_RE.sub("", url)
if width and height:
try:
w = int(width)
h = int(height)
except (TypeError, ValueError):
return f"{base}=w1920-h1080"
longest = max(w, h)
if longest > 3840:
scale = 3840 / longest
w = max(1, int(round(w * scale)))
h = max(1, int(round(h * scale)))
return f"{base}=w{w}-h{h}"
return f"{base}=w1920-h1080"
# Backwards-compatible names so the existing tests still find the helpers.
def parse_album_html(html: str) -> list[MediaItem]:
"""Parse only the first-page items embedded in the HTML.
Retained for backwards compatibility with the 0.5.0-rc1 surface; new
callers should use ``fetch_album`` for the full paginated result.
"""
return _extract_first_page_items(html)
_PHOTO_HOST_RE = re.compile(
r"^https?://(?:[a-z0-9-]+\.)?(?:googleusercontent\.com|google\.com)/",
re.IGNORECASE,
)
def _is_dimension(v: Any) -> bool:
return isinstance(v, int) and 16 <= v <= 20_000
@@ -0,0 +1,277 @@
from __future__ import annotations
import io
import logging
from PIL import Image, ImageColor, ImageFilter, ImageOps
from .coordinator import MediaItem
_LOGGER = logging.getLogger(__name__)
# Re-export fill mode constants so callers can import from here.
FILL_COVER = "cover"
FILL_CONTAIN = "contain"
FILL_BLUR = "blur"
# Absolute pixel ceiling. A 20000x20000 JPEG decodes to ~1.2 GB of RGB; Pillow
# raises DecompressionBombError above MAX_IMAGE_PIXELS. We set this high enough
# that 4K+ sources still decode, but reject anything absurd to protect
# low-memory devices like the Home Assistant Green.
_MAX_IMAGE_PIXELS = 80_000_000 # ~8K x 10K
Image.MAX_IMAGE_PIXELS = _MAX_IMAGE_PIXELS
def open_image(
data: bytes,
target_size: tuple[int, int] | None = None,
) -> Image.Image:
"""Open image bytes, apply EXIF orientation, normalise to RGB/RGBA.
If ``target_size`` is given, uses PIL's ``draft`` mode so libjpeg decodes
at a reduced scale. Big speed/memory win on low-power devices when the
source is much larger than the output canvas.
"""
img = Image.open(io.BytesIO(data))
if target_size is not None and img.format == "JPEG":
try:
img.draft("RGB", target_size)
except Exception:
pass
img = ImageOps.exif_transpose(img)
# Force pixel data into memory; BytesIO must stay reachable until here.
img.load()
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
return img
def safe_close(img: Image.Image | None) -> None:
"""Close a PIL image without raising. No-op on None."""
if img is None:
return
try:
img.close()
except Exception:
pass
def is_portrait_img(img: Image.Image) -> bool:
try:
w, h = img.size
return h >= w
except Exception:
return False
def is_portrait_item(item: MediaItem, img: Image.Image | None = None) -> bool:
by_meta = _is_portrait_dims(item.width, item.height)
if by_meta is not None:
return by_meta
if img is not None:
return is_portrait_img(img)
return False
def is_portrait_item_by_metadata(item: MediaItem) -> bool | None:
"""Return portrait/landscape from item metadata only, or None if unknown."""
return _is_portrait_dims(item.width, item.height)
def resolve_output_size(
req_w: int | None,
req_h: int | None,
ratio: str,
max_short_edge: int | None = None,
) -> tuple[int, int]:
ratio_w, ratio_h = _parse_aspect_ratio(ratio)
target = ratio_w / ratio_h
if req_w is None and req_h is None:
if ratio_w >= ratio_h:
width = 3840
height = max(1, int(round(width / target)))
else:
height = 3840
width = max(1, int(round(height * target)))
elif req_w is None:
height = max(1, int(req_h or 2160))
width = max(1, int(round(height * target)))
elif req_h is None:
width = max(1, int(req_w or 3840))
height = max(1, int(round(width / target)))
else:
req_w = max(1, int(req_w))
req_h = max(1, int(req_h))
if (req_w / req_h) >= target:
height = req_h
width = max(1, int(round(height * target)))
else:
width = req_w
height = max(1, int(round(width / target)))
if max_short_edge is not None:
short = min(width, height)
if short > max_short_edge:
scale = max_short_edge / short
width = max(1, int(round(width * scale)))
height = max(1, int(round(height * scale)))
return (width, height)
def render_image(img: Image.Image, fill_mode: str, width: int, height: int) -> Image.Image:
"""Render img into a (width x height) canvas using the given fill mode."""
if fill_mode == FILL_CONTAIN:
return _resize_contain(img, width, height)
if fill_mode == FILL_BLUR:
return _blur_fill(img, width, height)
return _resize_cover(img, width, height)
def pair_images(
img1: Image.Image,
img2: Image.Image,
target_w: int,
target_h: int,
fill_mode: str,
portrait_canvas: bool,
divider: int,
divider_fill: tuple[int, int, int] | tuple[int, int, int, int],
transparent_divider: bool,
) -> Image.Image:
canvas_mode = "RGBA" if transparent_divider else "RGB"
canvas = Image.new(canvas_mode, (target_w, target_h), divider_fill)
if portrait_canvas:
top_h = max(1, (target_h - divider) // 2)
bottom_h = max(1, target_h - divider - top_h)
top_img = render_image(img1, fill_mode, target_w, top_h)
bottom_img = render_image(img2, fill_mode, target_w, bottom_h)
canvas.paste(top_img.convert(canvas_mode), (0, 0))
canvas.paste(bottom_img.convert(canvas_mode), (0, top_h + divider))
safe_close(top_img)
safe_close(bottom_img)
return canvas
left_w = max(1, (target_w - divider) // 2)
right_w = max(1, target_w - divider - left_w)
left_img = render_image(img1, fill_mode, left_w, target_h)
right_img = render_image(img2, fill_mode, right_w, target_h)
canvas.paste(left_img.convert(canvas_mode), (0, 0))
canvas.paste(right_img.convert(canvas_mode), (left_w + divider, 0))
safe_close(left_img)
safe_close(right_img)
return canvas
def encode_image(img: Image.Image) -> bytes:
"""Encode a PIL image to a client-compatible JPEG or PNG.
JPEGs are written as baseline (non-progressive) with 4:2:0 subsampling and
without EXIF, which maximises compatibility with Android WebView and older
clients. RGBA images are encoded as PNG to preserve alpha.
"""
out = io.BytesIO()
if "A" in img.getbands():
img.save(out, format="PNG", optimize=True)
return out.getvalue()
rgb = img if img.mode == "RGB" else img.convert("RGB")
rgb.save(
out,
format="JPEG",
quality=88,
optimize=True,
progressive=False,
subsampling=2,
)
if rgb is not img:
safe_close(rgb)
return out.getvalue()
def parse_divider_color(color: str) -> tuple[tuple[int, int, int] | tuple[int, int, int, int], bool]:
raw = (color or "").strip().lower()
compact = raw.replace(" ", "")
if compact in ("transparent", "transperant", "none", "clear", "rgba(0,0,0,0)"):
return (0, 0, 0, 0), True
try:
return ImageColor.getrgb(color), False
except Exception:
return (255, 255, 255), False
# -- Private helpers ---------------------------------------------------------
def _is_portrait_dims(width: int | None, height: int | None) -> bool | None:
if not width or not height:
return None
try:
w, h = int(width), int(height)
if w <= 0 or h <= 0:
return None
return h >= w
except Exception:
return None
def _parse_aspect_ratio(ratio: str) -> tuple[int, int]:
try:
left, right = ratio.split(":", maxsplit=1)
w, h = int(left), int(right)
if w > 0 and h > 0:
return (w, h)
except Exception:
pass
return (16, 9)
def _resize_cover(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
src_w, src_h = img.size
if src_w <= 0 or src_h <= 0:
return img.resize((target_w, target_h))
scale = max(target_w / src_w, target_h / src_h)
new_w = max(1, int(round(src_w * scale)))
new_h = max(1, int(round(src_h * scale)))
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
left = max(0, int(round((new_w - target_w) / 2)))
top = max(0, int(round((new_h - target_h) / 2)))
cropped = resized.crop((left, top, left + target_w, top + target_h))
if cropped is not resized:
safe_close(resized)
return cropped
def _resize_contain(img: Image.Image, target_w: int, target_h: int, bg=(0, 0, 0)) -> Image.Image:
src_w, src_h = img.size
if src_w <= 0 or src_h <= 0:
return img.resize((target_w, target_h))
scale = min(target_w / src_w, target_h / src_h)
new_w = max(1, int(src_w * scale))
new_h = max(1, int(src_h * scale))
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
canvas = Image.new("RGB", (target_w, target_h), bg)
rgb_resized = resized if resized.mode == "RGB" else resized.convert("RGB")
canvas.paste(rgb_resized, ((target_w - new_w) // 2, (target_h - new_h) // 2))
if rgb_resized is not resized:
safe_close(rgb_resized)
safe_close(resized)
return canvas
def _blur_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
bg = _resize_cover(img, target_w, target_h).filter(ImageFilter.GaussianBlur(radius=24))
src_w, src_h = img.size
if src_w <= 0 or src_h <= 0:
return bg
scale = min(target_w / src_w, target_h / src_h)
new_w = max(1, int(src_w * scale))
new_h = max(1, int(src_h * scale))
fg = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
rgb_fg = fg if fg.mode == "RGB" else fg.convert("RGB")
bg.paste(rgb_fg, ((target_w - new_w) // 2, (target_h - new_h) // 2))
if rgb_fg is not fg:
safe_close(rgb_fg)
safe_close(fg)
return bg
@@ -0,0 +1,12 @@
{
"domain": "album_slideshow",
"name": "Album Slideshow Camera",
"codeowners": ["@eyalgal"],
"config_flow": true,
"dependencies": ["http", "frontend"],
"documentation": "https://github.com/eyalgal/album_slideshow",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
"version": "1.0.0"
}
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN
from .coordinator import AlbumCoordinator
from .store import SlideshowStore
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
async_add_entities(
[
SlideIntervalNumber(entry, store),
RefreshHoursNumber(entry, store, coordinator),
PairDividerWidthNumber(entry, store),
ImageCacheMbNumber(entry, store),
]
)
class _BaseNumber(NumberEntity, RestoreEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_mode = NumberMode.BOX
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
self.entry = entry
self.store = store
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.store.add_listener(self.async_write_ha_state)
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
class SlideIntervalNumber(_BaseNumber):
_attr_icon = "mdi:timer-outline"
_attr_native_min_value = 3
_attr_native_max_value = 3600
_attr_native_step = 1
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_interval"
self._attr_name = "Slide interval (seconds)"
@property
def native_value(self):
return int(self.store.slide_interval)
async def async_set_native_value(self, value: float) -> None:
self.store.slide_interval = int(value)
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
try:
self.store.slide_interval = int(float(old.state))
self.store.notify()
except Exception:
return
class RefreshHoursNumber(_BaseNumber):
_attr_icon = "mdi:refresh"
_attr_native_min_value = 1
_attr_native_max_value = 168
_attr_native_step = 1
def __init__(self, entry: ConfigEntry, store: SlideshowStore, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, store)
self.coordinator = coordinator
self._attr_unique_id = f"{entry.entry_id}_refresh_minutes"
self._attr_name = "Album refresh (hours)"
@property
def native_value(self):
return int(self.store.refresh_hours)
async def async_set_native_value(self, value: float) -> None:
self.store.refresh_hours = int(value)
self.store.notify()
await self.coordinator.async_request_refresh()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
try:
val = int(float(old.state))
# Migration: old values were in minutes; convert if above max hours
if val > self._attr_native_max_value:
val = max(int(val / 60), int(self._attr_native_min_value))
self.store.refresh_hours = val
self.store.notify()
except Exception:
return
class PairDividerWidthNumber(_BaseNumber):
_attr_icon = "mdi:border-vertical"
_attr_native_min_value = 0
_attr_native_max_value = 64
_attr_native_step = 1
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_pair_divider_px"
self._attr_name = "Pair divider size (px)"
@property
def native_value(self):
return int(self.store.pair_divider_px)
async def async_set_native_value(self, value: float) -> None:
self.store.pair_divider_px = max(0, int(value))
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
try:
self.store.pair_divider_px = max(0, int(float(old.state)))
self.store.notify()
except Exception:
return
class ImageCacheMbNumber(_BaseNumber):
_attr_icon = "mdi:database-outline"
_attr_native_min_value = 50
_attr_native_max_value = 1000
_attr_native_step = 50
_attr_native_unit_of_measurement = "MB"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_image_cache_mb"
self._attr_name = "Image cache size (MB)"
@property
def native_value(self):
return int(self.store.image_cache_mb)
async def async_set_native_value(self, value: float) -> None:
self.store.image_cache_mb = max(50, int(value))
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
try:
self.store.image_cache_mb = max(50, int(float(old.state)))
self.store.notify()
except Exception:
return
@@ -0,0 +1,138 @@
"""Playlist construction: ordering and date filtering.
Pure, dependency-free helpers so the camera and tests can share a single
implementation. Operates on ``MediaItem``-like objects that expose
``captured_at`` / ``uploaded_at`` (epoch ms or ``None``).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Iterable, TypeVar
from .const import (
DATE_FILTER_LAST_7,
DATE_FILTER_LAST_30,
DATE_FILTER_LAST_365,
DATE_FILTER_OFF,
DATE_FILTER_ON_THIS_DAY,
DATE_FILTER_THIS_MONTH,
DATE_FILTER_THIS_YEAR,
ORDER_ALBUM,
ORDER_NEWEST_ADDED,
ORDER_NEWEST_TAKEN,
ORDER_OLDEST_ADDED,
ORDER_OLDEST_TAKEN,
ORDER_RANDOM,
)
T = TypeVar("T")
def order_items(items: list[T], order_mode: str) -> list[T]:
"""Return a new list ordered per ``order_mode``.
``random`` and ``album_order`` are no-ops here - random shuffling lives
in the camera so it can dedupe recent slides; ``album_order`` keeps the
source order untouched. The taken/added orderings are stable; items
without the required timestamp keep their relative position at the end.
"""
if order_mode == ORDER_RANDOM or order_mode == ORDER_ALBUM:
return list(items)
key_attr, reverse = _order_key(order_mode)
if key_attr is None:
return list(items)
with_ts: list[tuple[int, int, T]] = []
without_ts: list[tuple[int, T]] = []
for idx, it in enumerate(items):
ts = getattr(it, key_attr, None)
if isinstance(ts, int):
with_ts.append((ts, idx, it))
else:
without_ts.append((idx, it))
with_ts.sort(key=lambda t: (t[0], t[1]), reverse=reverse)
return [it for _, _, it in with_ts] + [it for _, it in without_ts]
def _order_key(order_mode: str) -> tuple[str | None, bool]:
if order_mode == ORDER_NEWEST_TAKEN:
return "captured_at", True
if order_mode == ORDER_OLDEST_TAKEN:
return "captured_at", False
if order_mode == ORDER_NEWEST_ADDED:
return "uploaded_at", True
if order_mode == ORDER_OLDEST_ADDED:
return "uploaded_at", False
return None, False
def filter_items(
items: Iterable[T],
*,
mode: str,
now: datetime | None = None,
) -> list[T]:
"""Filter items by ``captured_at`` according to ``mode``.
Items with no ``captured_at`` are kept by default unless the mode is
``on_this_day`` (treated as a strict filter).
``now`` is overridable for deterministic tests.
"""
if not mode or mode == DATE_FILTER_OFF:
return list(items)
today_utc = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
pred, strict = _build_predicate(mode, today_utc)
if pred is None:
return list(items)
out: list[T] = []
for it in items:
ts = getattr(it, "captured_at", None)
if not isinstance(ts, int):
if not strict:
out.append(it)
continue
if pred(ts):
out.append(it)
return out
def _build_predicate(
mode: str,
today_utc: datetime,
):
"""Return (predicate, strict). ``strict`` drops items without timestamps."""
if mode == DATE_FILTER_LAST_7:
cutoff = int((today_utc - timedelta(days=7)).timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
if mode == DATE_FILTER_LAST_30:
cutoff = int((today_utc - timedelta(days=30)).timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
if mode == DATE_FILTER_LAST_365:
cutoff = int((today_utc - timedelta(days=365)).timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
if mode == DATE_FILTER_THIS_MONTH:
start = today_utc.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
cutoff = int(start.timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
if mode == DATE_FILTER_THIS_YEAR:
start = today_utc.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
cutoff = int(start.timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
if mode == DATE_FILTER_ON_THIS_DAY:
# Match items whose UTC month+day equals today's. Useful for daily
# "memories"-style rotation across all years.
today_md = (today_utc.month, today_utc.day)
def _on_this_day(ts: int) -> bool:
d = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
return (d.month, d.day) == today_md
return _on_this_day, True
return None, False
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
DOMAIN,
FILL_COVER,
FILL_CONTAIN,
FILL_BLUR,
MAX_RESOLUTION_OPTIONS,
ORIENTATION_MISMATCH_PAIR,
ORIENTATION_MISMATCH_SINGLE,
ORIENTATION_MISMATCH_AVOID,
ORDER_OPTIONS,
ORDER_RANDOM,
DATE_FILTER_OPTIONS,
DATE_FILTER_OFF,
)
from .store import SlideshowStore
ASPECT_RATIO_OPTIONS = ["16:9", "16:10", "4:3", "1:1", "3:4", "10:16", "9:16"]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
async_add_entities(
[
FillModeSelect(entry, store),
PortraitModeSelect(entry, store),
OrderModeSelect(entry, store),
AspectRatioSelect(entry, store),
MaxResolutionSelect(entry, store),
DateFilterSelect(entry, store),
]
)
class _BaseSelect(SelectEntity, RestoreEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_entity_registry_enabled_default = True
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
self.entry = entry
self.store = store
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.store.add_listener(self.async_write_ha_state)
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
class FillModeSelect(_BaseSelect):
_attr_icon = "mdi:aspect-ratio"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_fill_mode"
self._attr_name = "Fill mode"
self._attr_options = [FILL_BLUR, FILL_COVER, FILL_CONTAIN]
@property
def current_option(self):
value = self.store.fill_mode
return value if value in self.options else self.options[0]
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.fill_mode = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.fill_mode = old.state
self.store.notify()
class PortraitModeSelect(_BaseSelect):
_attr_icon = "mdi:account-box-outline"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_portrait_mode"
self._attr_name = "Orientation mismatch mode"
self._attr_options = [
ORIENTATION_MISMATCH_PAIR,
ORIENTATION_MISMATCH_SINGLE,
ORIENTATION_MISMATCH_AVOID,
]
@property
def current_option(self):
value = self.store.portrait_mode
return value if value in self.options else self.options[0]
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.portrait_mode = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old:
restored = old.state
if restored in ("blur", "crop"):
restored = ORIENTATION_MISMATCH_SINGLE
if restored in self.options:
self.store.portrait_mode = restored
self.store.notify()
class OrderModeSelect(_BaseSelect):
_attr_icon = "mdi:shuffle-variant"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_order_mode"
self._attr_name = "Order mode"
self._attr_options = list(ORDER_OPTIONS)
@property
def current_option(self):
value = self.store.order_mode
return value if value in self.options else ORDER_RANDOM
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.order_mode = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.order_mode = old.state
self.store.notify()
class AspectRatioSelect(_BaseSelect):
_attr_icon = "mdi:crop"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_aspect_ratio"
self._attr_name = "Aspect ratio"
self._attr_options = ASPECT_RATIO_OPTIONS
@property
def current_option(self):
value = self.store.aspect_ratio
return value if value in self.options else self.options[0]
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.aspect_ratio = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.aspect_ratio = old.state
self.store.notify()
class MaxResolutionSelect(_BaseSelect):
_attr_icon = "mdi:image-size-select-large"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_max_resolution"
self._attr_name = "Max resolution"
self._attr_options = MAX_RESOLUTION_OPTIONS
@property
def current_option(self):
value = self.store.max_resolution
return value if value in self.options else self.options[0]
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.max_resolution = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.max_resolution = old.state
self.store.notify()
class DateFilterSelect(_BaseSelect):
_attr_icon = "mdi:calendar-filter"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_date_filter"
self._attr_name = "Date filter"
self._attr_options = list(DATE_FILTER_OPTIONS)
@property
def current_option(self):
value = self.store.date_filter
return value if value in self.options else DATE_FILTER_OFF
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.date_filter = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.date_filter = old.state
self.store.notify()
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, PROVIDER_GOOGLE_SHARED, PROVIDER_LOCAL_FOLDER
from .coordinator import AlbumCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
entities: list[SensorEntity] = [
AlbumCountSensor(entry, coordinator),
AlbumTitleSensor(entry, coordinator),
CacheUsageSensor(entry, coordinator),
]
if coordinator.provider == PROVIDER_LOCAL_FOLDER:
# Diagnostic surface for the local-folder background enrichment
# (EXIF reads + reverse-geocode). For Google albums there's no
# enrichment work, so this sensor is omitted to keep the device
# screen tidy.
entities.append(EnrichmentProgressSensor(entry, coordinator))
async_add_entities(entities)
class _BaseAlbumSensor(SensorEntity):
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
self.entry = entry
self.coordinator = coordinator
coordinator.async_add_listener(self.async_write_ha_state)
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
def _provider_icon(self) -> str:
if self.coordinator.provider == PROVIDER_GOOGLE_SHARED:
return "mdi:google-photos"
return "mdi:folder-multiple-image"
class AlbumCountSensor(_BaseAlbumSensor):
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_count"
self._attr_name = "Photo count"
@property
def icon(self) -> str:
return self._provider_icon()
@property
def native_value(self):
data = self.coordinator.data or {}
return len(data.get("items", []))
class AlbumTitleSensor(_BaseAlbumSensor):
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_title"
self._attr_name = "Album title"
@property
def icon(self) -> str:
return self._provider_icon()
@property
def native_value(self):
data = self.coordinator.data or {}
return data.get("title")
class CacheUsageSensor(_BaseAlbumSensor):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = "MB"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:database"
_attr_should_poll = True
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_cache_usage"
self._attr_name = "Image cache usage"
@property
def native_value(self):
cam = self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id, {}).get("camera")
if cam is None:
return None
return cam.cache_usage_mb
class EnrichmentProgressSensor(_BaseAlbumSensor):
"""Percent-complete sensor for the local-folder enrichment worker.
Reports the slower-changing of the two phases:
- ``exif``: reading capture date + GPS from EXIF tags.
- ``geocoding``: reverse-geocoding GPS to a city/country label.
Holds at ``100`` when both phases finish and stays there until the
next coordinator refresh discovers new files. The ``phase`` and
raw counts are surfaced as state attributes so dashboards can show
progress text alongside the bar.
"""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = "%"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:map-marker-radius"
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_enrichment_progress"
self._attr_name = "Enrichment progress"
@property
def native_value(self):
prog = getattr(self.coordinator, "_enrich_progress", None) or {}
phase = prog.get("phase")
if phase == "geocoding":
total = prog.get("geocode_total") or 0
done = prog.get("geocode_done") or 0
else:
total = prog.get("exif_total") or 0
done = prog.get("exif_done") or 0
if total <= 0:
return None
return max(0, min(100, round(100 * done / total)))
@property
def extra_state_attributes(self):
prog = getattr(self.coordinator, "_enrich_progress", None) or {}
return {
"phase": prog.get("phase"),
"exif_total": prog.get("exif_total", 0),
"exif_done": prog.get("exif_done", 0),
"geocode_total": prog.get("geocode_total", 0),
"geocode_done": prog.get("geocode_done", 0),
}
@@ -0,0 +1,21 @@
next_slide:
name: Next slide
description: Advance the slideshow to the next image for a specific config entry.
fields:
entry_id:
name: Entry ID
description: The config entry id for the album slideshow instance.
required: true
selector:
text:
refresh_album:
name: Refresh album
description: Refresh the album list for a specific config entry.
fields:
entry_id:
name: Entry ID
description: The config entry id for the album slideshow instance.
required: true
selector:
text:
@@ -0,0 +1,55 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
from .const import (
DEFAULT_SLIDE_INTERVAL,
DEFAULT_REFRESH_HOURS,
DEFAULT_FILL_MODE,
DEFAULT_ORIENTATION_MISMATCH_MODE,
DEFAULT_ORDER_MODE,
DEFAULT_ASPECT_RATIO,
DEFAULT_PAIR_DIVIDER_PX,
DEFAULT_PAIR_DIVIDER_COLOR,
DEFAULT_IMAGE_CACHE_MB,
DEFAULT_MAX_RESOLUTION,
DEFAULT_DATE_FILTER,
)
Listener = Callable[[], None]
@dataclass
class SlideshowStore:
slide_interval: int = DEFAULT_SLIDE_INTERVAL
refresh_hours: int = DEFAULT_REFRESH_HOURS
fill_mode: str = DEFAULT_FILL_MODE
portrait_mode: str = DEFAULT_ORIENTATION_MISMATCH_MODE
order_mode: str = DEFAULT_ORDER_MODE
aspect_ratio: str = DEFAULT_ASPECT_RATIO
pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX
pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
max_resolution: str = DEFAULT_MAX_RESOLUTION
# Date filter mode (preset windows like this_year / on_this_day).
date_filter: str = DEFAULT_DATE_FILTER
# Pause toggle - when True, the slideshow holds on the current frame.
paused: bool = False
# In-memory last rendered frame. Not user-configurable; used to re-serve
# the previous slide instantly across a camera reload.
last_frame: bytes | None = None
_listeners: list[Listener] = field(default_factory=list)
def add_listener(self, cb: Listener) -> None:
if cb not in self._listeners:
self._listeners.append(cb)
def notify(self) -> None:
for cb in list(self._listeners):
cb()
@@ -0,0 +1,45 @@
{
"config": {
"step": {
"user": {
"title": "Add album slideshow",
"description": "Choose a source for the slideshow.",
"data": {
"provider": "Source"
}
},
"google_shared": {
"title": "Google Photos",
"description": "Paste a Google Photos shared album share link.",
"data": {
"album_name": "Album name",
"album_url": "Shared album link"
}
},
"local_folder": {
"title": "Local Folder",
"description": "Pick a folder path on your Home Assistant filesystem. Use /local/... (maps to /config/www/...) or /media/local/... for NAS-mounted media folders. See the README for details.",
"data": {
"album_name": "Album name",
"local_path": "Folder path",
"recursive": "Include subfolders"
}
}
},
"error": {
"invalid_album_url": "That does not look like a Google Photos shared album link.",
"invalid_path": "Path is empty or invalid."
}
},
"options": {
"step": {
"init": {
"title": "Local Folder options",
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
"data": {
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
}
}
}
}
}
@@ -0,0 +1,55 @@
from __future__ import annotations
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN
from .store import SlideshowStore
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
async_add_entities([PauseSwitch(entry, store)])
class PauseSwitch(SwitchEntity, RestoreEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_icon = "mdi:pause"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
self.entry = entry
self.store = store
self._attr_unique_id = f"{entry.entry_id}_paused"
self._attr_name = "Pause slideshow"
@property
def is_on(self) -> bool:
return bool(self.store.paused)
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
async def async_turn_on(self, **kwargs) -> None:
self.store.paused = True
self.store.notify()
async def async_turn_off(self, **kwargs) -> None:
self.store.paused = False
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.store.add_listener(self.async_write_ha_state)
old = await self.async_get_last_state()
if old is not None and old.state in ("on", "off"):
self.store.paused = old.state == "on"
self.store.notify()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from homeassistant.components.text import TextEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN, DEFAULT_PAIR_DIVIDER_COLOR
from .store import SlideshowStore
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
async_add_entities([PairDividerColorText(entry, store)])
class PairDividerColorText(TextEntity, RestoreEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_icon = "mdi:palette"
_attr_native_min = 1
_attr_native_max = 32
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
self.entry = entry
self.store = store
self._attr_unique_id = f"{entry.entry_id}_pair_divider_color"
self._attr_name = "Pair divider color"
def _on_store_change() -> None:
self.async_write_ha_state()
store.add_listener(_on_store_change)
@property
def native_value(self) -> str:
return self.store.pair_divider_color
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
async def async_set_value(self, value: str) -> None:
val = (value or "").strip()
self.store.pair_divider_color = val or DEFAULT_PAIR_DIVIDER_COLOR
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.store.add_listener(self.async_write_ha_state)
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
self.store.pair_divider_color = old.state.strip() or DEFAULT_PAIR_DIVIDER_COLOR
self.store.notify()
@@ -0,0 +1,45 @@
{
"config": {
"step": {
"user": {
"title": "Add album slideshow",
"description": "Choose a source for the slideshow.",
"data": {
"provider": "Source"
}
},
"google_shared": {
"title": "Google Photos",
"description": "Paste a Google Photos shared album share link.",
"data": {
"album_name": "Album name",
"album_url": "Shared album link"
}
},
"local_folder": {
"title": "Local Folder",
"description": "Pick a folder path on your Home Assistant filesystem. Use /local/... (maps to /config/www/...) or /media/local/... for NAS-mounted media folders. See the README for details.",
"data": {
"album_name": "Album name",
"local_path": "Folder path",
"recursive": "Include subfolders"
}
}
},
"error": {
"invalid_album_url": "That does not look like a Google Photos shared album link.",
"invalid_path": "Path is empty or invalid."
}
},
"options": {
"step": {
"init": {
"title": "Local Folder options",
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
"data": {
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,405 @@
"""The Climate Scheduler integration."""
import logging
import json
import time
from datetime import datetime, timedelta
from pathlib import Path
from homeassistant.config_entries import ConfigEntry
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.components.http import HomeAssistantView, StaticPathConfig
from homeassistant.util import json as json_util
from aiohttp import web
from .const import DOMAIN, UPDATE_INTERVAL_SECONDS
from .coordinator import HeatingSchedulerCoordinator
from .storage import ScheduleStorage
# Expose dynamic service descriptions for Home Assistant UI
from .services import async_get_services # noqa: E402,F401
_LOGGER = logging.getLogger(__name__)
async def _async_setup_common(hass: HomeAssistant) -> None:
"""Common setup for storage, coordinator, services and panel."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
# Initialize storage once
storage: ScheduleStorage | None = hass.data[DOMAIN].get("storage")
if storage is None:
storage = ScheduleStorage(hass)
await storage.async_load()
hass.data[DOMAIN]["storage"] = storage
# Initialize coordinator once
coordinator: HeatingSchedulerCoordinator | None = hass.data[DOMAIN].get("coordinator")
if coordinator is None:
coordinator = HeatingSchedulerCoordinator(
hass,
storage,
timedelta(seconds=UPDATE_INTERVAL_SECONDS)
)
hass.data[DOMAIN]["coordinator"] = coordinator
# Start coordinator updates
_LOGGER.info(f"Starting coordinator with {UPDATE_INTERVAL_SECONDS}s update interval")
await coordinator.async_refresh()
_LOGGER.info("Forcing initial temperature sync for all entities")
await coordinator.force_update_all()
# Schedule periodic updates
async def _scheduled_update(now):
"""Handle scheduled update."""
await coordinator.async_request_refresh()
from homeassistant.helpers.event import async_track_time_interval
async_track_time_interval(
hass,
_scheduled_update,
timedelta(seconds=UPDATE_INTERVAL_SECONDS)
)
# Avoid re-registering services, but be robust across updates/reloads.
# During an in-process reload/upgrade, `hass.data` can persist while new
# services from a newer version are missing. If so, re-register.
if hass.data[DOMAIN].get("services_registered"):
# Keep this list in sync with the services registered in `services.py`.
expected_services = (
"recreate_all_sensors",
"cleanup_malformed_sensors",
"set_schedule",
"get_schedule",
"clear_schedule",
"enable_schedule",
"disable_schedule",
"set_ignored",
"sync_all",
"create_group",
"delete_group",
"rename_group",
"add_to_group",
"remove_from_group",
"get_groups",
"list_groups",
"list_profiles",
"set_group_schedule",
"enable_group",
"disable_group",
"get_settings",
"save_settings",
"reload_integration",
"advance_schedule",
"advance_group",
"cancel_advance",
"get_advance_status",
"clear_advance_history",
"test_fire_event",
"create_profile",
"delete_profile",
"rename_profile",
"set_active_profile",
"get_profiles",
"cleanup_derivative_sensors",
"factory_reset",
"reregister_card",
"diagnostics",
)
missing = [s for s in expected_services if not hass.services.has_service(DOMAIN, s)]
if not missing:
# Services are already registered; still ensure frontend resources
# are registered/updated (important after install/upgrade or when
# Lovelace wasn't ready earlier during startup).
await _register_frontend_resources(hass)
return
_LOGGER.warning(
"Climate Scheduler services flagged as registered but missing %s; re-registering services",
missing,
)
hass.data[DOMAIN]["services_registered"] = False
# Register services from services module
from . import services as service_module
await service_module.async_setup_services(hass)
hass.data[DOMAIN]["services_registered"] = True
# Register frontend card resources
await _register_frontend_resources(hass)
async def _register_frontend_resources(hass: HomeAssistant) -> None:
"""Register the bundled frontend card as a Lovelace resource."""
# Get version from manifest.json first
manifest_path = Path(__file__).parent / "manifest.json"
try:
import json
manifest_text = await hass.async_add_executor_job(manifest_path.read_text)
manifest = json.loads(manifest_text)
frontend_version = manifest.get("version", f"u{int(time.time())}")
_LOGGER.debug("Current integration version: %s", frontend_version)
except Exception as e:
_LOGGER.warning("Failed to read manifest version: %s", e)
frontend_version = f"u{int(time.time())}"
# Always attempt to (re)register frontend resources on startup/update.
# We intentionally do not short-circuit when the stored version matches
# the current one, because we want to remove any existing resource
# entries and recreate them during install/update/reboot.
# Register static path for frontend files
frontend_path = Path(__file__).parent / "frontend"
if not frontend_path.exists():
_LOGGER.warning("Frontend directory not found at %s", frontend_path)
return
# Register the static path using the correct HA API.
# This only needs to happen once per HA runtime; avoid repeated registration
# and noisy logs when config entries reload.
should_cache = False
if not hass.data[DOMAIN].get("frontend_static_registered"):
# Expose at /<domain>/static so integrations can reliably reference it
await hass.http.async_register_static_paths([
StaticPathConfig(f"/{DOMAIN}/static", str(frontend_path), should_cache)
])
hass.data[DOMAIN]["frontend_static_registered"] = True
_LOGGER.info("Registered frontend static path: %s -> %s", f"/{DOMAIN}/static", frontend_path)
else:
_LOGGER.debug("Frontend static path already registered")
# Register Lovelace resource
try:
# Get lovelace resources
lovelace_data = hass.data.get("lovelace")
if lovelace_data is None:
_LOGGER.warning("Lovelace integration not available, card will need manual registration")
hass.data[DOMAIN]["frontend_registered_version"] = frontend_version
return
# Get resources based on HA version
from homeassistant.const import __version__ as ha_version
version_parts = [int(x) for x in ha_version.split(".")[:2]]
version_number = version_parts[0] * 1000 + version_parts[1]
if version_number >= 2025002: # 2025.2.0 and later
resources = lovelace_data.resources
else:
resources = lovelace_data.get("resources")
if resources is None:
_LOGGER.warning("Lovelace resources not available, card will need manual registration")
return
# Check for YAML mode
if not hasattr(resources, "store") or resources.store is None:
_LOGGER.info("Lovelace YAML mode detected, card must be registered manually")
# Check if the card is already registered in YAML
base_url = f"/{DOMAIN}/static/climate-scheduler-card.js"
card_already_registered = False
try:
for entry in resources.async_items():
entry_url = entry.get("url", "")
entry_base_url = entry_url.split("?")[0]
if entry_base_url == base_url:
card_already_registered = True
_LOGGER.info("Card is already registered in YAML configuration")
# Dismiss any existing notification since card is now present
await hass.services.async_call(
"persistent_notification",
"dismiss",
{"notification_id": f"{DOMAIN}_yaml_mode"},
)
break
except Exception as e:
_LOGGER.debug("Could not check YAML resources: %s", e)
# Only create notification if card is NOT registered
if not card_already_registered:
_LOGGER.warning("Card is not registered in YAML configuration - notification will be created")
await hass.services.async_call(
"persistent_notification",
"create",
{
"notification_id": f"{DOMAIN}_yaml_mode",
"title": "Climate Scheduler: Manual Card Registration Required",
"message": (
"Your Lovelace dashboard is configured via YAML mode. "
"The Climate Scheduler card cannot be auto-registered.\n\n"
"**To add the card manually:**\n\n"
"Add this to your Lovelace resources configuration:\n\n"
f"```yaml\n"
f"lovelace:\n"
f" mode: yaml\n"
f" resources:\n"
f" - url: /{DOMAIN}/static/climate-scheduler-card.js?v={frontend_version}\n"
f" type: module\n"
f"```\n\n"
"Then restart Home Assistant and refresh your browser."
),
},
)
return
# Ensure resources are loaded
if not resources.loaded:
await resources.async_load()
# Build URL with version for cache busting
# Use the integration-hosted static path we just registered
base_url = f"/{DOMAIN}/static/climate-scheduler-card.js"
url = f"{base_url}?v={frontend_version}"
# Check for old standalone card installations and remove them
old_card_patterns = [
"/hacsfiles/climate-scheduler/",
"/local/community/climate-scheduler/",
"climate-scheduler-card.js",
f"/{DOMAIN}/static/"
]
existing_entry = None
removed_old = False
for entry in list(resources.async_items()):
entry_url = entry["url"]
entry_base_url = entry_url.split("?")[0]
# Check if this is our new bundled card
if entry_base_url == base_url:
existing_entry = entry
continue
# Check if this is an old standalone card installation
if any(pattern in entry_url for pattern in old_card_patterns):
_LOGGER.info("Removing old standalone card registration: %s", entry_url)
await resources.async_delete_item(entry["id"])
removed_old = True
# If there's an existing bundled registration, remove it first so
# we always create a fresh entry (ensures consistent behavior on
# install/update/reboot and avoids stale IDs or metadata).
if existing_entry:
try:
_LOGGER.info("Removing existing bundled frontend card registration: %s", existing_entry.get("url"))
await resources.async_delete_item(existing_entry["id"])
except Exception: # noqa: BLE001
_LOGGER.debug("Failed to remove existing bundled frontend resource, will attempt to (re)create it")
# Create a new resource entry for the bundled card
if hasattr(resources, "async_create_item"):
await resources.async_create_item({"res_type": "module", "url": url})
if removed_old:
_LOGGER.info("Successfully migrated to bundled frontend card (version %s)", frontend_version)
else:
_LOGGER.info("Successfully registered bundled frontend card (version %s)", frontend_version)
else:
_LOGGER.warning("Cannot auto-register card: Lovelace resources are in YAML mode")
# Note: Version mismatch detection and refresh notification is handled by the frontend
# JavaScript, which can accurately detect when the browser cache is stale
except Exception as err:
_LOGGER.error("Failed to auto-register frontend card: %s", err)
# Store the version we just registered
hass.data[DOMAIN]["frontend_registered_version"] = frontend_version
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up via YAML by importing into a config entry, else no-op."""
if DOMAIN in config:
# Import legacy YAML into a config entry
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Climate Scheduler from a config entry."""
await _async_setup_common(hass)
# Get current version from manifest
manifest_path = Path(__file__).parent / "manifest.json"
current_version = None
try:
manifest_text = await hass.async_add_executor_job(manifest_path.read_text)
manifest = json.loads(manifest_text)
current_version = manifest.get("version")
except Exception as e:
_LOGGER.warning("Failed to read manifest version: %s", e)
# Check if this is first install or an upgrade
stored_version = entry.data.get("version")
is_first_install = stored_version is None
is_upgrade = stored_version is not None and current_version != stored_version
# Update version in config entry if changed
if current_version and (is_first_install or is_upgrade):
new_data = dict(entry.data)
new_data["version"] = current_version
hass.config_entries.async_update_entry(entry, data=new_data)
if is_first_install:
_LOGGER.info("First install detected (version %s) - will reload integration after setup", current_version)
elif is_upgrade:
_LOGGER.info("Upgrade detected (%s -> %s) - will reload integration after setup", stored_version, current_version)
# Forward entry setup to sensor, climate, and switch platforms
await hass.config_entries.async_forward_entry_setups(entry, ["sensor", "climate", "switch"])
# Reload integration after first install or upgrade to ensure UI is properly initialized
if is_first_install or is_upgrade:
async def _delayed_reload():
"""Reload integration after a short delay to allow setup to complete."""
await asyncio.sleep(2) # Wait for platforms to fully initialize
try:
_LOGGER.info("Performing post-setup reload to initialize UI")
await hass.config_entries.async_reload(entry.entry_id)
except Exception as e:
_LOGGER.warning("Post-setup reload failed: %s", e)
import asyncio
hass.async_create_task(_delayed_reload())
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
# Unload all platforms
unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor", "climate", "switch"])
# Remove panel and services only if this is the last entry
entries = hass.config_entries.async_entries(DOMAIN)
if len(entries) <= 1:
_LOGGER.debug("[BACKEND] Unloading last config entry - removing all services (including set_group_schedule)")
# Unregister services
for svc in [
"set_schedule","get_schedule","clear_schedule","enable_schedule","disable_schedule",
"sync_all","create_group","delete_group","add_to_group","remove_from_group",
"get_groups","set_group_schedule","enable_group","disable_group","get_settings",
"save_settings","reload_integration","advance_schedule","advance_group",
"cancel_advance","get_advance_status","clear_advance_history","test_fire_event","create_profile",
"delete_profile","rename_profile","set_active_profile","get_profiles",
"cleanup_derivative_sensors","factory_reset"
]:
try:
hass.services.async_remove(DOMAIN, svc)
if svc == "set_group_schedule":
_LOGGER.debug("[BACKEND] Removed service: set_group_schedule")
except Exception: # noqa: BLE001
pass
_LOGGER.info("[BACKEND] All services removed during unload")
hass.data.pop(DOMAIN, None)
else:
_LOGGER.info("[BACKEND] Unloading entry but keeping services (not last entry)")
return unload_ok
@@ -0,0 +1,481 @@
"""Climate platform for Climate Scheduler."""
import asyncio
import logging
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, time
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode,
HVACAction,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import DOMAIN
from .coordinator import HeatingSchedulerCoordinator
from .storage import ScheduleStorage
_LOGGER = logging.getLogger(__name__)
__all__ = ["async_setup_entry", "ClimateSchedulerGroupEntity"]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Climate Scheduler climate entities from a config entry."""
storage: ScheduleStorage = hass.data[DOMAIN]["storage"]
coordinator: HeatingSchedulerCoordinator = hass.data[DOMAIN]["coordinator"]
# Get all groups - create climate cards for all groups with entities
groups = await storage.async_get_groups()
entities = []
groups_normalized = False
for group_name, group_data in groups.items():
# Normalize _is_single_entity_group flag based on actual entity count
# This fixes inconsistency where groups with 1 entity might not have the flag set
entity_count = len(group_data.get("entities", []))
is_single_entity = entity_count == 1
has_flag = group_data.get("_is_single_entity_group", False)
# If flag doesn't match actual entity count, normalize it
if is_single_entity != has_flag:
group_data["_is_single_entity_group"] = is_single_entity
groups_normalized = True
_LOGGER.info(
f"Normalized _is_single_entity_group flag for group '{group_name}': "
f"entity_count={entity_count}, flag={is_single_entity}"
)
# Skip virtual groups (no entities) - but include all groups with at least one entity
if entity_count == 0:
continue
# Create climate entity for this group (including single-entity groups)
entities.append(
ClimateSchedulerGroupEntity(
coordinator,
storage,
group_name,
group_data,
)
)
_LOGGER.info(f"Created climate entity for group '{group_name}' with {entity_count} entities")
# Save normalized flags if any were updated
if groups_normalized:
await storage.async_save()
_LOGGER.info("Saved normalized group flags to storage")
if entities:
async_add_entities(entities, True)
_LOGGER.info(f"Added {len(entities)} climate scheduler group entities")
class ClimateSchedulerGroupEntity(CoordinatorEntity, ClimateEntity):
"""Climate entity representing a schedule group."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HeatingSchedulerCoordinator,
storage: ScheduleStorage,
group_name: str,
group_data: Dict[str, Any],
) -> None:
"""Initialize the climate entity."""
super().__init__(coordinator)
self._storage = storage
self._group_name = group_name
self._attr_has_entity_name = True
self._attr_name = f"Climate Schedule {group_name}"
self._attr_unique_id = f"climate_scheduler_group_{group_name.lower().replace(' ', '_')}"
# Climate entity configuration - will be set when added to hass
self._attr_temperature_unit = UnitOfTemperature.CELSIUS # Default, updated in async_added_to_hass
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE |
ClimateEntityFeature.TURN_ON |
ClimateEntityFeature.TURN_OFF |
ClimateEntityFeature.PRESET_MODE
)
# Temperature control settings
self._attr_min_temp = 5.0
self._attr_max_temp = 35.0
self._attr_target_temp_step = 0.5
# Simplified modes: Auto (Active - follows schedule) or Off (Idle)
self._attr_hvac_modes = [
HVACMode.AUTO, # Active - follows schedule
HVACMode.OFF, # Idle - turns off members
]
# Preset modes from profiles
profiles = group_data.get("profiles", {})
self._attr_preset_modes = list(profiles.keys()) if profiles else []
self._attr_preset_mode = group_data.get("active_profile", "Default")
# State tracking
self._member_entities: List[str] = group_data.get("entities", [])
self._enabled: bool = group_data.get("enabled", True)
self._attr_current_temperature: Optional[float] = None
self._attr_target_temperature: Optional[float] = None
self._attr_hvac_mode: Optional[HVACMode] = HVACMode.AUTO if self._enabled else HVACMode.OFF
self._attr_hvac_action: Optional[HVACAction] = None # Will be set in _update_state
# Cache for attributes
self._active_node: Optional[Dict[str, Any]] = None
self._next_node: Optional[Dict[str, Any]] = None
self._member_temps: Dict[str, float] = {}
async def async_added_to_hass(self) -> None:
"""When entity is added to hass, set temperature unit from system config."""
await super().async_added_to_hass()
# Use the same approach as storage.py - get temperature unit from system config
self._attr_temperature_unit = self.hass.config.units.temperature_unit
# Set temperature step from first member entity if available
if self._member_entities:
first_member = self.hass.states.get(self._member_entities[0])
if first_member:
member_step = first_member.attributes.get("target_temp_step", 0.5)
self._attr_target_temp_step = member_step
_LOGGER.debug(
"Climate entity %s initialized with temperature unit: %s, step: %s",
self.entity_id,
self._attr_temperature_unit,
self._attr_target_temp_step,
)
# Perform initial state update
self._update_state()
@property
def unique_id(self) -> str:
"""Return unique ID."""
return self._attr_unique_id
@property
def device_info(self):
"""Return device info to link this entity to the Climate Scheduler integration."""
return {
"identifiers": {(DOMAIN, "climate_scheduler")},
"name": "Climate Scheduler",
"manufacturer": "Climate Scheduler",
"model": "Schedule Manager",
}
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
# Update enabled state and profiles from storage
group_data = self._storage._data.get("groups", {}).get(self._group_name)
if group_data:
self._enabled = group_data.get("enabled", True)
profiles = group_data.get("profiles", {})
self._attr_preset_modes = list(profiles.keys()) if profiles else []
self._attr_preset_mode = group_data.get("active_profile", "Default")
# Update HVAC mode based on enabled state
self._attr_hvac_mode = HVACMode.AUTO if self._enabled else HVACMode.OFF
self._update_state()
self.async_write_ha_state()
def _update_state(self) -> None:
"""Update entity state from member entities and schedule."""
# 1. Calculate average current temperature from members (in their native unit)
temps = []
temp_map = {}
for entity_id in self._member_entities:
state = self.hass.states.get(entity_id)
if state and state.attributes.get("current_temperature") is not None:
try:
temp = float(state.attributes["current_temperature"])
# Store temperature in its native unit, no conversion
temps.append(temp)
temp_map[entity_id] = round(temp, 1)
except (ValueError, TypeError):
pass
if temps:
self._attr_current_temperature = round(sum(temps) / len(temps), 1)
else:
self._attr_current_temperature = None
self._member_temps = temp_map
# 2. Check if any member is actively requesting heat/cool and determine overall action
heating_count = 0
cooling_count = 0
for entity_id in self._member_entities:
state = self.hass.states.get(entity_id)
if state:
hvac_action = state.attributes.get("hvac_action")
if hvac_action == "heating":
heating_count += 1
elif hvac_action == "cooling":
cooling_count += 1
# Set hvac_action based on member states and enabled status
if not self._enabled:
self._attr_hvac_action = HVACAction.OFF
elif heating_count > 0:
self._attr_hvac_action = HVACAction.HEATING
elif cooling_count > 0:
self._attr_hvac_action = HVACAction.COOLING
else:
self._attr_hvac_action = HVACAction.IDLE
# 3. Get active node from schedule to determine setpoint
current_time = datetime.now().time()
current_day = datetime.now().strftime('%a').lower()
# Get the schedule data from storage (synchronously accessible)
group_data = self._storage._data.get("groups", {}).get(self._group_name, {})
schedule_mode = group_data.get("schedule_mode", "all_days")
schedules = group_data.get("schedules", {})
# Get nodes for current day using the same logic as coordinator
if schedule_mode == "all_days":
nodes = schedules.get("all_days", [])
elif schedule_mode == "5/2":
if current_day in ["mon", "tue", "wed", "thu", "fri"]:
nodes = schedules.get("weekday", [])
else:
nodes = schedules.get("weekend", [])
else: # individual
nodes = schedules.get(current_day, [])
# In individual or 5/2 mode, if current time is before all nodes today,
# use previous day's last node
if schedule_mode in ["individual", "5/2"] and nodes:
sorted_nodes = sorted(nodes, key=lambda n: self._storage._time_to_minutes(n["time"]))
current_minutes = current_time.hour * 60 + current_time.minute
if sorted_nodes and current_minutes < self._storage._time_to_minutes(sorted_nodes[0]["time"]):
# We're before the first node of today, need previous day/period's last node
days_of_week = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
current_day_index = days_of_week.index(current_day)
prev_day = days_of_week[(current_day_index - 1) % 7]
# Get previous day's nodes based on schedule mode
if schedule_mode == "5/2":
# In 5/2 mode, determine if previous day is weekday or weekend
if prev_day in ["mon", "tue", "wed", "thu", "fri"]:
prev_day_nodes = schedules.get("weekday", [])
else:
prev_day_nodes = schedules.get("weekend", [])
else:
# In individual mode, use specific day
prev_day_nodes = schedules.get(prev_day, [])
if prev_day_nodes:
sorted_prev_nodes = sorted(prev_day_nodes, key=lambda n: self._storage._time_to_minutes(n["time"]))
last_prev_node = sorted_prev_nodes[-1]
# Prepend previous day's last node with time "00:00"
carryover_node = {**last_prev_node, "time": "00:00"}
nodes = [carryover_node] + nodes
# Determine the active setpoint from schedule
active_setpoint = None
if nodes:
active_node = self._storage.get_active_node(nodes, current_time)
if active_node:
active_setpoint = active_node.get("temp")
_LOGGER.debug(f"Active node for {self._group_name}: temp={active_setpoint}")
else:
_LOGGER.debug(f"No active node for {self._group_name} at {current_time}")
else:
_LOGGER.debug(f"No schedule data for {self._group_name} on {current_day}")
# Show the scheduled setpoint as target temperature, or use a default
if active_setpoint is not None:
self._attr_target_temperature = active_setpoint
else:
# Fallback: use average of member target temps, or 20°C default
member_targets = []
for entity_id in self._member_entities:
state = self.hass.states.get(entity_id)
if state and state.attributes.get("temperature") is not None:
try:
member_targets.append(float(state.attributes["temperature"]))
except (ValueError, TypeError):
pass
if member_targets:
self._attr_target_temperature = round(sum(member_targets) / len(member_targets), 1)
else:
self._attr_target_temperature = 20.0 # Default fallback
_LOGGER.debug(f"No schedule setpoint for {self._group_name}, using fallback: {self._attr_target_temperature}")
# HVAC mode reflects enabled state (not member states)
# This is already set in _handle_coordinator_update
self._active_node = None
self._next_node = None
def _map_hvac_mode(self, mode_str: str) -> HVACMode:
"""Map string hvac_mode to HVACMode enum."""
mapping = {
"off": HVACMode.OFF,
"heat": HVACMode.HEAT,
"cool": HVACMode.COOL,
"heat_cool": HVACMode.HEAT_COOL,
"auto": HVACMode.AUTO,
"dry": HVACMode.DRY,
"fan_only": HVACMode.FAN_ONLY,
}
return mapping.get(mode_str.lower() if mode_str else "heat", HVACMode.HEAT)
@property
def extra_state_attributes(self) -> Dict[str, Any]:
"""Return entity specific state attributes."""
attrs = {
"member_entities": self._member_entities,
"member_count": len(self._member_entities),
"group_name": self._group_name,
"schedule_enabled": self._enabled,
}
# Add member temperatures if available
if self._member_temps:
attrs["member_temperatures"] = self._member_temps
# Placeholder for future phases - active node info
if self._active_node:
attrs["active_node"] = {
"time": self._active_node.get("time"),
"temp": self._active_node.get("temp"),
"hvac_mode": self._active_node.get("hvac_mode"),
}
if self._next_node:
attrs["next_node"] = {
"time": self._next_node.get("time"),
"temp": self._next_node.get("temp"),
"hvac_mode": self._next_node.get("hvac_mode"),
}
return attrs
async def async_set_temperature(self, **kwargs) -> None:
"""Set new target temperature for all member entities."""
temperature = kwargs.get("temperature")
if temperature is None:
_LOGGER.warning("No temperature provided to async_set_temperature")
return
# Apply temperature to all member entities
for entity_id in self._member_entities:
try:
await self.hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": entity_id,
"temperature": temperature,
},
blocking=True,
)
_LOGGER.info(f"Set temperature {temperature} for {entity_id}")
except Exception as e:
_LOGGER.error(f"Failed to set temperature for {entity_id}: {e}")
# Set override for each member until next scheduled node
current_time = datetime.now().time()
current_day = datetime.now().strftime('%a').lower()
group_data = self._storage._data.get("groups", {}).get(self._group_name, {})
schedules = group_data.get("schedules", {})
schedule_data = schedules.get(current_day)
if schedule_data and "nodes" in schedule_data:
nodes = schedule_data["nodes"]
next_node = self._storage.get_next_node(nodes, current_time)
if next_node:
# Calculate when override expires (at next node time)
next_node_time_str = next_node["time"]
next_node_hours, next_node_minutes = map(int, next_node_time_str.split(":"))
override_until = datetime.now().replace(
hour=next_node_hours,
minute=next_node_minutes,
second=0,
microsecond=0
)
# If next node time is earlier in the day than current time, it's tomorrow
if override_until <= datetime.now():
override_until += timedelta(days=1)
# Set override for all members
for entity_id in self._member_entities:
self.coordinator.override_until[entity_id] = override_until
_LOGGER.info(
f"Set manual override for group {self._group_name} until {override_until}"
)
# Update target temperature immediately and trigger state refresh
self._attr_target_temperature = temperature
# Schedule a state update after a short delay to allow member entities to react
async def delayed_update():
await asyncio.sleep(2) # Wait for members to start heating
self._update_state()
self.async_write_ha_state()
self.hass.async_create_task(delayed_update())
self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set HVAC mode - AUTO (active) or OFF (idle)."""
if hvac_mode == HVACMode.OFF:
# Turn off schedule (idle mode)
await self.async_turn_off()
elif hvac_mode == HVACMode.AUTO:
# Turn on schedule (active mode)
await self.async_turn_on()
else:
_LOGGER.warning(f"Unsupported HVAC mode {hvac_mode} for climate scheduler group")
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode (profile) for this group."""
try:
await self._storage.async_set_active_profile(self._group_name, preset_mode)
except ValueError as err:
_LOGGER.error(f"Profile switch failed for group {self._group_name}: {err}")
return
# Force coordinator refresh to apply new schedule immediately
await self.coordinator.async_request_refresh()
_LOGGER.info(f"Set profile {preset_mode} for group {self._group_name}")
async def async_turn_on(self) -> None:
"""Enable the schedule."""
# Enable schedule for all member entities
for entity_id in self._member_entities:
await self._storage.async_set_enabled(entity_id, True)
self._enabled = True
self.async_write_ha_state()
_LOGGER.info("Enabled schedule for group %s", self._group_name)
async def async_turn_off(self) -> None:
"""Disable the schedule."""
# Disable schedule for all member entities
for entity_id in self._member_entities:
await self._storage.async_set_enabled(entity_id, False)
self._enabled = False
self.async_write_ha_state()
_LOGGER.info("Disabled schedule for group %s", self._group_name)
@@ -0,0 +1,28 @@
from __future__ import annotations
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from .const import DOMAIN
class ClimateSchedulerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def async_step_user(self, user_input: dict | None = None):
# Only allow a single entry
if self._async_current_entries():
return self.async_abort(reason="already_configured")
if user_input is not None:
return self.async_create_entry(title="Climate Scheduler", data={})
# No options to configure — empty form confirms creation
return self.async_show_form(step_id="user", data_schema=vol.Schema({}))
async def async_step_import(self, user_input: dict | None = None):
# Support YAML import for backward compatibility
if self._async_current_entries():
return self.async_abort(reason="already_configured")
return self.async_create_entry(title="Climate Scheduler", data={})
@@ -0,0 +1,24 @@
"""Constants for the Climate Scheduler integration."""
DOMAIN = "climate_scheduler"
# Storage
STORAGE_VERSION = 1
STORAGE_KEY = "climate_scheduler_data"
# Default schedule nodes (time in HH:MM format, temp in Celsius)
DEFAULT_SCHEDULE = []
# Temperature settings (in Celsius)
MIN_TEMP = 5.0
MAX_TEMP = 30.0
NO_CHANGE_TEMP = None # Special value to indicate temperature should not be changed
# Update interval
UPDATE_INTERVAL_SECONDS = 60 # Check schedules every minute
# Settings keys
SETTING_USE_WORKDAY = "use_workday_integration" # Whether to use Workday integration for 5/2 scheduling
SETTING_WORKDAYS = "workdays" # List of days considered workdays (e.g., ["mon", "tue", "wed", "thu", "fri"])
# Default workdays (Monday through Friday)
DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
1.15.1
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,673 @@
/**
* Home Assistant API Integration
* Handles communication with Home Assistant via WebSocket API
* Supports both custom panel mode (using hass object) and iframe mode (WebSocket)
*/
class HomeAssistantAPI {
constructor() {
this.connection = null;
this.messageId = 1;
this.pendingRequests = new Map();
this.stateUpdateCallbacks = [];
this.hass = null; // For custom panel mode
this.usingHassObject = false; // Track which mode we're in
}
/**
* Set hass object (custom panel mode)
*/
setHassObject(hass) {
this.hass = hass;
this.usingHassObject = true;
}
async connect() {
// If we already have a hass object, we're in custom panel mode
if (this.hass && this.usingHassObject) {
console.log('Using existing hass connection from custom panel');
return Promise.resolve();
}
try {
// Get auth token - works in both browser and mobile app
const authToken = await this.getAuthToken();
// Connect to Home Assistant WebSocket API
// Use relative path for mobile app compatibility
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/api/websocket`;
this.connection = new WebSocket(wsUrl);
return new Promise((resolve, reject) => {
this.connection.onopen = () => {
console.log('WebSocket connected');
};
this.connection.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message, authToken, resolve, reject);
};
this.connection.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
this.connection.onclose = () => {
console.log('WebSocket disconnected');
};
});
} catch (error) {
console.error('Failed to connect:', error);
throw error;
}
}
handleMessage(message, authToken, resolveConnection, rejectConnection) {
if (message.type === 'auth_required') {
// Send authentication
this.send({
type: 'auth',
access_token: authToken
});
} else if (message.type === 'auth_ok') {
console.log('Authenticated successfully');
resolveConnection();
} else if (message.type === 'auth_invalid') {
console.error('Authentication failed');
rejectConnection(new Error('Authentication failed'));
} else if (message.type === 'result') {
// Handle response to our request
const pending = this.pendingRequests.get(message.id);
if (pending) {
if (message.success) {
pending.resolve(message.result);
} else {
pending.reject(message.error);
}
this.pendingRequests.delete(message.id);
}
} else if (message.type === 'event') {
// Handle state change events
if (message.event && message.event.event_type === 'state_changed') {
this.notifyStateUpdate(message.event.data);
}
}
}
async getAuthToken() {
// Method 1: Check if running in Home Assistant panel context (works in mobile app)
if (window.hassConnection) {
try {
const auth = await window.hassConnection;
if (auth && auth.auth && auth.auth.data && auth.auth.data.access_token) {
return auth.auth.data.access_token;
}
} catch (e) {
console.warn('Could not get token from hassConnection:', e);
}
}
// Method 2: Try to get from parent window context
if (window.parent && window.parent.hassConnection && window.parent !== window) {
try {
const auth = await window.parent.hassConnection;
if (auth && auth.auth && auth.auth.data && auth.auth.data.access_token) {
return auth.auth.data.access_token;
}
} catch (e) {
console.warn('Could not get token from parent.hassConnection:', e);
}
}
// Method 3: Fallback to localStorage (browser only)
try {
const token = localStorage.getItem('hassTokens');
if (token) {
const tokens = JSON.parse(token);
if (tokens.access_token) {
return tokens.access_token;
}
}
} catch (e) {
console.warn('Could not get token from localStorage:', e);
}
throw new Error('No authentication token found. Please ensure the panel is loaded within Home Assistant.');
}
send(message) {
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
this.connection.send(JSON.stringify(message));
} else {
throw new Error('WebSocket not connected');
}
}
async sendRequest(message) {
// If using hass object (custom panel mode), use hass.callWS
if (this.hass && this.usingHassObject) {
try {
return await this.hass.callWS(message);
} catch (error) {
console.error('Error calling hass.callWS:', error);
throw error;
}
}
// Fallback to WebSocket mode
const id = this.messageId++;
const request = { id, ...message };
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
this.send(request);
// Timeout after 30 seconds
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
async getStates() {
// Custom panel mode
if (this.hass && this.usingHassObject) {
return Object.values(this.hass.states);
}
// WebSocket mode
return await this.sendRequest({ type: 'get_states' });
}
async getConfig() {
// Custom panel mode
if (this.hass && this.usingHassObject) {
return this.hass.config;
}
// WebSocket mode
return await this.sendRequest({ type: 'get_config' });
}
async getClimateEntities() {
const states = await this.getStates();
return states.filter(state =>
state.entity_id.startsWith('climate.') &&
!state.entity_id.startsWith('climate.climate_scheduler_')
);
}
async callService(domain, service, serviceData, returnResponse = false) {
// Custom panel mode
if (this.hass && this.usingHassObject) {
// Use callWS for return_response to avoid triggering haptic feedback
if (returnResponse) {
const result = await this.hass.callWS({
type: 'call_service',
domain,
service,
service_data: serviceData,
return_response: true
});
return result?.response;
} else {
// Use callService for non-return-response calls
return await this.hass.callService(domain, service, serviceData);
}
}
// WebSocket mode
const requestData = {
type: 'call_service',
domain,
service,
service_data: serviceData
};
if (returnResponse) {
requestData.return_response = true;
}
return await this.sendRequest(requestData);
}
async setLogLevel(level = 'debug') {
return await this.callService('logger', 'set_level', {
'custom_components.climate_scheduler': level
});
}
async getSchedule(entityId, day = null) {
try {
const serviceData = {
schedule_id: entityId
};
if (day) {
serviceData.day = day;
}
// Call our custom service to get schedule with return_response
const result = await this.callService('climate_scheduler', 'get_schedule',
serviceData, true); // Pass true to enable return_response
return result;
} catch (error) {
console.error('Failed to get schedule:', error);
return null;
}
}
async setSchedule(entityId, nodes, day = null, scheduleMode = null) {
const serviceData = {
schedule_id: entityId,
nodes: nodes
};
if (day) {
serviceData.day = day;
}
if (scheduleMode) {
serviceData.schedule_mode = scheduleMode;
}
return await this.callService('climate_scheduler', 'set_schedule', serviceData);
}
async enableSchedule(entityId) {
return await this.callService('climate_scheduler', 'enable_schedule', {
schedule_id: entityId
});
}
async disableSchedule(entityId) {
return await this.callService('climate_scheduler', 'disable_schedule', {
schedule_id: entityId
});
}
async advanceSchedule(entityId) {
return await this.callService('climate_scheduler', 'advance_schedule', {
schedule_id: entityId
});
}
async advanceGroup(groupName) {
return await this.callService('climate_scheduler', 'advance_group', {
schedule_id: groupName
});
}
async cancelAdvance(entityId) {
return await this.callService('climate_scheduler', 'cancel_advance', {
schedule_id: entityId
});
}
async getAdvanceStatus(entityId) {
try {
const result = await this.callService('climate_scheduler', 'get_advance_status', {
schedule_id: entityId
}, true);
// Normalize across modes:
// - hass.callWS path returns `result.response`
// - websocket path may return `{response: ...}`
return result?.response ?? result;
} catch (error) {
console.error('Failed to get advance status:', error);
return { is_active: false, history: [] };
}
}
async clearAdvanceHistory(entityId) {
return await this.callService('climate_scheduler', 'clear_advance_history', {
schedule_id: entityId
});
}
async testFireEvent(groupName, node, day) {
return await this.callService('climate_scheduler', 'test_fire_event', {
schedule_id: groupName,
node: JSON.stringify(node),
day: day
});
}
async getOverrideStatus(entityId) {
try {
const result = await this.callService('climate_scheduler', 'get_override_status', {
schedule_id: entityId
}, true);
return result;
} catch (error) {
console.error('Failed to get override status:', error);
return { has_override: false };
}
}
async clearSchedule(entityId) {
return await this.callService('climate_scheduler', 'clear_schedule', {
schedule_id: entityId
});
}
async setIgnored(entityId, ignored) {
return await this.callService('climate_scheduler', 'set_ignored', {
schedule_id: entityId,
ignored: ignored
});
}
// Group management methods
async createGroup(groupName) {
return await this.callService('climate_scheduler', 'create_group', {
schedule_id: groupName
});
}
async deleteGroup(groupName) {
return await this.callService('climate_scheduler', 'delete_group', {
schedule_id: groupName
});
}
async renameGroup(oldName, newName) {
return await this.callService('climate_scheduler', 'rename_group', {
old_name: oldName,
new_name: newName
});
}
async addToGroup(groupName, entityId) {
return await this.callService('climate_scheduler', 'add_to_group', {
schedule_id: groupName,
entity_id: entityId
});
}
async removeFromGroup(groupName, entityId) {
return await this.callService('climate_scheduler', 'remove_from_group', {
schedule_id: groupName,
entity_id: entityId
});
}
async getGroups() {
try {
const result = await this.callService('climate_scheduler', 'get_groups', {}, true);
return result;
} catch (error) {
console.error('Failed to get groups:', error);
return { groups: {} };
}
}
async setGroupSchedule(groupName, nodes, day = null, scheduleMode = null, profileName = null) {
const callStartTime = performance.now();
console.debug('[HA-API] setGroupSchedule called', {
timestamp: new Date().toISOString(),
groupName,
nodeCount: nodes?.length,
day,
scheduleMode,
profileName,
usingHassObject: this.usingHassObject
});
// Guard: ensure a valid groupName is provided before calling HA service.
if (!groupName) {
const msg = 'setGroupSchedule called without a valid groupName';
console.error('[HA-API]', msg, groupName, nodes, day, scheduleMode);
// Throw so callers can handle the error rather than sending null to HA
throw new Error(msg);
}
const serviceData = {
schedule_id: groupName,
nodes: nodes
};
if (day) {
serviceData.day = day;
}
if (scheduleMode) {
serviceData.schedule_mode = scheduleMode;
}
if (profileName) {
serviceData.profile_name = profileName;
}
console.debug('[HA-API] Calling climate_scheduler.set_group_schedule', {
serviceData: { ...serviceData, nodes: `[${nodes?.length} nodes]` },
connectionMode: this.usingHassObject ? 'hass-object' : 'websocket'
});
try {
const result = await this.callService('climate_scheduler', 'set_group_schedule', serviceData);
console.debug('[HA-API] set_group_schedule succeeded', {
duration: (performance.now() - callStartTime).toFixed(2) + 'ms',
result
});
return result;
} catch (error) {
console.error('[HA-API] setGroupSchedule failed:', {
error,
errorCode: error?.code,
errorMessage: error?.message,
translationKey: error?.translation_key,
translationPlaceholders: error?.translation_placeholders,
groupName,
duration: (performance.now() - callStartTime).toFixed(2) + 'ms',
connectionMode: this.usingHassObject ? 'hass-object' : 'websocket'
});
throw error;
}
}
async enableGroup(groupName) {
return await this.callService('climate_scheduler', 'enable_group', {
schedule_id: groupName
});
}
async disableGroup(groupName) {
return await this.callService('climate_scheduler', 'disable_group', {
schedule_id: groupName
});
}
async getHistory(entityId, startTime, endTime) {
try {
// Format times as ISO strings
const start = startTime.toISOString();
const end = endTime ? endTime.toISOString() : new Date().toISOString();
// Use recorder history API
const result = await this.sendRequest({
type: 'history/history_during_period',
start_time: start,
end_time: end,
entity_ids: [entityId],
minimal_response: false,
no_attributes: false
});
return result;
} catch (error) {
console.error('Failed to get history:', error);
return null;
}
}
async subscribeToStateChanges() {
// Custom panel mode - hass object handles state updates automatically
if (this.hass && this.usingHassObject) {
// Set up listener for hass state changes
// The panel will call updateHassConnection when hass changes
console.log('Using hass object state updates');
return Promise.resolve();
}
// WebSocket mode
return await this.sendRequest({
type: 'subscribe_events',
event_type: 'state_changed'
});
}
async getSettings() {
try {
const result = await this.callService('climate_scheduler', 'get_settings', {}, true);
// Normalize across execution modes:
// - In custom panel mode, callService(..., true) returns the service response directly.
// - In raw websocket mode, callService may return { response: <service_response> }.
const payload = result?.response ?? result ?? {};
// Service response includes version metadata, but app.js expects the raw settings dict.
// If the response shape is { settings: {...}, version: {...} }, return settings.
if (
payload &&
typeof payload === 'object' &&
payload.version &&
payload.settings &&
typeof payload.settings === 'object'
) {
return payload.settings;
}
return payload;
} catch (error) {
console.error('Failed to get settings:', error);
return {};
}
}
async saveSettings(settings) {
try {
await this.callService('climate_scheduler', 'save_settings', { settings: JSON.stringify(settings) });
return true;
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
}
async cleanupDerivativeSensors(confirmDeleteAll = false) {
try {
const result = await this.callService('climate_scheduler', 'cleanup_derivative_sensors', {
confirm_delete_all: confirmDeleteAll
}, true);
return result?.response || result || {};
} catch (error) {
console.error('Failed to cleanup derivative sensors:', error);
throw error;
}
}
async cleanupOrphanedClimateEntities(deleteEntities = false) {
try {
const result = await this.callService('climate_scheduler', 'cleanup_orphaned_climate_entities', {
delete: deleteEntities
}, true);
return result?.response || result || {};
} catch (error) {
console.error('Failed to cleanup orphaned climate entities:', error);
throw error;
}
}
async cleanupUnmonitoredStorage(deleteEntries = false) {
try {
const result = await this.callService('climate_scheduler', 'cleanup_unmonitored_storage', {
delete: deleteEntries
}, true);
return result?.response || result || {};
} catch (error) {
console.error('Failed to cleanup unmonitored storage:', error);
throw error;
}
}
// Profile management methods
async createProfile(scheduleId, profileName) {
return await this.callService('climate_scheduler', 'create_profile', {
schedule_id: scheduleId,
profile_name: profileName
});
}
async deleteProfile(scheduleId, profileName) {
return await this.callService('climate_scheduler', 'delete_profile', {
schedule_id: scheduleId,
profile_name: profileName
});
}
async renameProfile(scheduleId, oldName, newName) {
return await this.callService('climate_scheduler', 'rename_profile', {
schedule_id: scheduleId,
old_name: oldName,
new_name: newName
});
}
async setActiveProfile(scheduleId, profileName) {
return await this.callService('climate_scheduler', 'set_active_profile', {
schedule_id: scheduleId,
profile_name: profileName
});
}
async getProfiles(scheduleId) {
try {
const result = await this.callService('climate_scheduler', 'get_profiles', {
schedule_id: scheduleId
}, true);
return result;
} catch (error) {
console.error('Failed to get profiles:', error);
return { profiles: {}, active_profile: null };
}
}
async runDiagnostics() {
try {
const result = await this.callService('climate_scheduler', 'diagnostics', {}, true);
return result;
} catch (error) {
console.error('Failed to run diagnostics:', error);
throw error;
}
}
onStateUpdate(callback) {
this.stateUpdateCallbacks.push(callback);
}
notifyStateUpdate(data) {
this.stateUpdateCallbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('Error in state update callback:', error);
}
});
}
async subscribeToEvents(eventType, callback) {
if (this.usingHassObject && this.hass) {
// Use hass connection for event subscription
const conn = this.hass.connection;
if (conn && conn.subscribeEvents) {
return await conn.subscribeEvents(callback, eventType);
}
}
console.warn('Event subscription not available');
return null;
}}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,598 @@
/**
* Climate Scheduler Custom Panel
* Modern Home Assistant custom panel implementation (replaces legacy iframe approach)
*/
// Version checking - detect if browser cache is stale
(async function () {
try {
const scriptUrl = document.currentScript?.src || new URL(import.meta.url).href;
const loadedVersion = new URL(scriptUrl).searchParams.get('v');
// Fetch the current server version
const response = await fetch('/climate_scheduler/static/.version');
if (response.ok) {
const serverVersion = (await response.text()).trim().split(',')[0];
// Compare versions - check the aren't None and if they don't match, user has stale cache
if ((loadedVersion && serverVersion) && loadedVersion !== serverVersion) {
console.warn('[Climate Scheduler] Version mismatch detected. Loaded:', loadedVersion, 'Server:', serverVersion);
// Store in sessionStorage to avoid showing repeatedly
const notificationKey = 'climate_scheduler_refresh_shown';
const shownVersion = sessionStorage.getItem(notificationKey);
if (shownVersion !== serverVersion) {
// Show persistent notification
const event = new CustomEvent('hass-notification', {
bubbles: true,
cancelable: false,
composed: true,
detail: {
message: 'Climate Scheduler has been updated. Please refresh your browser (Ctrl+F5 or Cmd+Shift+R) to load the new version.',
duration: 0 // Persistent notification
}
});
document.body.dispatchEvent(event);
// Mark as shown for this session
sessionStorage.setItem(notificationKey, serverVersion);
console.info('[Climate Scheduler] Refresh notification displayed');
}
}
}
}
catch (e) {
console.warn('[Climate Scheduler] Version check failed:', e);
}
})();
// Load other JavaScript files
const loadScript = (src) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = () => resolve();
script.onerror = reject;
document.head.appendChild(script);
});
};
// Track if scripts are loaded
let scriptsLoaded = false;
const getVersion = () => {
const scriptUrl = import.meta.url;
const version = new URL(scriptUrl).searchParams.get('v');
if (!version)
return null;
// If version has comma (dev: "tag,timestamp"), use timestamp for cache busting
if (version.includes(',')) {
const parts = version.split(',');
return parts[1]; // timestamp
}
// Otherwise use version as-is (HACS tag or production tag)
return version;
};
// Load dependencies in order
const loadScripts = () => {
if (scriptsLoaded)
return Promise.resolve();
// Determine base path from where panel.js was loaded
const scriptUrl = import.meta.url;
const url = new URL(scriptUrl);
// Remove panel.js and query params to get base path
const basePath = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/'));
const version = getVersion();
return Promise.all([
loadScript(`${basePath}/utils.js?v=${version}`),
loadScript(`${basePath}/ha-api.js?v=${version}`)
]).then(() => {
return loadScript(`${basePath}/app.js?v=${version}`);
}).then(() => {
scriptsLoaded = true;
}).catch(error => {
console.error('Failed to load Climate Scheduler scripts:', error);
throw error;
});
};
class ClimateSchedulerPanel extends HTMLElement {
constructor() {
super();
this._hass = null;
this.narrow = false;
this.panel = null;
}
// Declare properties that Home Assistant looks for
static get properties() {
return {
hass: { type: Object },
narrow: { type: Boolean },
route: { type: Object },
panel: { type: Object }
};
}
async connectedCallback() {
this.render();
// Store reference to this panel element globally so app.js can query within it
window.climateSchedulerPanelRoot = this;
// Wait for scripts to load before initializing
try {
await loadScripts();
// Small delay to ensure DOM is fully rendered
await new Promise(resolve => setTimeout(resolve, 100));
// Update version info in footer
const versionElement = this.querySelector('#version-info');
if (versionElement) {
try {
const scriptUrl = import.meta.url;
const versionParam = new URL(scriptUrl).searchParams.get('v');
let version = '';
if (versionParam) {
if (versionParam.includes(',')) {
// Has timestamp - dev deployment: "tag,timestamp"
const parts = versionParam.split(',');
const tag = (parts[0] || 'unknown').replace(/^v/, '');
version = `v${tag} (dev)`;
}
else {
// No timestamp - production: just tag
const tag = versionParam.replace(/^v/, '');
version = `v${tag}`;
}
}
else {
version = '(manual)';
}
versionElement.textContent = `Climate Scheduler ${version}`;
}
catch (e) {
console.warn('Failed to determine version:', e);
versionElement.textContent = 'Climate Scheduler';
}
}
// Initialize the app when panel is loaded and scripts are ready
if (window.initClimateSchedulerApp) {
window.initClimateSchedulerApp(this.hass);
}
}
catch (error) {
console.error('Failed to initialize Climate Scheduler:', error);
}
}
set hass(value) {
this._hass = value;
// Apply theme based on Home Assistant theme mode
if (value && value.themes) {
const isDark = value.themes.darkMode;
if (isDark) {
// Dark mode is default, remove attribute
document.documentElement.removeAttribute('data-theme');
this.removeAttribute('data-theme');
}
else {
// Light mode needs explicit attribute
document.documentElement.setAttribute('data-theme', 'light');
this.setAttribute('data-theme', 'light');
}
}
// Pass hass object to app if it's already initialized
if (window.updateHassConnection && value) {
window.updateHassConnection(value);
}
}
get hass() {
return this._hass;
}
render() {
if (!this.querySelector('.container')) {
// Load CSS using same base path detection as scripts
const scriptUrl = import.meta.url;
const url = new URL(scriptUrl);
const basePath = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/'));
const version = getVersion();
const styleLink = document.createElement('link');
styleLink.rel = 'stylesheet';
styleLink.href = `${basePath}/styles.css?v=${version}`;
this.appendChild(styleLink);
// Create container div for content
const container = document.createElement('div');
container.innerHTML = `
<div class="container">
<section class="entity-selector">
<div class="groups-section">
<h3 class="section-title">Monitored (<span id="groups-count">0</span>)</h3>
<div id="groups-list" class="groups-list">
<!-- Dynamically populated with groups -->
</div>
</div>
<div class="profiles-section">
<div class="group-container collapsed" id="global-profile-container">
<div class="group-header" id="toggle-global-profiles">
<div style="display: flex; align-items: center; gap: 8px;">
<span class="group-toggle-icon" style="transform: rotate(-90deg);">▼</span>
<span class="group-title">Profiles</span>
</div>
</div>
<div id="global-profile-list" style="display: none; padding: 12px 16px;">
<div class="profile-controls">
<select id="profile-dropdown" class="profile-dropdown">
<option value="" disabled selected>Select a profile to edit...</option>
</select>
<button id="new-profile-btn" class="btn-profile" title="Create new profile"></button>
<button id="rename-profile-btn" class="btn-profile" title="Rename profile">✎</button>
<button id="delete-profile-btn" class="btn-profile" title="Delete profile">🗑</button>
</div>
</div>
</div>
</div>
<div class="ignored-section">
<div class="group-container collapsed" id="ignored-container">
<div class="group-header" id="toggle-ignored">
<div style="display: flex; align-items: center; gap: 8px;">
<span class="group-toggle-icon" style="transform: rotate(-90deg);">▼</span>
<span class="group-title">Unmonitored</span>
</div>
</div>
<div id="ignored-entity-list" class="entity-list ignored-list" style="display: none;">
<div class="filter-box">
<input type="text" id="ignored-filter" placeholder="Filter by name..." />
</div>
<div id="ignored-entities-container">
<span id="ignored-count" style="display: none;">0</span>
<!-- Dynamically populated -->
</div>
</div>
</div>
</div>
</section>
<!-- Modals -->
<div id="confirm-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Clear Schedule?</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to clear the entire schedule for <strong id="confirm-entity-name"></strong>?</p>
<p>This action cannot be undone.</p>
</div>
<div class="modal-actions">
<button id="confirm-cancel" class="btn-secondary">Cancel</button>
<button id="confirm-clear" class="btn-danger">Clear Schedule</button>
</div>
</div>
</div>
<div id="create-group-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Create New Group</h3>
</div>
<div class="modal-body">
<label for="new-group-name">Group Name:</label>
<input type="text" id="new-group-name" placeholder="e.g., Bedrooms" style="width: 100%; padding: 8px; margin-top: 8px;" />
</div>
<div class="modal-actions">
<button id="create-group-cancel" class="btn-secondary">Cancel</button>
<button id="create-group-confirm" class="btn-primary">Create Group</button>
</div>
</div>
</div>
<div id="add-to-group-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Add to Group</h3>
</div>
<div class="modal-body">
<p>Add <strong id="add-entity-name"></strong> to group:</p>
<select id="add-to-group-select" style="width: 100%; padding: 8px; margin-top: 8px; margin-bottom: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;">
<!-- Populated dynamically -->
</select>
<p style="text-align: center; color: var(--text-secondary); margin: 8px 0;">or</p>
<input type="text" id="new-group-name-inline" placeholder="Create new group..." style="width: 100%; padding: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;" />
</div>
<div class="modal-actions">
<button id="add-to-group-cancel" class="btn-secondary">Cancel</button>
<button id="add-to-group-confirm" class="btn-primary">Add to Group</button>
</div>
</div>
</div>
<div id="convert-temperature-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Convert All Schedules</h3>
</div>
<div class="modal-body">
<p style="margin-bottom: 16px;">This will convert all saved schedules (entities and groups) as well as the default schedule and min/max settings.</p>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Current unit (convert FROM):</label>
<div style="display: flex; gap: 16px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="radio" name="convert-from-unit" value="°C" id="convert-from-celsius" style="cursor: pointer;">
<span>Celsius (°C)</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="radio" name="convert-from-unit" value="°F" id="convert-from-fahrenheit" style="cursor: pointer;">
<span>Fahrenheit (°F)</span>
</label>
</div>
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Target unit (convert TO):</label>
<div style="display: flex; gap: 16px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="radio" name="convert-to-unit" value="°C" id="convert-to-celsius" style="cursor: pointer;">
<span>Celsius (°C)</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="radio" name="convert-to-unit" value="°F" id="convert-to-fahrenheit" style="cursor: pointer;">
<span>Fahrenheit (°F)</span>
</label>
</div>
</div>
<p style="color: var(--warning, #ff9800); font-size: 0.9rem;"><strong>Warning:</strong> This action cannot be undone. Make sure you select the correct source and target units.</p>
</div>
<div class="modal-actions">
<button id="convert-temperature-cancel" class="btn-secondary">Cancel</button>
<button id="convert-temperature-confirm" class="btn-primary">Convert Schedules</button>
</div>
</div>
</div>
<div id="edit-group-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Edit Group</h3>
</div>
<div class="modal-body">
<label for="edit-group-name">Group Name:</label>
<input type="text" id="edit-group-name" placeholder="Group name" style="width: 100%; padding: 8px; margin-top: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;" />
</div>
<div class="modal-actions">
<button id="edit-group-delete" class="btn-danger">Delete Group</button>
<div style="flex: 1;"></div>
<button id="edit-group-cancel" class="btn-secondary">Cancel</button>
<button id="edit-group-save" class="btn-primary">Save</button>
</div>
</div>
</div>
<!-- Instructions Section (Collapsible) -->
<div class="instructions-container">
<div id="global-instructions-toggle" class="instructions-toggle">
<span class="toggle-icon">▶</span>
<span class="toggle-text">Instructions</span>
</div>
<div id="global-graph-instructions" class="graph-instructions collapsed" style="display: none;">
<p>📍 <strong>Double-click or double-tap</strong> the line to add a new node</p>
<p>👆 <strong>Drag nodes</strong> vertically to change temperature or horizontally to move their time</p>
<p>⬌ <strong>Drag the horizontal segment</strong> between two nodes to shift that period while preserving its duration</p>
<p>📋 <strong>Copy / Paste</strong> buttons duplicate a schedule across days or entities</p>
<p>⚙️ <strong>Tap a node</strong> to open its settings panel for HVAC/fan/swing/preset values</p>
</div>
</div>
<!-- Settings Panel -->
<div id="settings-panel" class="settings-panel collapsed">
<div class="settings-header" id="settings-toggle">
<div style="display: flex; align-items: center; gap: 8px;">
<span class="collapse-indicator" style="transform: rotate(-90deg);">▼</span>
<h3>Settings</h3>
</div>
</div>
<div class="settings-content">
<div class="settings-flex" style="display: flex; gap: 24px; align-items: flex-start;">
<div class="settings-main" style="flex: 1; min-width: 0;">
<div class="settings-section">
<h4>Group Management</h4>
<button id="create-group-btn" class="btn-secondary" style="width: 100%;">
+ Create New Group
</button>
</div>
<div class="settings-section">
<h4>Default Schedule</h4>
<p class="settings-description">Set the default temperature schedule used when clearing or creating new schedules</p>
<div class="graph-container">
<keyframe-timeline id="default-schedule-graph" class="temperature-graph" showHeader="false"></keyframe-timeline>
</div>
<div style="margin-top: 8px;">
<button id="clear-default-schedule-btn" class="btn-danger-outline">Clear Schedule</button>
</div>
<div id="default-node-settings-panel" class="node-settings-panel" style="display: none;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h4>Node Settings</h4>
<button id="default-delete-node-btn" class="btn-danger-outline" style="padding: 4px 12px; font-size: 0.9rem;">Delete Node</button>
</div>
<div class="node-info">
<span>Time: <strong id="default-node-time">--:--</strong></span>
<span>Temperature: <strong id="default-node-temp">--°C</strong></span>
</div>
<div class="setting-item" id="default-hvac-mode-item">
<label for="default-node-hvac-mode">HVAC Mode:</label>
<select id="default-node-hvac-mode"><option value="">-- No Change --</option></select>
</div>
<div class="setting-item" id="default-fan-mode-item">
<label for="default-node-fan-mode">Fan Mode:</label>
<select id="default-node-fan-mode"><option value="">-- No Change --</option></select>
</div>
<div class="setting-item" id="default-swing-mode-item">
<label for="default-node-swing-mode">Swing Mode:</label>
<select id="default-node-swing-mode"><option value="">-- No Change --</option></select>
</div>
<div class="setting-item" id="default-preset-mode-item">
<label for="default-node-preset-mode">Preset Mode:</label>
<select id="default-node-preset-mode"><option value="">-- No Change --</option></select>
</div>
</div>
</div>
<div class="settings-section">
<h4>Graph Options</h4>
<div class="setting-row" style="display:flex; gap:18px; align-items:flex-start; flex-wrap: wrap;">
<div class="setting-item" style="flex:1; min-width:280px;">
<label for="tooltip-mode">Tooltip Display:</label>
<select id="tooltip-mode">
<option value="history">Show Historical Temperature</option>
<option value="cursor">Show Cursor Position</option>
</select>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Choose what information to display when hovering over the graph</p>
</div>
<div style="display:flex; gap:12px; align-items:center;">
<div style="display:flex; flex-direction:column; gap:6px;">
<label for="min-temp" style="font-weight:600;">Min Temp (<span id="min-unit">°C</span>)</label>
<input id="min-temp" type="number" step="0.1" placeholder="e.g. 5.0" style="width:120px; padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label for="max-temp" style="font-weight:600;">Max Temp (<span id="max-unit">°C</span>)</label>
<input id="max-temp" type="number" step="0.1" placeholder="e.g. 30.0" style="width:120px; padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;" />
</div>
</div>
</div>
<div class="setting-item" style="margin-top: 12px;">
<label>
<input type="checkbox" id="debug-panel-toggle" style="margin-right: 8px;"> Show Debug Panel
</label>
</div>
</div>
<div class="settings-section">
<h4>Temperature Precision</h4>
<div class="setting-row" style="display:flex; gap:18px; align-items:flex-start; flex-wrap: wrap;">
<div class="setting-item" style="flex:1; min-width:280px;">
<label for="graph-snap-step">Graph Snap Step:</label>
<select id="graph-snap-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
<option value="0.1">0.1°</option>
<option value="0.5">0.5°</option>
<option value="1">1.0°</option>
</select>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Temperature rounding when dragging nodes on the graph</p>
</div>
<div class="setting-item" style="flex:1; min-width:280px;">
<label for="input-temp-step">Input Field Step:</label>
<select id="input-temp-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
<option value="0.1">0.1°</option>
<option value="0.5">0.5°</option>
<option value="1">1.0°</option>
</select>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Step size for temperature input fields and up/down buttons</p>
</div>
<div class="setting-item" style="flex:1; min-width:280px;">
<label for="humidity-step">Humidity Slider Step:</label>
<select id="humidity-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
<option value="1">1%</option>
<option value="2">2%</option>
<option value="5">5%</option>
</select>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Step size for humidity slider in node settings dialog</p>
</div>
</div>
</div>
<div class="settings-section">
<h4>Derivative Sensors</h4>
<p class="settings-description">Automatically create sensors to track heating/cooling rates for performance analysis</p>
<div class="setting-item" style="max-width: 100%;">
<label>
<input type="checkbox" id="create-derivative-sensors" style="margin-right: 8px;"> Auto-create derivative sensors
</label>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">When enabled, creates sensor.climate_scheduler_[name]_rate for each thermostat to track temperature change rate (°C/h)</p>
</div>
</div>
<div class="settings-section">
<h4>Workday Integration</h4>
<p class="settings-description">Configure which days are workdays for 5/2 mode scheduling</p>
<div class="setting-item" style="max-width: 100%;">
<label id="use-workday-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="use-workday-integration" style="margin-right: 8px;" disabled>
<span>Use Workday integration for 5/2 scheduling</span>
</label>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;" id="workday-help-text">Checking if Workday integration is installed...</p>
</div>
<div id="workday-selector" style="margin-top: 16px; display: none;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Select Workdays:</label>
<p class="settings-description" style="margin-bottom: 8px; font-size: 0.85rem;">Choose which days are considered workdays when Workday integration is disabled</p>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="mon" style="cursor: pointer;">
<span>Monday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="tue" style="cursor: pointer;">
<span>Tuesday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="wed" style="cursor: pointer;">
<span>Wednesday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="thu" style="cursor: pointer;">
<span>Thursday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="fri" style="cursor: pointer;">
<span>Friday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="sat" style="cursor: pointer;">
<span>Saturday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="sun" style="cursor: pointer;">
<span>Sunday</span>
</label>
</div>
</div>
</div>
</div>
<!-- right column removed: min/max now inline in Graph Options -->
</div>
<div class="settings-actions" style="margin-top: 12px; display: flex; gap: 12px; flex-wrap: wrap;">
<button id="refresh-entities-menu" class="btn-secondary">Refresh Entities</button>
<button id="sync-all-menu" class="btn-secondary">Sync All Thermostats</button>
<button id="reload-integration-menu" class="btn-secondary">Reload Integration</button>
<button id="convert-temperature-btn" class="btn-secondary">Convert All Schedules...</button>
<button id="run-diagnostics-btn" class="btn-secondary">Run Diagnostics</button>
<button id="cleanup-derivative-sensors-btn" class="btn-secondary">Cleanup Derivative Sensors</button>
<button id="cleanup-orphaned-climate-btn" class="btn-secondary">Cleanup Orphaned Entities</button>
<button id="cleanup-storage-btn" class="btn-secondary">Cleanup Unmonitored Storage</button>
<button id="reset-defaults" class="btn-secondary">Reset to Defaults</button>
</div>
</div>
</div>
<!-- Debug Panel -->
<div id="debug-panel" class="debug-panel" style="display: none;">
<div class="debug-header">
<h3>Debug Console</h3>
<button id="clear-debug" class="btn-secondary" style="padding: 4px 8px; font-size: 0.85rem;">Clear</button>
</div>
<div id="debug-content" class="debug-content">
<!-- Debug messages will appear here -->
</div>
</div>
<footer>
<img alt="Integration Usage" src="https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.climate_scheduler.total" />
<p><span id="version-info">Climate Scheduler</span>, created by <a href="https://neave.engineering" target="_blank" rel="noopener noreferrer" style="color: var(--primary)">Keegan Neave</a></p>
<p><a href="https://www.buymeacoffee.com/kneave" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">☕ Buy me a coffee</a></p>
</div>
</footer>
</div>
`;
this.appendChild(container);
}
}
}
customElements.define('climate-scheduler-panel', ClimateSchedulerPanel);
//# sourceMappingURL=panel.js.map
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,185 @@
/**
* Shared Utility Functions
* Common utilities used across the Climate Scheduler frontend
*/
// ============================================================================
// TIMEZONE UTILITIES
// ============================================================================
/**
* Get a Date-like object in the server's timezone
* @param {Date} date - The UTC date to convert
* @param {string} serverTimeZone - The server's timezone (e.g., 'America/New_York')
* @returns {Object} Date-like object with methods that return values in server timezone
*/
function getServerDate(date, serverTimeZone) {
if (!serverTimeZone) return date; // Fallback to local time if no timezone set
// Use Intl.DateTimeFormat to get date components in server timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: serverTimeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
const parts = formatter.formatToParts(date);
const partsObj = {};
parts.forEach(part => {
partsObj[part.type] = part.value;
});
return {
getFullYear: () => parseInt(partsObj.year),
getMonth: () => parseInt(partsObj.month) - 1, // JS months are 0-indexed
getDate: () => parseInt(partsObj.day),
getHours: () => parseInt(partsObj.hour),
getMinutes: () => parseInt(partsObj.minute),
getSeconds: () => parseInt(partsObj.second),
getTime: () => date.getTime(),
_originalDate: date
};
}
/**
* Get current server time as Date-like object
* @param {string} serverTimeZone - The server's timezone
* @returns {Object} Date-like object with current time in server timezone
*/
function getServerNow(serverTimeZone) {
return getServerDate(new Date(), serverTimeZone);
}
/**
* Convert UTC timestamp to server timezone Date-like object
* @param {Date} utcDate - The UTC date to convert
* @param {string} serverTimeZone - The server's timezone
* @returns {Object} Date-like object in server timezone
*/
function utcToServerDate(utcDate, serverTimeZone) {
return getServerDate(utcDate, serverTimeZone);
}
// ============================================================================
// TEMPERATURE CONVERSION UTILITIES
// ============================================================================
/**
* Convert Celsius to Fahrenheit
* @param {number} celsius - Temperature in Celsius
* @returns {number} Temperature in Fahrenheit
*/
function celsiusToFahrenheit(celsius) {
return (celsius * 9/5) + 32;
}
/**
* Convert Fahrenheit to Celsius
* @param {number} fahrenheit - Temperature in Fahrenheit
* @returns {number} Temperature in Celsius
*/
function fahrenheitToCelsius(fahrenheit) {
return (fahrenheit - 32) * 5/9;
}
/**
* Convert temperature between units
* @param {number} temp - Temperature value
* @param {string} fromUnit - Source unit ('°C' or '°F')
* @param {string} toUnit - Target unit ('°C' or '°F')
* @returns {number} Converted temperature
*/
function convertTemperature(temp, fromUnit, toUnit) {
if (fromUnit === toUnit) return temp;
if (fromUnit === '°C' && toUnit === '°F') return celsiusToFahrenheit(temp);
if (fromUnit === '°F' && toUnit === '°C') return fahrenheitToCelsius(temp);
return temp;
}
// ============================================================================
// TIME UTILITIES
// ============================================================================
/**
* Convert time string to minutes since midnight
* @param {string} timeStr - Time in 'HH:MM' format
* @returns {number} Minutes since midnight
*/
function timeToMinutes(timeStr) {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
}
/**
* Convert minutes since midnight to time string
* @param {number} minutes - Minutes since midnight
* @returns {string} Time in 'HH:MM' format
*/
function minutesToTime(minutes) {
const hours = Math.floor(minutes / 60);
const mins = Math.floor(minutes % 60);
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
}
/**
* Format hours and minutes as 'HH:MM' time string
* @param {number} hours - Hours (0-23)
* @param {number} minutes - Minutes (0-59)
* @returns {string} Time in 'HH:MM' format
*/
function formatTimeString(hours, minutes) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
/**
* Adjust time by adding/subtracting minutes with 24-hour wraparound
* @param {string} timeStr - Time in 'HH:MM' format
* @param {number} minutesToAdd - Minutes to add (negative to subtract)
* @returns {string} New time in 'HH:MM' format
*/
function adjustTime(timeStr, minutesToAdd) {
let totalMinutes = timeToMinutes(timeStr) + minutesToAdd;
// Handle wraparound
while (totalMinutes < 0) totalMinutes += 1440;
while (totalMinutes >= 1440) totalMinutes -= 1440;
return minutesToTime(totalMinutes);
}
/**
* Interpolate temperature at a given time (step function - hold until next node)
* @param {Array} nodes - Array of schedule nodes with {time, temp} properties
* @param {string} timeStr - Time in 'HH:MM' format
* @returns {number} Interpolated temperature
*/
function interpolateTemperature(nodes, timeStr) {
if (nodes.length === 0) return 18;
const sorted = [...nodes].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
const currentMinutes = timeToMinutes(timeStr);
// Find the most recent node before or at current time
let activeNode = null;
for (let i = 0; i < sorted.length; i++) {
const nodeMinutes = timeToMinutes(sorted[i].time);
if (nodeMinutes <= currentMinutes) {
activeNode = sorted[i];
} else {
break;
}
}
// If no node found before current time, use last node (wrap around from previous day)
if (!activeNode) {
activeNode = sorted[sorted.length - 1];
}
return activeNode.temp;
}
@@ -0,0 +1,20 @@
{
"domain": "climate_scheduler",
"name": "Climate Scheduler",
"codeowners": [
"@kneave"
],
"config_flow": true,
"dependencies": [
"climate",
"http"
],
"documentation": "https://github.com/kneave/climate-scheduler",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/kneave/climate-scheduler/issues",
"requirements": [
],
"version": "1.15.1"
}
@@ -0,0 +1,452 @@
"""Sensor platform for Climate Scheduler."""
import logging
from datetime import timedelta
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util import dt as dt_util
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# How many historical samples to keep for derivative calculation
SAMPLE_SIZE = 10
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Climate Scheduler sensors from a config entry."""
from homeassistant.helpers import device_registry as dr, entity_registry as er
storage = hass.data[DOMAIN]["storage"]
coordinator = hass.data[DOMAIN]["coordinator"]
# Get settings to check if derivative sensors are enabled
settings = storage._data.get("settings", {})
sensors = []
# Always create coldest and warmest sensors
sensors.append(ColdestEntitySensor(hass, storage, coordinator))
sensors.append(WarmestEntitySensor(hass, storage, coordinator))
if not settings.get("create_derivative_sensors", True):
async_add_entities(sensors, True)
return
# Get registries
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
# Get all entities from groups (both single and multi-entity groups)
all_entity_ids = await storage.async_get_all_entities()
for entity_id in all_entity_ids:
# Skip ignored entities
if await storage.async_is_ignored(entity_id):
continue
# Find the device for this climate entity
entity_entry = entity_registry.async_get(entity_id)
_LOGGER.debug(f"Entity entry for {entity_id}: {entity_entry}")
device_id = None
if entity_entry and entity_entry.device_id:
device_id = entity_entry.device_id
_LOGGER.debug(f"Found device {device_id} for {entity_id}")
# Create main temperature rate sensor
_LOGGER.debug(f"Creating derivative sensor for {entity_id}")
sensors.append(ClimateSchedulerRateSensor(hass, entity_id, entity_id, "current_temperature", device_id))
if device_id:
# Find all sensor entities on the same device
device_sensors = [
entry for entry in entity_registry.entities.values()
if entry.device_id == device_id
and entry.domain == "sensor"
and "floor" in entry.entity_id.lower() # Look for "floor" in the entity_id
]
_LOGGER.debug(f"Found {len(device_sensors)} floor sensors for {entity_id}: {[s.entity_id for s in device_sensors]}")
# Create derivative sensors for floor temperature sensors
for floor_sensor in device_sensors:
_LOGGER.debug(f"Creating floor derivative sensor for {entity_id} tracking {floor_sensor.entity_id}")
sensors.append(ClimateSchedulerRateSensor(
hass,
entity_id, # Associated climate entity
floor_sensor.entity_id, # Floor sensor to track
"state", # Use state instead of attribute
device_id # Link to same device
))
else:
_LOGGER.warning(f"No device found for {entity_id}, skipping floor sensor detection")
if sensors:
async_add_entities(sensors, True)
class ClimateSchedulerRateSensor(SensorEntity):
"""Sensor that tracks the rate of temperature change for a climate entity."""
def __init__(
self,
hass: HomeAssistant,
climate_entity_id: str,
source_entity_id: str,
temperature_attribute: str = "current_temperature",
device_id: str = None
) -> None:
"""Initialize the sensor."""
self.hass = hass
self._climate_entity_id = climate_entity_id
self._source_entity_id = source_entity_id
self._temperature_attribute = temperature_attribute
self._device_id = device_id # Store device_id for device_info
# Extract name from entity_id (e.g., climate.bedroom -> bedroom)
entity_name = climate_entity_id.split(".")[-1]
friendly_name = entity_name.replace("_", " ").title()
# Determine if this is a floor sensor (tracking a separate sensor entity)
is_floor = source_entity_id != climate_entity_id
if is_floor:
# For floor sensors, include the floor sensor name in unique_id to avoid collisions
floor_sensor_name = source_entity_id.split(".")[-1]
# Strip any previously-created climate_scheduler prefix to avoid recursive names
if floor_sensor_name.startswith("climate_scheduler_"):
floor_sensor_name = floor_sensor_name[len("climate_scheduler_") :]
# Strip trailing _rate if present
if floor_sensor_name.endswith("_rate"):
floor_sensor_name = floor_sensor_name[: -len("_rate")]
# Prevent duplicated segments like "front_room_front_room_..."
if floor_sensor_name.startswith(f"{entity_name}_"):
floor_sensor_suffix = floor_sensor_name[len(entity_name) + 1 :]
else:
floor_sensor_suffix = floor_sensor_name
suffix = f"{floor_sensor_suffix.replace('_', ' ').title()} Rate"
unique_suffix = f"{floor_sensor_suffix}_rate"
else:
suffix = "Rate"
unique_suffix = "rate"
_LOGGER.debug(f"Creating sensor climate_scheduler_{entity_name}_{unique_suffix} with device_id: {device_id}")
self._attr_name = f"Climate Scheduler {friendly_name} {suffix}"
self._attr_unique_id = f"climate_scheduler_{entity_name}_{unique_suffix}"
# Let Home Assistant manage the `entity_id` from the `unique_id`
self._attr_device_class = None # No standard device class for rate
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = "°C/h"
self._attr_icon = "mdi:thermometer-lines" if not is_floor else "mdi:floor-plan"
# Storage for temperature samples (timestamp, temperature)
self._samples = []
self._attr_native_value = 0.0 # Start at 0 instead of None
# Set device_info during initialization to ensure proper device linking
if device_id:
device_registry = dr.async_get(hass)
device = device_registry.async_get(device_id)
if device:
self._attr_device_info = {
"identifiers": device.identifiers,
}
_LOGGER.debug(f"Linked sensor {self._attr_unique_id} to device {device_id} with identifiers {device.identifiers}")
else:
_LOGGER.error(f"Could not find device {device_id} in registry for sensor {self._attr_unique_id}")
else:
_LOGGER.warning(f"No device_id provided for sensor {self._attr_unique_id}")
async def async_added_to_hass(self) -> None:
"""Register state listener when entity is added."""
# Listen to state changes of the source entity
self.async_on_remove(
async_track_state_change_event(
self.hass,
[self._source_entity_id],
self._async_source_state_changed,
)
)
# Get initial state
state = self.hass.states.get(self._source_entity_id)
if state:
# For separate sensor entities, use the state value
if self._temperature_attribute == "state":
try:
temp = float(state.state)
now = dt_util.utcnow()
self._samples.append((now, temp))
except (ValueError, TypeError):
pass
# For climate entity attributes, use the attribute
elif state.attributes.get(self._temperature_attribute) is not None:
temp = float(state.attributes[self._temperature_attribute])
now = dt_util.utcnow()
self._samples.append((now, temp))
@callback
def _async_source_state_changed(self, event) -> None:
"""Handle source entity state changes."""
new_state = event.data.get("new_state")
if not new_state:
return
try:
# For separate sensor entities, use the state value
if self._temperature_attribute == "state":
temp = float(new_state.state)
# For climate entity attributes, use the attribute
else:
current_temp = new_state.attributes.get(self._temperature_attribute)
if current_temp is None:
return
temp = float(current_temp)
now = dt_util.utcnow()
# Add new sample
self._samples.append((now, temp))
# Keep only last SAMPLE_SIZE samples
if len(self._samples) > SAMPLE_SIZE:
self._samples = self._samples[-SAMPLE_SIZE:]
# Calculate derivative if we have enough samples
if len(self._samples) >= 2:
self._calculate_rate()
self.async_write_ha_state()
except (ValueError, TypeError) as e:
_LOGGER.debug(f"Error processing temperature for {self._climate_entity_id}: {e}")
def _calculate_rate(self) -> None:
"""Calculate the rate of temperature change."""
if len(self._samples) < 2:
self._attr_native_value = 0.0 # Not enough data, rate is 0
return
# Get oldest and newest samples
oldest_time, oldest_temp = self._samples[0]
newest_time, newest_temp = self._samples[-1]
# Calculate time difference in hours
time_diff = (newest_time - oldest_time).total_seconds() / 3600
if time_diff == 0:
self._attr_native_value = 0.0
return
# Calculate temperature change rate (°C per hour)
temp_diff = newest_temp - oldest_temp
rate = temp_diff / time_diff
# Round to 2 decimal places
self._attr_native_value = round(rate, 2)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional state attributes."""
return {
"climate_entity": self._climate_entity_id,
"source_entity": self._source_entity_id,
"temperature_attribute": self._temperature_attribute,
"sample_count": len(self._samples),
"time_window_minutes": 5,
}
class ColdestEntitySensor(SensorEntity):
"""Sensor that shows the coldest climate entity."""
def __init__(
self,
hass: HomeAssistant,
storage,
coordinator
) -> None:
"""Initialize the sensor."""
self.hass = hass
self._storage = storage
self._coordinator = coordinator
self._attr_name = "Climate Scheduler Coldest Entity"
self._attr_unique_id = "climate_scheduler_coldest_entity"
# Let Home Assistant manage the `entity_id` from the `unique_id`
self._attr_device_class = SensorDeviceClass.TEMPERATURE
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
self._attr_icon = "mdi:snowflake"
self._attr_native_value = None
self._coldest_entity_id = None
self._coldest_friendly_name = None
self._remove_listener = None
async def async_added_to_hass(self) -> None:
"""Register state listener when entity is added."""
# Listen to coordinator updates
self._remove_listener = self._coordinator.async_add_listener(self._handle_coordinator_update)
# Initial update
await self._async_update()
async def async_will_remove_from_hass(self) -> None:
"""Unregister listener when entity is removed."""
if self._remove_listener:
self._remove_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.hass.async_create_task(self._async_update())
async def _async_update(self) -> None:
"""Update the sensor value."""
# Get all entities from groups
all_entities = await self._storage.async_get_all_entities()
coldest_temp = None
coldest_entity = None
coldest_name = None
for entity_id in all_entities:
# Skip ignored entities
if await self._storage.async_is_ignored(entity_id):
continue
state = self.hass.states.get(entity_id)
if not state:
continue
# Check if entity has current_temperature attribute
current_temp = state.attributes.get("current_temperature")
if current_temp is None:
continue
try:
temp = float(current_temp)
if coldest_temp is None or temp < coldest_temp:
coldest_temp = temp
coldest_entity = entity_id
coldest_name = state.attributes.get("friendly_name", entity_id)
except (ValueError, TypeError):
continue
self._attr_native_value = coldest_temp
self._coldest_entity_id = coldest_entity
self._coldest_friendly_name = coldest_name
self.async_write_ha_state()
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional state attributes."""
return {
"entity_id": self._coldest_entity_id,
"friendly_name": self._coldest_friendly_name,
}
class WarmestEntitySensor(SensorEntity):
"""Sensor that shows the warmest climate entity."""
def __init__(
self,
hass: HomeAssistant,
storage,
coordinator
) -> None:
"""Initialize the sensor."""
self.hass = hass
self._storage = storage
self._coordinator = coordinator
self._attr_name = "Climate Scheduler Warmest Entity"
self._attr_unique_id = "climate_scheduler_warmest_entity"
# Let Home Assistant manage the `entity_id` from the `unique_id`
self._attr_device_class = SensorDeviceClass.TEMPERATURE
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
self._attr_icon = "mdi:fire"
self._attr_native_value = None
self._warmest_entity_id = None
self._warmest_friendly_name = None
self._remove_listener = None
async def async_added_to_hass(self) -> None:
"""Register state listener when entity is added."""
# Listen to coordinator updates
self._remove_listener = self._coordinator.async_add_listener(self._handle_coordinator_update)
# Initial update
await self._async_update()
async def async_will_remove_from_hass(self) -> None:
"""Unregister listener when entity is removed."""
if self._remove_listener:
self._remove_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.hass.async_create_task(self._async_update())
async def _async_update(self) -> None:
"""Update the sensor value."""
# Get all entities from groups
all_entities = await self._storage.async_get_all_entities()
warmest_temp = None
warmest_entity = None
warmest_name = None
for entity_id in all_entities:
# Skip ignored entities
if await self._storage.async_is_ignored(entity_id):
continue
state = self.hass.states.get(entity_id)
if not state:
continue
# Check if entity has current_temperature attribute
current_temp = state.attributes.get("current_temperature")
if current_temp is None:
continue
try:
temp = float(current_temp)
if warmest_temp is None or temp > warmest_temp:
warmest_temp = temp
warmest_entity = entity_id
warmest_name = state.attributes.get("friendly_name", entity_id)
except (ValueError, TypeError):
continue
self._attr_native_value = warmest_temp
self._warmest_entity_id = warmest_entity
self._warmest_friendly_name = warmest_name
self.async_write_ha_state()
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional state attributes."""
return {
"entity_id": self._warmest_entity_id,
"friendly_name": self._warmest_friendly_name,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,499 @@
# Static service definitions for Climate Scheduler.
# These provide UI documentation and selectors. The services are still
# registered dynamically in Python; keep this file up-to-date when fields
# or selectors change.
recreate_all_sensors:
name: Recreate all sensors
description: >-
Delete ALL Climate Scheduler sensor entities and reload the integration to
recreate them cleanly. Requires confirmation.
fields:
confirm:
description: Must be set to true to confirm deletion of all sensors
example: true
required: true
selector:
boolean: {}
cleanup_malformed_sensors:
name: Cleanup malformed sensors
description: >-
Scan for unexpected Climate Scheduler sensor entities and optionally remove
them. Returns expected and unexpected sensor entity lists.
fields:
delete:
description: Whether to actually delete the unexpected entities (default
is false for dry-run)
required: false
example: true
selector:
boolean: {}
cleanup_orphaned_climate_entities:
name: Cleanup orphaned entities
description: >-
Scan for orphaned Climate Scheduler entities (climate, sensor, and switch entities without matching
groups or climate entities in storage) and optionally remove them. Returns expected count and orphaned entity lists.
fields:
delete:
description: Whether to actually delete the orphaned entities (default
is false for dry-run)
required: false
example: true
selector:
boolean: {}
set_schedule:
name: Set climate schedule
description: Configure temperature schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
example: climate.living_room
selector:
entity:
domain: climate
nodes:
description: >-
List of schedule nodes with time and temperature (JSON string).
required: true
example: '[{"time": "07:00", "temp": 21}, {"time": "23:00", "temp": 18}]'
day:
description: Day of week for this schedule (all_days, mon, tue, wed, thu,
fri, sat, sun, weekday, weekend)
required: false
default: all_days
selector:
text: {}
schedule_mode:
description: Schedule mode (all_days, 5/2, individual)
required: false
default: all_days
selector:
text: {}
get_schedule:
name: Get climate schedule
description: Retrieve the current schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
selector:
entity:
domain: climate
clear_schedule:
name: Clear climate schedule
description: Clear the schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
selector:
entity:
domain: climate
enable_schedule:
name: Enable schedule
description: Enable the schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
selector:
text: {}
disable_schedule:
name: Disable schedule
description: Disable the schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
selector:
text: {}
set_ignored:
name: Set ignored status
description: Mark a schedule target as ignored or not
fields:
schedule_id:
description: Schedule ID
required: true
selector:
text: {}
ignored:
description: True to ignore, False to un-ignore
required: true
selector:
boolean: {}
sync_all:
name: Sync all
description: Force immediate sync of all thermostats managed by Climate Scheduler
fields: {}
create_group:
name: Create group
description: Create a new Climate Scheduler group
fields:
schedule_id:
description: New group name
required: true
selector:
text: {}
delete_group:
name: Delete group
description: Delete an existing group
fields:
schedule_id:
description: Group name to delete
required: true
selector:
text: {}
rename_group:
name: Rename group
description: Rename an existing group
fields:
old_name:
description: Existing group name
required: true
selector:
text: {}
new_name:
description: New group name
required: true
selector:
text: {}
add_to_group:
name: Add entity to group
description: Add a climate entity to a group
fields:
schedule_id:
description: Target group name
required: true
selector:
text: {}
entity_id:
description: Climate entity id to add
required: true
selector:
entity:
domain: climate
remove_from_group:
name: Remove entity from group
description: Remove a climate entity from a group
fields:
schedule_id:
description: Target group name
required: true
selector:
text: {}
entity_id:
description: Climate entity id to remove
required: true
selector:
entity:
domain: climate
get_groups:
name: Get groups
description: Return stored groups data
fields: {}
list_groups:
name: List groups
description: Return a simple list of group names
fields: {}
list_profiles:
name: List profiles
description: Return a list of profiles across all groups
fields: {}
set_group_schedule:
name: Set group schedule
description: Set schedule for a whole group
fields:
schedule_id:
description: Target group name
required: true
selector:
text: {}
nodes:
description: JSON string of schedule nodes
required: true
selector:
text: {}
day:
description: Day identifier
required: false
selector:
text: {}
schedule_mode:
description: Schedule mode
required: false
selector:
text: {}
enable_group:
name: Enable group
description: Enable a group's schedule
fields:
schedule_id:
description: Group name
required: true
selector:
text: {}
disable_group:
name: Disable group
description: Disable a group's schedule
fields:
schedule_id:
description: Group name
required: true
selector:
text: {}
get_settings:
name: Get settings
description: Retrieve current integration settings and version info
fields: {}
save_settings:
name: Save settings
description: Save integration settings (JSON string)
fields:
settings:
description: Settings JSON string
required: true
selector:
text: {}
reload_integration:
name: Reload integration
description: Reload the Climate Scheduler integration
fields: {}
reregister_card:
name: Reregister card
description: >-
Automatically registers the Climate Scheduler card in Lovelace resources.
Removes any existing registrations and creates a fresh entry.
Use this if the card is not appearing in the card picker.
fields:
resource_url:
description: >-
(Optional) Custom URL for the card resource. If not provided,
automatically uses the bundled card with the current integration version.
required: false
example: /climate_scheduler/static/climate-scheduler-card.js
selector:
text: {}
resource_type:
description: Resource type (e.g. `module` or `js`)
required: false
default: module
selector:
text: {}
advance_schedule:
name: Advance schedule
description: Temporarily advance a schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
selector:
text: {}
advance_group:
name: Advance group
description: Advance schedule for a group
fields:
schedule_id:
description: Group name
required: true
selector:
text: {}
cancel_advance:
name: Cancel advance
description: Cancel an advanced schedule for a schedule target
fields:
schedule_id:
description: Schedule ID (entity id)
required: true
selector:
text: {}
get_advance_status:
name: Get advance status
description: Get current advance status for a schedule target
fields:
schedule_id:
description: Schedule ID (entity id)
required: true
selector:
text: {}
clear_advance_history:
name: Clear advance history
description: Clear the advance history for a schedule target
fields:
schedule_id:
description: Schedule ID (entity id)
required: true
selector:
text: {}
test_fire_event:
name: Test fire event
description: Fire a test node_activated event with specific node settings for testing automations
fields:
schedule_id:
description: Group name or entity ID to fire event for
required: true
example: Downstairs
selector:
text: {}
node:
description: Node data (time, temp, hvac_mode, etc.) as JSON string
required: false
example: '{"time": "08:00", "temp": 21, "hvac_mode": "heat"}'
selector:
text: {}
day:
description: Day of week (mon, tue, wed, thu, fri, sat, sun)
required: false
example: mon
selector:
text: {}
create_profile:
name: Create profile
description: Create a profile for a schedule
fields:
schedule_id:
description: Schedule ID (entity id or group id)
required: true
selector:
text: {}
profile_name:
description: New profile name
required: true
selector:
text: {}
delete_profile:
name: Delete profile
description: Delete a profile from a schedule
fields:
schedule_id:
description: Schedule ID (entity id or group id)
required: true
selector:
text: {}
profile_name:
description: Profile name to delete
required: true
selector:
text: {}
rename_profile:
name: Rename profile
description: Rename a profile
fields:
schedule_id:
description: Schedule ID (entity id or group id)
required: true
selector:
text: {}
old_name:
description: Existing profile name
required: true
selector:
text: {}
new_name:
description: New profile name
required: true
selector:
text: {}
set_active_profile:
name: Set active profile
description: Set the active profile for a schedule
fields:
schedule_id:
description: Schedule ID (entity id or group id)
required: true
selector:
text: {}
profile_name:
description: Profile name to set active
required: true
selector:
text: {}
get_profiles:
name: Get profiles
description: Get profiles for a schedule
fields:
schedule_id:
description: Schedule ID (entity id or group id)
required: true
selector:
text: {}
cleanup_derivative_sensors:
name: Cleanup derivative sensors
description: Remove derivative sensors based on auto-creation setting
fields:
confirm_delete_all:
description: Confirm deletion of all derivative sensors
required: false
default: false
example: false
selector:
boolean: {}
cleanup_unmonitored_storage:
name: Cleanup unmonitored storage
description: >-
Remove stale storage references for unmonitored or missing entities,
including obsolete groups, invalid profiles, stale entity references,
and orphaned advance history entries.
fields:
delete:
description: Execute deletion; set false to preview only
required: false
default: false
example: true
selector:
boolean: {}
factory_reset:
name: Factory reset
description: Reset all stored data
fields:
confirm:
description: Must be set to true to confirm factory reset
required: true
selector:
boolean: {}
diagnostics:
name: Run diagnostics
description: >-
Run comprehensive diagnostics to troubleshoot card visibility issues.
Returns integration version, card registration status, and accessibility checks.
Use this when users report they cannot find the card in Lovelace.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,421 @@
"""Switch platform for Climate Scheduler - Exposes schedules in scheduler-component format."""
import logging
import hashlib
from datetime import datetime, time, timedelta
from typing import Any, Dict, List, Optional
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Climate Scheduler switch entities from a config entry."""
from homeassistant.helpers import entity_registry as er
storage = hass.data[DOMAIN]["storage"]
coordinator = hass.data[DOMAIN]["coordinator"]
entity_registry = er.async_get(hass)
# Clean up old switch entities that don't have the token suffix
# (These are from before we added the unique_id token)
old_entities_removed = 0
for entry in list(entity_registry.entities.values()):
if entry.platform != DOMAIN or entry.domain != "switch":
continue
# Old entities have unique_id like "climate_scheduler_schedule_bathroom"
# New entities have unique_id like "climate_scheduler_schedule_climate.bathroom_abc123"
# or "climate_scheduler_schedule_Bathroom_abc123"
if entry.unique_id and entry.unique_id.startswith(f"{DOMAIN}_schedule_"):
# Check if it's missing the token (no underscore followed by 6 hex chars at the end)
parts = entry.unique_id.split("_")
last_part = parts[-1] if parts else ""
# If the last part is not a 6-character hex token, it's an old entity
if len(last_part) != 6 or not all(c in "0123456789abcdef" for c in last_part.lower()):
_LOGGER.info(f"Removing old scheduler entity: {entry.entity_id} (unique_id: {entry.unique_id})")
entity_registry.async_remove(entry.entity_id)
old_entities_removed += 1
if old_entities_removed > 0:
_LOGGER.info(f"Removed {old_entities_removed} old scheduler switch entities")
switches = []
# Create switch entity for each group (including single-entity groups)
all_groups = await storage.async_get_groups()
for group_name, group_data in all_groups.items():
# Skip ignored groups
if group_data.get("ignored", False):
continue
switches.append(ClimateSchedulerSwitch(
hass,
coordinator,
storage,
group_name,
group_data
))
if switches:
async_add_entities(switches, True)
_LOGGER.info(f"Created {len(switches)} scheduler switch entities")
class ClimateSchedulerSwitch(CoordinatorEntity, SwitchEntity):
"""Switch entity that exposes a climate schedule in scheduler-component format."""
def __init__(
self,
hass: HomeAssistant,
coordinator,
storage,
group_name: str,
group_data: Dict[str, Any],
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.hass = hass
self.storage = storage
self._group_name = group_name
self._group_data = group_data
# Generate a stable token for the entity_id (6 chars like scheduler-component)
token = hashlib.md5(group_name.encode()).hexdigest()[:6]
# For single-entity groups, use a cleaner name
if group_name.startswith("__entity_"):
entity_id = group_name.replace("__entity_", "")
self._attr_name = f"Schedule {entity_id.split('.')[-1]}"
self._attr_unique_id = f"{DOMAIN}_schedule_{entity_id}_{token}"
else:
self._attr_name = f"Schedule {group_name}"
self._attr_unique_id = f"{DOMAIN}_schedule_{group_name}_{token}"
self._attr_has_entity_name = False
self._attr_should_poll = True
# Cache for computed attributes
self._cached_next_trigger: Optional[datetime] = None
self._cached_next_slot: Optional[int] = None
self._cached_actions: List[Dict[str, Any]] = []
self._cached_next_entries: List[Dict[str, Any]] = []
@property
def is_on(self) -> bool:
"""Return true if schedule is enabled."""
# Refresh group data from storage
self._refresh_group_data()
return self._group_data.get("enabled", True)
@property
def state(self) -> str:
"""Return the state of the scheduler.
States match scheduler-component:
- off: Schedule is disabled
- on: Schedule is enabled and waiting for next trigger
- triggered: Currently executing (we'll use this briefly after firing)
"""
if not self.is_on:
return "off"
# Check if this was recently triggered (within last minute)
# For now, we'll just return "on" - can enhance later
return "on"
@property
def extra_state_attributes(self) -> Dict[str, Any]:
"""Return scheduler-component compatible attributes."""
self._refresh_group_data()
self._compute_schedule_attributes()
active_profile = self._group_data.get("active_profile", "Default")
profiles = self._group_data.get("profiles", {})
profile_data = profiles.get(active_profile, {})
schedule_mode = profile_data.get("schedule_mode", "all_days")
schedules = profile_data.get("schedules", {})
# Get entities affected by this schedule
entities = self._group_data.get("entities", [])
attrs = {
# Core scheduler-component attributes
"next_trigger": self._cached_next_trigger.isoformat() if self._cached_next_trigger else None,
"next_slot": self._cached_next_slot,
"actions": self._cached_actions,
# Fallback format
"next_entries": self._cached_next_entries,
# Additional metadata
"schedule_mode": schedule_mode,
"schedules": schedules,
"active_profile": active_profile,
"profiles": list(profiles.keys()),
"entities": entities,
# Compatibility fields
"weekdays": self._get_weekdays_list(schedule_mode),
"timeslots": self._cached_actions, # Alias
}
return attrs
def _refresh_group_data(self) -> None:
"""Refresh group data from storage."""
# This is called frequently, so we cache the lookup
import asyncio
if asyncio.iscoroutinefunction(self.storage.async_get_group):
# Can't call async from sync property, so we'll need to handle this differently
# For now, keep the cached version and update on coordinator updates
pass
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
# Refresh group data when coordinator updates
loop = self.hass.loop
if loop and loop.is_running():
loop.create_task(self._async_refresh_group_data())
self.async_write_ha_state()
async def _async_refresh_group_data(self) -> None:
"""Async refresh of group data."""
group_data = await self.storage.async_get_group(self._group_name)
if group_data:
self._group_data = group_data
def _compute_schedule_attributes(self) -> None:
"""Compute next_trigger, next_slot, actions, and next_entries."""
now = dt_util.now()
current_time = now.time()
current_day = now.strftime("%a").lower() # mon, tue, wed, etc.
active_profile = self._group_data.get("active_profile", "Default")
profiles = self._group_data.get("profiles", {})
profile_data = profiles.get(active_profile, {})
schedule_mode = profile_data.get("schedule_mode", "all_days")
schedules = profile_data.get("schedules", {})
# Determine which schedule to use based on mode and current day
current_schedule = self._get_schedule_for_day(schedules, schedule_mode, current_day)
if not current_schedule:
self._cached_next_trigger = None
self._cached_next_slot = None
self._cached_actions = []
self._cached_next_entries = []
return
# Sort nodes by time
sorted_nodes = sorted(current_schedule, key=lambda n: self._time_str_to_minutes(n.get("time", "00:00")))
# Find next node
next_node = None
next_node_index = None
for idx, node in enumerate(sorted_nodes):
node_time_str = node.get("time", "00:00")
node_minutes = self._time_str_to_minutes(node_time_str)
current_minutes = current_time.hour * 60 + current_time.minute
if node_minutes > current_minutes:
next_node = node
next_node_index = idx
break
# If no node found after current time, use first node tomorrow
if next_node is None and sorted_nodes:
next_node = sorted_nodes[0]
next_node_index = 0
# Add one day
next_trigger_dt = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
else:
next_trigger_dt = now.replace(hour=0, minute=0, second=0, microsecond=0)
if next_node:
# Parse time
time_str = next_node.get("time", "00:00")
try:
hours, minutes = time_str.split(":")
next_trigger_dt = next_trigger_dt.replace(hour=int(hours), minute=int(minutes))
except (ValueError, AttributeError):
_LOGGER.warning(f"Invalid time format: {time_str}")
next_trigger_dt = None
self._cached_next_trigger = next_trigger_dt
self._cached_next_slot = next_node_index
# Build actions list for all nodes
entities = self._group_data.get("entities", [])
# If entities list is empty, try to derive from group name for single-entity groups
if not entities and self._group_name.startswith("__entity_"):
entities = [self._group_name.replace("__entity_", "")]
_LOGGER.debug(f"Derived entity from group name: {entities}")
self._cached_actions = []
for node in sorted_nodes:
temp = node.get("temp")
# If still no entities, create a generic action without entity_id
# This allows the data structure to be populated even if entities are missing
if not entities:
action = {
"entity_id": "unknown",
"service": "climate.set_temperature",
"data": {"temperature": temp}
}
# Add HVAC mode if present
if "hvac_mode" in node:
action["data"]["hvac_mode"] = node["hvac_mode"]
# Add preset mode if present
if "preset_mode" in node:
action["service"] = "climate.set_preset_mode"
action["data"] = {"preset_mode": node["preset_mode"]}
self._cached_actions.append(action)
else:
# Normal case: create actions for each entity
for entity_id in entities:
action = {
"entity_id": entity_id,
"service": "climate.set_temperature",
"data": {"temperature": temp}
}
# Add HVAC mode if present
if "hvac_mode" in node:
action["data"]["hvac_mode"] = node["hvac_mode"]
# Add preset mode if present
if "preset_mode" in node:
action["service"] = "climate.set_preset_mode"
action["data"] = {"preset_mode": node["preset_mode"]}
self._cached_actions.append(action)
# Build next_entries (fallback format)
self._cached_next_entries = []
for node in sorted_nodes:
temp = node.get("temp")
time_str = node.get("time", "00:00")
# Calculate absolute datetime for this node
try:
hours, minutes = time_str.split(":")
node_dt = now.replace(hour=int(hours), minute=int(minutes), second=0, microsecond=0)
# If node time is in the past today, it's for tomorrow
if node_dt < now:
node_dt += timedelta(days=1)
entry_actions = []
# If still no entities, create a generic action
if not entities:
entry_actions.append({
"entity_id": "unknown",
"service": "climate.set_temperature",
"data": {"temperature": temp}
})
else:
for entity_id in entities:
entry_actions.append({
"entity_id": entity_id,
"service": "climate.set_temperature",
"data": {"temperature": temp}
})
self._cached_next_entries.append({
"time": node_dt.isoformat(),
"trigger_time": node_dt.isoformat(),
"actions": entry_actions
})
except (ValueError, AttributeError):
continue
else:
self._cached_next_trigger = None
self._cached_next_slot = None
self._cached_actions = []
self._cached_next_entries = []
def _get_schedule_for_day(
self,
schedules: Dict[str, List[Dict[str, Any]]],
schedule_mode: str,
current_day: str
) -> List[Dict[str, Any]]:
"""Get the appropriate schedule for the current day."""
if schedule_mode == "all_days":
return schedules.get("all_days", [])
elif schedule_mode == "5/2":
if current_day in ["mon", "tue", "wed", "thu", "fri"]:
return schedules.get("weekday", [])
else:
return schedules.get("weekend", [])
elif schedule_mode == "individual":
return schedules.get(current_day, [])
else:
return schedules.get("all_days", [])
def _get_weekdays_list(self, schedule_mode: str) -> List[str]:
"""Get weekdays list for scheduler-component compatibility."""
if schedule_mode == "all_days":
return ["daily"]
elif schedule_mode == "5/2":
return ["workday", "weekend"]
elif schedule_mode == "individual":
return ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
else:
return ["daily"]
@staticmethod
def _time_str_to_minutes(time_str: str) -> int:
"""Convert HH:MM string to minutes since midnight."""
try:
hours, minutes = time_str.split(":")
return int(hours) * 60 + int(minutes)
except (ValueError, AttributeError):
return 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable the schedule."""
await self.storage.async_enable_schedule(self._group_name)
await self.coordinator.async_request_refresh()
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable the schedule."""
await self.storage.async_disable_schedule(self._group_name)
await self.coordinator.async_request_refresh()
self.async_write_ha_state()
@property
def device_info(self) -> Dict[str, Any]:
"""Return device info for this scheduler."""
return {
"identifiers": {(DOMAIN, f"scheduler_{self._group_name}")},
"name": self._attr_name,
"manufacturer": "Climate Scheduler",
"model": "Schedule Controller",
"sw_version": "1.0",
}
@@ -0,0 +1,151 @@
{
"title": "Climate Scheduler",
"config": {
"step": {
"user": {
"title": "Configure Climate Scheduler",
"description": "Climate Scheduler is ready to use. No additional configuration needed."
}
}
},
"services": {
"set_schedule": {
"name": "Set climate schedule",
"description": "Configure temperature schedule for a schedule target"
},
"get_schedule": {
"name": "Get climate schedule",
"description": "Retrieve the current schedule for a schedule target"
},
"clear_schedule": {
"name": "Clear climate schedule",
"description": "Clear the schedule for a schedule target"
},
"enable_schedule": {
"name": "Enable climate schedule",
"description": "Enable the schedule for a schedule target"
},
"disable_schedule": {
"name": "Disable climate schedule",
"description": "Disable the schedule for a schedule target"
},
"create_group": {
"name": "Create thermostat group",
"description": "Create a new group to share schedules between multiple thermostats"
},
"delete_group": {
"name": "Delete thermostat group",
"description": "Delete a thermostat group"
},
"add_to_group": {
"name": "Add thermostat to group",
"description": "Add a climate entity to a group"
},
"remove_from_group": {
"name": "Remove thermostat from group",
"description": "Remove a climate entity from a group"
},
"get_groups": {
"name": "Get all groups",
"description": "Retrieve all thermostat groups"
},
"set_group_schedule": {
"name": "Set group schedule",
"description": "Set schedule for all thermostats in a group"
},
"sync_all": {
"name": "Sync all thermostats",
"description": "Force synchronization of all enabled thermostats with their schedules"
},
"enable_group": {
"name": "Enable group schedule",
"description": "Enable automatic scheduling for all thermostats in a group"
},
"disable_group": {
"name": "Disable group schedule",
"description": "Disable automatic scheduling for all thermostats in a group"
},
"get_settings": {
"name": "Get settings",
"description": "Retrieve global integration settings (min/max temp, default schedule, etc.)"
},
"save_settings": {
"name": "Save settings",
"description": "Save global integration settings"
},
"reload_integration": {
"name": "Reload integration (Dev)",
"description": "Reload the Climate Scheduler integration (development only)"
}
,
"reregister_card": {
"name": "Reregister card",
"description": "Automatically registers the Climate Scheduler card in Lovelace resources. Removes any existing registrations and creates a fresh entry."
}
,
"recreate_all_sensors": {
"name": "Recreate all sensors",
"description": "Delete and recreate all derivative sensors"
},
"cleanup_malformed_sensors": {
"name": "Cleanup malformed sensors",
"description": "Scan for unexpected derivative sensors and optionally remove them"
},
"set_ignored": {
"name": "Set ignored",
"description": "Mark a schedule target as ignored or not"
},
"advance_schedule": {
"name": "Advance schedule",
"description": "Temporarily advance a schedule for a schedule target"
},
"advance_group": {
"name": "Advance group",
"description": "Temporarily advance schedules for a group"
},
"cancel_advance": {
"name": "Cancel advance",
"description": "Cancel an advanced schedule action for a schedule target"
},
"get_advance_status": {
"name": "Get advance status",
"description": "Get current advance status for a schedule target"
},
"clear_advance_history": {
"name": "Clear advance history",
"description": "Clear stored advance history for a schedule target"
},
"test_fire_event": {
"name": "Test fire event",
"description": "Fire a test node_activated event with the current active node settings for testing automations"
},
"create_profile": {
"name": "Create profile",
"description": "Create a profile for a schedule"
},
"delete_profile": {
"name": "Delete profile",
"description": "Delete a profile from a schedule"
},
"rename_profile": {
"name": "Rename profile",
"description": "Rename a profile"
},
"set_active_profile": {
"name": "Set active profile",
"description": "Set the active profile for a schedule"
},
"get_profiles": {
"name": "Get profiles",
"description": "Get profiles for a schedule"
},
"cleanup_derivative_sensors": {
"name": "Cleanup derivative sensors",
"description": "Remove derivative sensors based on the auto-creation setting"
},
"factory_reset": {
"name": "Factory reset",
"description": "Reset all Climate Scheduler data to a fresh state (destructive)"
}
}
}
@@ -0,0 +1,151 @@
{
"title": "Climate Scheduler",
"config": {
"step": {
"user": {
"title": "Konfigurácia plánovača klimatizácie",
"description": "Plánovač klimatizácie je pripravený na použitie. Nie je potrebná žiadna ďalšia konfigurácia."
}
}
},
"services": {
"set_schedule": {
"name": "Nastavenie klimatického harmonogramu",
"description": "Konfigurácia teplotného plánu pre cieľový plán"
},
"get_schedule": {
"name": "Získajte klimatický harmonogram",
"description": "Načítať aktuálny rozvrh pre cieľ rozvrhu"
},
"clear_schedule": {
"name": "Vymazať klimatický harmonogram",
"description": "Vymazať plán pre cieľ plánu"
},
"enable_schedule": {
"name": "Povoliť klimatický plán",
"description": "Povoliť plán pre cieľ plánu"
},
"disable_schedule": {
"name": "Zakázať klimatizačný plán",
"description": "Zakázať plán pre cieľ plánu"
},
"create_group": {
"name": "Vytvoriť skupinu termostatov",
"description": "Vytvorte novú skupinu na zdieľanie harmonogramov medzi viacerými termostatmi"
},
"delete_group": {
"name": "Odstrániť skupinu termostatov",
"description": "Odstrániť skupinu termostatov"
},
"add_to_group": {
"name": "Pridať termostat do skupiny",
"description": "Pridanie klimatickej entity do skupiny"
},
"remove_from_group": {
"name": "Odstrániť termostat zo skupiny",
"description": "Odstránenie klimatickej entity zo skupiny"
},
"get_groups": {
"name": "Získať všetky skupiny",
"description": "Načítať všetky skupiny termostatov"
},
"set_group_schedule": {
"name": "Nastaviť rozvrh skupiny",
"description": "Nastavenie plánu pre všetky termostaty v skupine"
},
"sync_all": {
"name": "Synchronizovať všetky termostaty",
"description": "Vynútiť synchronizáciu všetkých povolených termostatov s ich harmonogramami"
},
"enable_group": {
"name": "Povoliť skupinový rozvrh",
"description": "Povoliť automatické plánovanie pre všetky termostaty v skupine"
},
"disable_group": {
"name": "Zakázať skupinový rozvrh",
"description": "Zakázať automatické plánovanie pre všetky termostaty v skupine"
},
"get_settings": {
"name": "Získať nastavenia",
"description": "Získanie globálnych nastavení integrácie (min./max. teplota, predvolený harmonogram atď.)"
},
"save_settings": {
"name": "Uložiť nastavenia",
"description": "Uložiť nastavenia globálnej integrácie"
},
"reload_integration": {
"name": "Integrácia obnovenia (vývojár)",
"description": "Obnoviť integráciu plánovača klimatizácie (iba pre vývojárov)"
}
,
"reregister_card": {
"name": "Znovu zaregistrovať kartu",
"description": "Automaticky zaregistruje kartu Climate Scheduler v zdrojoch Lovelace. Odstráni existujúce registrácie a vytvorí novú."
}
,
"recreate_all_sensors": {
"name": "Znovu vytvorte všetky senzory",
"description": "Odstráňte a znovu vytvorte všetky odvodené senzory"
},
"cleanup_malformed_sensors": {
"name": "Vyčistenie chybných senzorov",
"description": "Vyhľadajte neočakávané derivačné senzory a voliteľne ich odstráňte"
},
"set_ignored": {
"name": "Ignorované",
"description": "Označiť cieľ plánu ako ignorovaný alebo nie"
},
"advance_schedule": {
"name": "Pokročilý rozvrh",
"description": "Dočasný pokročilý plán pre cieľ plánu"
},
"advance_group": {
"name": "Pokročilá skupina",
"description": "Dočasné pokročilé rozvrhy pre skupinu"
},
"cancel_advance": {
"name": "Zrušiť pokročilé",
"description": "Zrušenie rozšírenej akcie plánovania pre cieľ plánovania"
},
"get_advance_status": {
"name": "Získať stav zálohy",
"description": "Získanie aktuálneho stavu zálohy pre cieľ plánu"
},
"clear_advance_history": {
"name": "Vymazať históriu záloh",
"description": "Vymazať uloženú históriu predbežných nastavení pre cieľ plánu"
},
"test_fire_event": {
"name": "Test fire event",
"description": "Fire a test node_activated event with the current active node settings for testing automations"
},
"create_profile": {
"name": "Vytvorriť profil",
"description": "Vytvorenie profilu pre rozvrh"
},
"delete_profile": {
"name": "Zmazať profil",
"description": "Odstránenie profilu z rozvrhu"
},
"rename_profile": {
"name": "Premenovať profil",
"description": "Premenovať profil"
},
"set_active_profile": {
"name": "Nastaviť aktívny profil",
"description": "Nastavenie aktívneho profilu pre plán"
},
"get_profiles": {
"name": "Získať profily",
"description": "Získajte profily pre rozvrh"
},
"cleanup_derivative_sensors": {
"name": "Čistenie derivačných senzorov",
"description": "Odstrániť odvodené senzory na základe nastavenia automatického vytvárania"
},
"factory_reset": {
"name": "Obnovenie továrenských nastavení",
"description": "Obnoviť všetky údaje plánovača klimatizácie do nového stavu (deštruktívne)"
}
}
}
@@ -0,0 +1,446 @@
"""The Intelligent Heating Pilot integration - DDD Architecture.
This module sets up the integration using a clean DDD architecture:
- Domain: Pure business logic (entities, value objects, services)
- Infrastructure: HA adapters (readers, commanders, event bridge)
- Application: Use case orchestration
The coordinator here is reduced to a thin setup/teardown manager.
"""
from __future__ import annotations
import hashlib
import logging
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, Platform
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.util import dt as dt_util
from .const import CONF_IHP_ENABLED, DOMAIN, SERVICE_CALCULATE_ANTICIPATED_START_TIME
from .heating_application import HeatingApplication
from .utils.config_helpers import as_bool
from .view import async_register_http_views
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[str] = [Platform.SENSOR, Platform.SWITCH]
_STARTUP_UPDATE_BASE_DELAY_SECONDS = 5
_STARTUP_UPDATE_JITTER_SECONDS = 55
_STARTUP_EXTRACTION_BASE_DELAY_SECONDS = 120
_STARTUP_EXTRACTION_JITTER_SECONDS = 600
_LATE_UPDATE_BASE_DELAY_SECONDS = 30
_LATE_UPDATE_JITTER_SECONDS = 90
def _stable_jitter_seconds(seed: str, spread_seconds: int) -> int:
"""Return deterministic jitter in [0, spread_seconds] for a given seed."""
if spread_seconds <= 0:
return 0
digest = hashlib.blake2s(seed.encode("utf-8"), digest_size=4).digest()
value = int.from_bytes(digest, "big")
return value % (spread_seconds + 1)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the Intelligent Heating Pilot component."""
hass.data.setdefault(DOMAIN, {})
# Initialize shared recorder access queue (FIFO) for all IHP devices.
# This must be created before any device entry setup to ensure all adapters
# share the same queue and recorder access is serialized across instances.
from .infrastructure.recorder_queue import get_recorder_queue
get_recorder_queue(hass)
# Store hass in http app context for REST API views to access it
hass.http.app["hass"] = hass
# Register HTTP views once at the integration level (not per device)
await async_register_http_views(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Intelligent Heating Pilot from a config entry.
REFACTORED: Now uses HADeviceConfigReader to extract configuration
and injects DeviceConfig into the Coordinator instead of passing
config_entry directly.
Args:
hass: Home Assistant instance
entry: Config entry to set up
Returns:
True if setup succeeded
"""
_LOGGER.debug("Setting up IHP config entry: %s", entry.entry_id)
hass.data.setdefault(DOMAIN, {})
# ============================================================================
# REFACTORING: Create device config reader and extract configuration
# ============================================================================
# Instead of passing config_entry to the Coordinator, we:
# 1. Create HADeviceConfigReader (infrastructure adapter)
# 2. Read DeviceConfig from config_entry (data + options)
# 3. Inject DeviceConfig into Coordinator (dependency injection)
from .infrastructure.adapters.device_config_reader import HADeviceConfigReader
# Create device configuration reader (infrastructure layer)
device_config_reader = HADeviceConfigReader(hass, entry)
# Read complete device configuration (applies "options override data" logic)
try:
device_config = await device_config_reader.get_device_config(entry.entry_id)
except ValueError as err:
_LOGGER.error("Failed to load device configuration: %s", err)
return False
_LOGGER.debug(
"Loaded device configuration: vtherm=%s, schedulers=%d, ihp_enabled=%s",
device_config.vtherm_entity_id,
len(device_config.scheduler_entities),
device_config.ihp_enabled,
)
# ============================================================================
# Create and load coordinator with injected configuration
# ============================================================================
# NEW: Coordinator ONLY receives device_config (pure DDD)
# config_entry is passed later via setup_config_entry_access for options updates
coordinator = HeatingApplication(hass, device_config)
coordinator.setup_config_entry_access(entry)
coordinator._options_snapshot = dict(entry.options)
await coordinator.async_load()
# Store coordinator
hass.data[DOMAIN][entry.entry_id] = coordinator
# Setup event listeners
coordinator.setup_listeners()
def _schedule_startup_work() -> None:
"""Schedule startup work with deterministic per-entry staggering.
This avoids synchronized bursts when many IHP entries start together.
"""
extraction_delay = _STARTUP_EXTRACTION_BASE_DELAY_SECONDS + _stable_jitter_seconds(
f"{entry.entry_id}:extract", _STARTUP_EXTRACTION_JITTER_SECONDS
)
update_delay = _STARTUP_UPDATE_BASE_DELAY_SECONDS + _stable_jitter_seconds(
f"{entry.entry_id}:update", _STARTUP_UPDATE_JITTER_SECONDS
)
@callback
def _run_extraction(_now):
hass.async_create_task(coordinator.async_initialize_cycle_extraction())
@callback
def _run_update(_now):
hass.async_create_task(coordinator.async_update())
entry.async_on_unload(
async_track_point_in_time(
hass,
_run_update,
dt_util.now() + dt_util.dt.timedelta(seconds=update_delay),
)
)
entry.async_on_unload(
async_track_point_in_time(
hass,
_run_extraction,
dt_util.now() + dt_util.dt.timedelta(seconds=extraction_delay),
)
)
_LOGGER.info(
"[%s] Startup workload staggered: update in %ss, extraction in %ss",
entry.entry_id,
update_delay,
extraction_delay,
)
# Wait for HA to be fully started before initializing cycle extraction and first update
# This ensures all entities (especially VTherm and scheduler entities) are available
@callback
def _ha_started(_event):
_LOGGER.info(
"[%s] HA started, initializing cycle extraction and triggering updates", entry.entry_id
)
_schedule_startup_work()
# Schedule initial tasks asynchronously to avoid blocking config flow
# This prevents HA watchdog restart during device creation with scheduler
if hass.is_running:
_LOGGER.debug("[%s] HA already running, scheduling staggered startup work", entry.entry_id)
_schedule_startup_work()
else:
_LOGGER.debug("[%s] Waiting for HA start event before initialization", entry.entry_id)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _ha_started)
# Small delayed update for late attribute population
@callback
def _delayed_update(_now):
_LOGGER.debug("[%s] Delayed update", entry.entry_id)
hass.async_create_task(coordinator.async_update())
late_update_delay = _LATE_UPDATE_BASE_DELAY_SECONDS + _stable_jitter_seconds(
f"{entry.entry_id}:late_update", _LATE_UPDATE_JITTER_SECONDS
)
entry.async_on_unload(
async_track_point_in_time(
hass,
_delayed_update,
dt_util.now() + dt_util.dt.timedelta(seconds=late_update_delay),
)
)
# Register options update listener
entry.async_on_unload(entry.add_update_listener(async_update_options))
# Register services (once per integration, not per device)
def _resolve_coordinator(entity_id: str) -> HeatingApplication:
"""Resolve an IHP coordinator from an entity ID.
Looks up the entity in the entity registry to find its config entry,
then returns the corresponding HeatingApplication coordinator.
Raises:
ServiceValidationError: If the entity or its coordinator cannot be found,
so callers receive immediate actionable feedback in the HA UI.
"""
entity_reg = er.async_get(hass)
entity_entry = entity_reg.async_get(entity_id)
if not entity_entry:
raise ServiceValidationError(
f"Entity '{entity_id}' was not found in the Home Assistant entity registry. "
"Please provide a valid IHP sensor entity ID, for example: "
"sensor.intelligent_heating_pilot_<device_name>_anticipated_start_time."
)
coord = hass.data[DOMAIN].get(entity_entry.config_entry_id)
if not coord or not isinstance(coord, HeatingApplication):
raise ServiceValidationError(
f"No IHP device found for entity '{entity_id}' "
f"(config_entry_id={entity_entry.config_entry_id}). "
"Make sure the IHP integration is properly configured."
)
return coord
if not hass.services.has_service(DOMAIN, "reset_learning"):
async def handle_reset_learning(call: ServiceCall) -> None:
"""Handle reset_learning service.
Resolves the target IHP device from the entity_id provided in the service
call, then delegates to that device's orchestrator. This allows callers
to choose which device to reset when multiple IHP config entries exist.
"""
_LOGGER.debug("Entering handle_reset_learning")
coord = _resolve_coordinator(call.data["entity_id"])
await coord._orchestrator.reset_all_learning_data(
device_id=coord.get_device_id()
)
_LOGGER.info(
"reset_learning: learning data cleared for device %s", coord.get_device_id()
)
reset_learning_schema = vol.Schema(
{
vol.Required("entity_id"): cv.entity_id,
}
)
hass.services.async_register(
DOMAIN,
"reset_learning",
handle_reset_learning,
schema=reset_learning_schema,
)
if not hass.services.has_service(DOMAIN, SERVICE_CALCULATE_ANTICIPATED_START_TIME):
async def handle_calculate_anticipated_start_time(call: ServiceCall):
"""Handle calculate_anticipated_start_time service.
This service calculates the anticipated start time for a given IHP device
to reach a target temperature at a specified time.
Delegates to orchestrator's calculate_anticipation_only() - no business logic here.
"""
_LOGGER.debug("Entering handle_calculate_anticipated_start_time")
# Extract service call parameters
entity_id = call.data["entity_id"]
target_time = call.data["target_time"]
target_temp = call.data.get("target_temp")
# Ensure target_time has timezone
if target_time.tzinfo is None:
target_time = dt_util.as_local(target_time)
# Resolve the coordinator for this device
device_coordinator = _resolve_coordinator(entity_id)
# Get target_temp from service call or VTherm
if target_temp is None:
# Try to get target temp from VTherm using public accessor
vtherm_entity_id = device_coordinator.get_vtherm_entity()
if vtherm_entity_id:
vtherm_state = hass.states.get(vtherm_entity_id)
if vtherm_state:
target_temp = vtherm_state.attributes.get("temperature")
if target_temp is not None:
target_temp = float(target_temp)
# Delegate to orchestrator (pure routing - no business logic)
anticipation_data = await device_coordinator._orchestrator.calculate_anticipation_only(
target_time=target_time,
target_temp=target_temp,
)
# Extract fields from anticipation_data (which is already structured)
anticipated_start_time = anticipation_data.get("anticipated_start_time")
response_target_time = anticipation_data.get("next_schedule_time") or target_time
response_target_temp = anticipation_data.get("next_target_temperature")
if response_target_temp is None:
response_target_temp = target_temp
if anticipated_start_time is None:
_LOGGER.warning("Could not calculate anticipated start time (insufficient data)")
# Return structure with None values
return {
"anticipated_start_time": None,
"target_time": response_target_time.isoformat(),
"target_temp": response_target_temp,
"current_temp": anticipation_data.get("current_temp"),
"estimated_duration_minutes": None,
"learned_heating_slope": anticipation_data.get("learned_heating_slope"),
"confidence_level": None,
}
_LOGGER.info(
"Service calculate_anticipated_start_time: "
"anticipated_start=%s, target_time=%s, target_temp=%.1f°C, "
"current_temp=%.1f°C, LHS=%.2f°C/h, confidence=%.2f",
anticipated_start_time.isoformat(),
response_target_time.isoformat(),
response_target_temp or 0.0,
anticipation_data.get("current_temp") or 0.0,
anticipation_data.get("learned_heating_slope") or 0.0,
anticipation_data.get("confidence_level") or 0.0,
)
# Return the result as service response data
return {
"anticipated_start_time": anticipated_start_time.isoformat(),
"target_time": response_target_time.isoformat(),
"target_temp": response_target_temp,
"current_temp": anticipation_data.get("current_temp"),
"estimated_duration_minutes": anticipation_data.get("estimated_duration_minutes"),
"learned_heating_slope": anticipation_data.get("learned_heating_slope"),
"confidence_level": anticipation_data.get("confidence_level"),
}
# Define service schema
calculate_anticipated_start_time_schema = vol.Schema(
{
vol.Required("entity_id"): cv.entity_id,
vol.Required("target_time"): cv.datetime,
vol.Optional("target_temp"): vol.Coerce(float),
}
)
hass.services.async_register(
DOMAIN,
SERVICE_CALCULATE_ANTICIPATED_START_TIME,
handle_calculate_anticipated_start_time,
schema=calculate_anticipated_start_time_schema,
supports_response=SupportsResponse.ONLY,
)
# Forward setup to platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
coordinator = hass.data[DOMAIN].get(entry.entry_id)
if coordinator:
await coordinator.async_cleanup()
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return bool(unload_ok)
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update options and reload integration."""
from .const import CONF_DATA_RETENTION_DAYS
coordinator: HeatingApplication | None = hass.data[DOMAIN].get(entry.entry_id)
previous_options = (
dict(getattr(coordinator, "_options_snapshot", {}) or {}) if coordinator else {}
)
previous_no_toggle = {k: v for k, v in previous_options.items() if k != CONF_IHP_ENABLED}
current_no_toggle = {k: v for k, v in entry.options.items() if k != CONF_IHP_ENABLED}
ihp_enabled = as_bool(entry.options.get(CONF_IHP_ENABLED), default=True)
# Check if only retention days changed (reconfiguration of cycle refresh)
retention_only_changed = (
coordinator is not None
and previous_no_toggle != current_no_toggle
and len(set(previous_no_toggle.keys()) ^ set(current_no_toggle.keys())) == 1
and CONF_DATA_RETENTION_DAYS
in (set(previous_no_toggle.keys()) ^ set(current_no_toggle.keys()))
)
if retention_only_changed:
# Handle retention change without reload
new_retention_days = int(entry.options.get(CONF_DATA_RETENTION_DAYS, 10))
_LOGGER.info(
"[%s] Data retention changed to %d days, updating cycle refresh",
entry.entry_id,
new_retention_days,
)
if coordinator:
coordinator._options_snapshot = dict(entry.options)
coordinator._ihp_enabled = ihp_enabled
await coordinator.async_notify_retention_change(new_retention_days)
return
# If only the ihp_enabled flag changed, skip full reload
if coordinator and previous_no_toggle == current_no_toggle:
_LOGGER.info("[%s] Options updated (ihp_enabled only), skipping reload", entry.entry_id)
coordinator._options_snapshot = dict(entry.options)
coordinator._ihp_enabled = ihp_enabled
await coordinator.async_update()
return
_LOGGER.info("[%s] Options updated, reloading", entry.entry_id)
if coordinator:
coordinator._options_snapshot = dict(entry.options)
await hass.config_entries.async_reload(entry.entry_id)
# Schedule async update after reload (non-blocking)
coordinator = hass.data[DOMAIN].get(entry.entry_id)
if coordinator:
hass.async_create_task(coordinator.async_update())
@@ -0,0 +1,7 @@
"""Application layer - use case orchestration."""
from __future__ import annotations
from .orchestrator import HeatingOrchestrator
__all__ = ["HeatingOrchestrator"]
@@ -0,0 +1,191 @@
"""Factory for HeatingCycleLifecycleManager - wires dependencies with DDD compliance."""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import TYPE_CHECKING
from ..domain.interfaces.device_config_reader_interface import DeviceConfig
from ..domain.interfaces.historical_data_adapter_interface import IHistoricalDataAdapter
from ..infrastructure.adapters.climate_data_reader import HAClimateDataReader
from ..infrastructure.adapters.entity_attribute_mapper_registry import (
EntityAttributeMapperRegistry,
)
from ..infrastructure.adapters.vtherm_attribute_mapper import VThermAttributeMapper
from ..infrastructure.recorder_queue import get_extraction_semaphore, get_recorder_queue
from .heating_cycle_lifecycle_manager import HeatingCycleLifecycleManager
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..domain.interfaces import IHeatingCycleStorage, ILhsStorage, ITimerScheduler
from ..domain.interfaces.heating_cycle_service_interface import IHeatingCycleService
from .lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class HeatingCycleLifecycleManagerFactory:
"""Factory for creating HeatingCycleLifecycleManager with singleton pattern.
Singleton Pattern:
- **One instance per device_id**: Each IHP device gets its own manager instance
- **Shared across requests**: Multiple calls with same device_id return same instance
- **Thread-safe**: Factory maintains internal registry to track instances
Dependency Wiring:
- Injects all required dependencies (heating_cycle_service, adapters, caches)
- Optionally injects LhsLifecycleManager for cascade updates
- Ensures DDD compliance (domain services, infrastructure adapters)
Usage:
```python
# First call creates instance
manager1 = factory.create(hass, device_config, ...)
# Second call with same device_id returns same instance
manager2 = factory.create(hass, device_config, ...)
assert manager1 is manager2 # True
```
"""
# Class-level registry: device_id -> HeatingCycleLifecycleManager instance
_instances: dict[str, HeatingCycleLifecycleManager] = {}
@classmethod
def create(
cls,
hass: HomeAssistant,
device_config: DeviceConfig,
heating_cycle_service: IHeatingCycleService,
cycle_cache: IHeatingCycleStorage | None = None,
timer_scheduler: ITimerScheduler | None = None,
model_storage: ILhsStorage | None = None,
lhs_lifecycle_manager: LhsLifecycleManager | None = None,
dead_time_updated_callback: Callable[[float], None] | None = None,
on_extraction_complete_callback: Callable[[], None] | None = None,
) -> HeatingCycleLifecycleManager:
"""Create or return existing HeatingCycleLifecycleManager for device_id.
Singleton Behavior:
- If instance exists for device_config.device_id, returns existing instance
- If no instance exists, creates new one and stores in registry
- Registry is class-level, shared across all factory instances
Dependency Injection:
- heating_cycle_service: Domain service for extracting cycles from historical data
- cycle_cache: Infrastructure adapter for persistent cycle storage
- timer_scheduler: Infrastructure adapter for scheduling 24h refresh
- model_storage: Infrastructure adapter for persisting individual cycles
- lhs_lifecycle_manager: Application service for cascade LHS updates
Args:
hass: Home Assistant instance (used for historical data adapters).
device_config: Device configuration (contains device_id for singleton key).
heating_cycle_service: Service for extracting heating cycles.
cycle_cache: Optional persistent cache for incremental cycle storage.
timer_scheduler: Optional scheduler for periodic 24h refresh.
model_storage: Optional persistent storage for individual cycle records.
lhs_lifecycle_manager: Optional LHS manager for cascade updates.
dead_time_updated_callback: Optional callback fired after dead time persistence.
on_extraction_complete_callback: Optional callback fired after cycle extraction completes.
Returns:
Singleton HeatingCycleLifecycleManager instance for the device_id.
"""
device_id = device_config.device_id
# Check if instance already exists
if device_id in cls._instances:
_LOGGER.debug(
"Returning existing HeatingCycleLifecycleManager for device_id=%s", device_id
)
return cls._instances[device_id]
# Create new instance
_LOGGER.debug("Creating new HeatingCycleLifecycleManager for device_id=%s", device_id)
# Wire historical data adapters from hass infrastructure
# Dynamically detect entity type (VTherm vs generic climate) for diagnostics
_LOGGER.debug(
"Setting up historical data adapters for device_id=%s", device_config.device_id
)
vtherm_entity_id = device_config.vtherm_entity_id
_LOGGER.debug("Configured VTherm entity_id: %s", vtherm_entity_id)
# Detect entity type (VTherm or generic climate) for diagnostic logging
entity_type = cls._detect_entity_type(hass, vtherm_entity_id)
_LOGGER.info(
"Detected entity type for %s: %s (auto-detection)",
vtherm_entity_id,
entity_type,
)
climate_adapter = HAClimateDataReader(hass, get_recorder_queue(hass), vtherm_entity_id)
historical_adapters: list[IHistoricalDataAdapter] = [climate_adapter]
_LOGGER.debug(
"Configured %d historical adapter(s) for device_id=%s with dynamic entity detection",
len(historical_adapters),
device_config.device_id,
)
manager = HeatingCycleLifecycleManager(
device_config=device_config,
heating_cycle_service=heating_cycle_service,
historical_adapters=historical_adapters,
heating_cycle_storage=cycle_cache,
timer_scheduler=timer_scheduler,
lhs_storage=model_storage,
lhs_lifecycle_manager=lhs_lifecycle_manager,
dead_time_updated_callback=dead_time_updated_callback,
extraction_semaphore=get_extraction_semaphore(hass),
on_extraction_complete_callback=on_extraction_complete_callback,
)
# Store in registry
cls._instances[device_id] = manager
_LOGGER.debug("Registered HeatingCycleLifecycleManager for device_id=%s", device_id)
return manager
@classmethod
def _detect_entity_type(cls, hass: HomeAssistant, vtherm_entity_id: str) -> str:
"""Detect the type of climate entity (VTherm or generic climate).
Uses EntityAttributeMapperRegistry to auto-detect the entity type.
Used for diagnostic logging and validation.
Args:
hass: Home Assistant instance
vtherm_entity_id: Entity ID to detect
Returns:
String describing the entity type (e.g., "VTherm v8.0+", "Generic Climate")
"""
try:
mapper_registry = EntityAttributeMapperRegistry(hass)
mapper = mapper_registry.get_mapper_for_entity(vtherm_entity_id)
# Identify mapper type for logging
if isinstance(mapper, VThermAttributeMapper):
return "VTherm (Versatile Thermostat)"
return "Generic Climate"
except ValueError as e:
_LOGGER.warning(
"Could not detect entity type for %s: %s",
vtherm_entity_id,
str(e),
)
return "Unknown"
@classmethod
def reset_instances(cls) -> None:
"""Clear singleton registry (for testing only).
Use Case:
Called in test teardown to ensure clean state between tests.
Should NOT be used in production code.
"""
cls._instances = {}
_LOGGER.debug("Reset HeatingCycleLifecycleManager singleton registry")
@@ -0,0 +1,752 @@
"""Lifecycle manager for learned heating slope (LHS) caching and refresh."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Callable
from ..domain.value_objects.heating import HeatingCycle
if TYPE_CHECKING:
from ..domain.interfaces import ILhsStorage, ITimerScheduler
from ..domain.services.contextual_lhs_calculator_service import ContextualLHSCalculatorService
from ..domain.services.global_lhs_calculator_service import GlobalLHSCalculatorService
_LOGGER = logging.getLogger(__name__)
try:
from homeassistant.util import dt as dt_util
except ImportError:
dt_util = None # For testing without HA
class LhsLifecycleManager:
"""Manage global and contextual LHS lifecycle, caching, and refresh.
This manager is a singleton per IHP device (identified by device_id).
It orchestrates:
1. In-memory cache management (_cached_global_lhs, _cached_contextual_lhs)
2. Persistent storage via ILhsStorage
3. Periodic refresh scheduling (24h timer)
4. Cascade updates triggered by HeatingCycleLifecycleManager
Architecture:
- **In-memory cache**: Fast lookups for global and contextual LHS values
- **Model storage (ILhsStorage)**: Persistent storage for LHS values with timestamps
- **Triggered by**: HeatingCycleLifecycleManager when cycles change
Lazy Loading Strategy:
========================
This class implements LAZY LOADING for contextual LHS to optimize startup performance:
**Startup Phase:**
- Global LHS: Loaded eagerly (single read from storage)
- Contextual LHS: Loaded ONLY for current hour (datetime.now().hour)
- Other 23 hours: NOT loaded at startup (deferred to on-demand)
**On-Demand Loading:**
- get_contextual_lhs(): Loads requested hour from storage (if not in memory)
- ensure_contextual_lhs_populated(): Same lazy-load behavior with optional force_recalculate
- Per-hour memory caching: Each hour cached independently after first load
**Bulk Update Methods (intentionally load all 24 hours):**
- on_retention_change(): Recalculates and caches all 24 hours when retention changes
- on_24h_timer(): Recalculates and caches all 24 hours on periodic refresh
- update_contextual_lhs_from_cycles(): Persists all 24 hours when called
Cache Hierarchy (fastest to slowest):
1. Memory cache (_cached_contextual_lhs[hour]): O(1) lookup
2. Storage cache (ILhsStorage.get_cached_contextual_lhs): Single disk read
3. Computation cache (calculate_contextual_lhs_for_hour): On-demand calculation
Lazy Loading Strategy:
========================
This class implements LAZY LOADING for contextual LHS to optimize startup performance:
**Startup Phase:**
- Global LHS: Loaded eagerly (single read from storage)
- Contextual LHS: Loaded ONLY for current hour (datetime.now().hour)
- Other 23 hours: NOT loaded at startup (deferred to on-demand)
**On-Demand Loading:**
- get_contextual_lhs(): Loads requested hour from storage (if not in memory)
- ensure_contextual_lhs_populated(): Same lazy-load behavior with optional force_recalculate
- Per-hour memory caching: Each hour cached independently after first load
**Bulk Update Methods (intentionally load all 24 hours):**
- on_retention_change(): Recalculates and caches all 24 hours when retention changes
- on_24h_timer(): Recalculates and caches all 24 hours on periodic refresh
- update_contextual_lhs_from_cycles(): Persists all 24 hours when called
Cache Hierarchy (fastest to slowest):
1. Memory cache (_cached_contextual_lhs[hour]): O(1) lookup
2. Storage cache (ILhsStorage.get_cached_contextual_lhs): Single disk read
3. Computation cache (calculate_contextual_lhs_for_hour): On-demand calculation
Lifecycle Events:
- startup(): Load global LHS + current hour contextual (lazy loading)
- on_retention_change(cycles): Recalculate ALL 24 hours with new retention window
- on_24h_timer(cycles): Recalculate ALL 24 hours on timer event
- update_global_lhs_from_cycles(cycles): Persist global LHS from cycles
- update_contextual_lhs_from_cycles(cycles): Persist ALL 24 contextual hours from cycles
- cancel(): Cleanup timers and release resources (keep cached data)
- startup(): Load global LHS + current hour contextual (lazy loading)
- on_retention_change(cycles): Recalculate ALL 24 hours with new retention window
- on_24h_timer(cycles): Recalculate ALL 24 hours on timer event
- update_global_lhs_from_cycles(cycles): Persist global LHS from cycles
- update_contextual_lhs_from_cycles(cycles): Persist ALL 24 contextual hours from cycles
- cancel(): Cleanup timers and release resources (keep cached data)
Cascade Pattern:
HeatingCycleLifecycleManager calls update_*_lhs_from_cycles() when:
- startup(): Initial cycles extracted
- on_retention_change(): Cycles re-extracted with new retention
- on_24h_timer(): Cycles refreshed with latest data
"""
def __init__(
self,
model_storage: ILhsStorage,
global_lhs_calculator: GlobalLHSCalculatorService,
contextual_lhs_calculator: ContextualLHSCalculatorService,
timer_scheduler: ITimerScheduler | None = None,
) -> None:
"""Initialize the lifecycle manager.
Note: This should be instantiated via LhsLifecycleManagerFactory
to ensure singleton behavior per device_id.
Args:
model_storage: Persistent storage adapter for cached LHS values.
global_lhs_calculator: Domain service for computing global LHS from cycles.
contextual_lhs_calculator: Domain service for computing contextual LHS by hour from cycles.
timer_scheduler: Optional scheduler for periodic 24h refresh tasks.
"""
self._model_storage = model_storage
self._global_lhs_calculator = global_lhs_calculator
self._contextual_lhs_calculator = contextual_lhs_calculator
self._timer_scheduler = timer_scheduler
# In-memory caches for fast repeated lookups (avoids disk I/O)
# These are loaded from storage on startup and invalidated on updates
self._cached_global_lhs: float | None = None # Global LHS value
self._cached_contextual_lhs: dict[int, float] = {} # hour (0-23) -> LHS value
self._timer_cancel_func: Callable[[], None] | None = None
async def startup(self) -> None:
"""Initialize LHS caches and schedule periodic refresh.
Lifecycle Event Flow:
1. Load cached global LHS from storage memory cache
2. Load cached contextual LHS for CURRENT HOUR ONLY (lazy loading)
3. Schedule 24h timer for automatic refresh (if scheduler provided)
Lazy Loading Strategy:
This method implements lazy loading to optimize startup performance:
- **Current hour**: Loaded eagerly during startup (most frequently used)
- **Other 23 hours**: Loaded on-demand via get_contextual_lhs() or ensure_contextual_lhs_populated()
- **Memory cache**: Per-hour caching in _cached_contextual_lhs (fast lookups)
- **Storage cache**: Persistent on-disk cache (fallback if not in memory)
- **Computation cache**: Computed on-demand when no cached value exists
Cache Strategy:
- **Reads from storage**: model_storage.get_cached_global_lhs()
- **Reads from storage**: model_storage.get_cached_contextual_lhs() for CURRENT HOUR ONLY
- **Writes to memory**: Populates _cached_global_lhs and _cached_contextual_lhs[current_hour]
- **Does NOT write to storage**: Only loads existing cached values
- **Does NOT compute**: Uses cached values or returns defaults via get_* methods
Note:
- Initial LHS values are computed and stored by HeatingCycleLifecycleManager during its startup
- Bulk updates (on_retention_change(), on_24h_timer()) intentionally load all 24 hours
- On-demand loads (get_contextual_lhs(), ensure_contextual_lhs_populated()) load per-hour
Returns:
None.
"""
_LOGGER.debug("Entering LhsLifecycleManager.startup")
try:
# Load cached global LHS
cached_global_lhs = await self._model_storage.get_cached_global_lhs()
if cached_global_lhs is not None:
# Extract value from LHSCacheEntry
lhs_value = cached_global_lhs.value
self._cached_global_lhs = lhs_value
_LOGGER.debug("Loaded cached global LHS: %.2f °C/h", lhs_value)
# Lazy Loading: Load contextual LHS for current hour ONLY
# Other hours will be loaded on-demand via get_contextual_lhs() or ensure_contextual_lhs_populated()
current_hour = datetime.now().hour if dt_util is None else dt_util.now().hour
_LOGGER.debug(
"Loading contextual LHS for current hour: %d (lazy loading enabled)", current_hour
)
cached_contextual = await self._model_storage.get_cached_contextual_lhs(current_hour)
if cached_contextual is not None:
# Extract value from LHSCacheEntry
lhs_value = cached_contextual.value
self._cached_contextual_lhs[current_hour] = lhs_value
_LOGGER.debug(
"Loaded cached contextual LHS for current hour %d: %.2f °C/h",
current_hour,
lhs_value,
)
else:
_LOGGER.debug(
"No cached contextual LHS for current hour %d; will load on-demand",
current_hour,
)
_LOGGER.info(
"LHS startup complete: loaded global LHS and current hour contextual LHS (lazy loading enabled)"
)
except Exception as exc:
_LOGGER.error("Error during startup: %s", exc)
# Don't re-raise - continue with defaults
# Schedule 24h timer for automatic refresh
if self._timer_scheduler is not None:
if dt_util is not None:
next_refresh = dt_util.now() + timedelta(hours=24)
else:
next_refresh = datetime.now() + timedelta(hours=24)
# Timer callback: no-op placeholder (cycles provided by HeatingCycleLifecycleManager)
async def noop_callback() -> None:
"""Placeholder timer callback - actual update comes from HCLM."""
pass
self._timer_cancel_func = self._timer_scheduler.schedule_timer(
next_refresh, noop_callback
)
_LOGGER.debug("Scheduled 24h LHS refresh timer for %s", next_refresh.isoformat())
_LOGGER.debug("Exiting LhsLifecycleManager.startup")
async def on_retention_change(self, cycles: list[HeatingCycle]) -> None:
"""Handle retention configuration changes.
Lifecycle Event Flow (triggered by HeatingCycleLifecycleManager):
1. HeatingCycleLifecycleManager re-extracts cycles for new retention window
2. HeatingCycleLifecycleManager calls this method with the new cycles
3. Invalidate in-memory caches FIRST (prevents use of stale data)
4. Recalculate global LHS from provided cycles
5. Recalculate contextual LHS (by hour) from provided cycles
6. Persist new LHS values to storage
Cache Strategy:
- **Invalidates memory FIRST**: Clears _cached_global_lhs and _cached_contextual_lhs
- **Receives cycles from**: HeatingCycleLifecycleManager.on_retention_change()
- **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
- **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
- **Writes to storage**: model_storage.set_cached_global_lhs()
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value)
Args:
cycles: Heating cycles extracted for the new retention window.
Returns:
None.
"""
from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
_LOGGER.debug("Entering LhsLifecycleManager.on_retention_change")
_LOGGER.debug("Recalculating LHS from %d cycles after retention change", len(cycles))
# Step 1: Invalidate in-memory caches FIRST (before any computation)
self._cached_global_lhs = None
self._cached_contextual_lhs = {}
_LOGGER.debug("Invalidated in-memory caches before recalculation")
# Step 2: Recalculate global LHS from provided cycles
global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)
# Validate: LHS must be realistically positive (>= 0.5°C/h)
if global_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
global_lhs,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
global_lhs = DEFAULT_LEARNED_SLOPE
updated_at = dt_util.now() if dt_util is not None else datetime.now()
await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
_LOGGER.info("Recalculated global LHS: %.2f °C/h", global_lhs)
# Step 3: Recalculate contextual LHS from provided cycles
contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
cycles
)
for hour, lhs_value in contextual_lhs_by_hour.items():
if lhs_value is not None and lhs_value > 0:
await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
_LOGGER.debug("Recalculated contextual LHS for %d hours", len(contextual_lhs_by_hour))
_LOGGER.debug("Exiting LhsLifecycleManager.on_retention_change")
async def on_24h_timer(self, cycles: list[HeatingCycle]) -> None:
"""Handle periodic 24h refresh execution.
Lifecycle Event Flow (triggered by HeatingCycleLifecycleManager):
1. HeatingCycleLifecycleManager extracts fresh cycles for retention window
2. HeatingCycleLifecycleManager calls this method with the fresh cycles
3. Recalculate global LHS from provided cycles
4. Recalculate contextual LHS (by hour) from provided cycles
5. Persist new LHS values to storage
6. Invalidate in-memory caches (will reload from storage on next access)
Cache Strategy:
- **Receives cycles from**: HeatingCycleLifecycleManager.on_24h_timer()
- **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
- **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
- **Writes to storage**: model_storage.set_cached_global_lhs()
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value)
- **Invalidates memory**: Sets _cached_global_lhs = None, _cached_contextual_lhs = {}
Args:
cycles: Heating cycles extracted for the current retention window.
Returns:
None.
"""
from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
_LOGGER.debug("Entering LhsLifecycleManager.on_24h_timer")
_LOGGER.info("24h LHS refresh timer triggered")
# Recalculate global LHS from provided cycles
global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)
# Validate: LHS must be realistically positive (>= 0.5°C/h)
if global_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
global_lhs,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
global_lhs = DEFAULT_LEARNED_SLOPE
updated_at = dt_util.now() if dt_util is not None else datetime.now()
await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
# Invalidate cache to ensure fresh load
self._cached_global_lhs = None
_LOGGER.info("Refreshed global LHS: %.2f °C/h", global_lhs)
# Recalculate contextual LHS from provided cycles
contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
cycles
)
for hour, lhs_value in contextual_lhs_by_hour.items():
if lhs_value is not None and lhs_value > 0:
await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
# Invalidate cache to ensure fresh load
self._cached_contextual_lhs = {}
_LOGGER.debug("Exiting LhsLifecycleManager.on_24h_timer")
async def get_global_lhs(self) -> float:
"""Return the global learned heating slope (LHS).
Cache Strategy (read-only, optimized with memory cache):
- **Reads from memory first**: Checks _cached_global_lhs
- **On memory cache hit**: Returns immediately (fast path)
- **On memory cache miss**: Loads from model_storage.get_cached_global_lhs()
- **Writes to memory**: Caches loaded value in _cached_global_lhs
- **Validation**: Returns default if cached value is invalid (<=0 or None)
- **Does NOT write to storage**: Read-only operation
Use Case:
Called frequently during anticipation calculations to get the baseline heating rate.
Memory cache ensures fast lookups without repeated disk I/O.
Returns:
The cached or default global LHS in C/hour.
"""
from ..domain.constants import DEFAULT_LEARNED_SLOPE
_LOGGER.debug("Entering LhsLifecycleManager.get_global_lhs")
# Check in-memory cache first (fast path)
if self._cached_global_lhs is not None:
_LOGGER.debug(
"Returning in-memory cached global LHS: %.2f °C/h", self._cached_global_lhs
)
_LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
return self._cached_global_lhs
# Not in memory cache, load from storage
cached_entry = await self._model_storage.get_cached_global_lhs()
# Validate cached value is positive
if cached_entry is not None:
cached_lhs = cached_entry.value
if cached_lhs > 0:
# Cache in memory for subsequent calls
self._cached_global_lhs = cached_lhs
_LOGGER.debug("Loaded from storage and cached global LHS: %.2f °C/h", cached_lhs)
_LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
return cached_lhs
# Return default if cache invalid or missing
_LOGGER.debug(
"No valid cached global LHS, returning default: %.2f °C/h", DEFAULT_LEARNED_SLOPE
)
_LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
return DEFAULT_LEARNED_SLOPE
async def cancel(self) -> None:
"""Cancel any scheduled refresh work and release resources.
Cache Strategy:
- **Memory cache**: NOT cleared (remains valid until next update)
- **Storage cache**: NOT cleared (persistent data remains)
- **Timers**: Cancelled to stop periodic refresh
Use Case:
Called when the IHP device is being shut down or removed.
Does NOT clear learned data, only stops active timers.
Returns:
None.
"""
_LOGGER.debug("Entering LhsLifecycleManager.cancel")
# Cancel timer if scheduled
if self._timer_cancel_func is not None:
try:
self._timer_cancel_func()
_LOGGER.debug("Cancelled scheduled timer")
except Exception as exc:
_LOGGER.error("Error cancelling timer: %s", exc)
finally:
self._timer_cancel_func = None
_LOGGER.debug("Exiting LhsLifecycleManager.cancel")
async def get_contextual_lhs(
self,
target_time: datetime,
cycles: list[HeatingCycle],
) -> float:
"""Return contextual LHS for a target time.
Cache Strategy (read-only, optimized with memory cache per hour):
- **Reads from memory first**: Checks _cached_contextual_lhs[target_hour]
- **On memory cache hit**: Returns immediately (fast path)
- **On memory cache miss**: Loads from model_storage.get_cached_contextual_lhs(hour)
- **If no storage cache**: Computes from provided cycles
- **Writes to memory**: Caches loaded/computed value in _cached_contextual_lhs[hour]
- **Falls back to global LHS**: If no contextual data exists for the hour
- **Does NOT write to storage**: Read-only operation (use update_contextual_lhs_from_cycles)
Use Case:
Called during anticipation calculations to get hour-specific heating rates.
Memory cache per hour ensures fast lookups without repeated disk I/O.
Args:
target_time: Target datetime used to select the contextual hour (0-23).
cycles: Heating cycles used to compute contextual LHS when not cached.
Returns:
Contextual LHS for the target hour in C/hour, or global LHS fallback.
"""
_LOGGER.debug("Entering LhsLifecycleManager.get_contextual_lhs")
target_hour = target_time.hour
_LOGGER.debug("Getting contextual LHS for hour %d", target_hour)
# Check in-memory cache first (fast path)
if target_hour in self._cached_contextual_lhs:
cached_value = self._cached_contextual_lhs[target_hour]
_LOGGER.debug(
"Returning in-memory cached contextual LHS for hour %d: %.2f °C/h",
target_hour,
cached_value,
)
_LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
return cached_value
# Not in memory cache, try to get from storage
cached_entry = await self._model_storage.get_cached_contextual_lhs(target_hour)
if cached_entry is not None:
cached_contextual = cached_entry.value
# Cache in memory for subsequent calls
self._cached_contextual_lhs[target_hour] = cached_contextual
_LOGGER.debug(
"Loaded from storage and cached contextual LHS for hour %d: %.2f °C/h",
target_hour,
cached_contextual,
)
_LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
return cached_contextual
# No cache, compute contextual LHS for THIS HOUR ONLY (not all 24)
_LOGGER.debug("No cached contextual LHS for hour %d, computing from cycles", target_hour)
computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
cycles, target_hour
)
# If contextual LHS is None or invalid (< 0.5°C/h), fallback to global LHS
from ..domain.constants import MINIMUM_REALISTIC_LHS
if computed_lhs is None or computed_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.debug(
"Contextual LHS for hour %d is invalid (%.2f°C/h < %.2f°C/h), falling back to global LHS",
target_hour,
computed_lhs or 0,
MINIMUM_REALISTIC_LHS,
)
return await self.get_global_lhs()
# Cache computed value in memory
self._cached_contextual_lhs[target_hour] = computed_lhs
_LOGGER.debug(
"Computed and cached contextual LHS for hour %d: %.2f °C/h",
target_hour,
computed_lhs,
)
_LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
return computed_lhs
async def update_global_lhs_from_cycles(self, cycles: list[HeatingCycle]) -> float:
"""Recalculate and persist global LHS from cycles.
Lifecycle Event (triggered by HeatingCycleLifecycleManager):
This is called when:
- HeatingCycleLifecycleManager.startup(): Initial cycles extracted
- HeatingCycleLifecycleManager.on_retention_change(): Cycles re-extracted
- HeatingCycleLifecycleManager.on_24h_timer(): Cycles refreshed
Cache Strategy (write operation):
- **Receives cycles from**: HeatingCycleLifecycleManager
- **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
- **Writes to storage**: model_storage.set_cached_global_lhs(lhs, timestamp)
- **Invalidates memory**: Sets _cached_global_lhs = None
- **Next read**: get_global_lhs() will reload from storage into memory
Args:
cycles: Heating cycles used to compute the global LHS.
Returns:
The computed and persisted global LHS in C/hour.
"""
from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
_LOGGER.debug("Entering LhsLifecycleManager.update_global_lhs_from_cycles")
_LOGGER.debug("Updating global LHS from %d cycles", len(cycles))
try:
global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)
# Validate: LHS must be realistically positive (>= 0.5°C/h)
if global_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
global_lhs,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
global_lhs = DEFAULT_LEARNED_SLOPE
updated_at = dt_util.now() if dt_util is not None else datetime.now()
await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
# Update in-memory cache with new value for fast subsequent access
self._cached_global_lhs = global_lhs
_LOGGER.debug("Updated global LHS in-memory cache: %.2f °C/h", global_lhs)
_LOGGER.info("Updated global LHS: %.2f °C/h", global_lhs)
_LOGGER.debug("Exiting LhsLifecycleManager.update_global_lhs_from_cycles")
return global_lhs
except Exception as exc:
_LOGGER.error("Error calculating global LHS: %s", exc)
_LOGGER.debug("Exiting LhsLifecycleManager.update_global_lhs_from_cycles")
return DEFAULT_LEARNED_SLOPE
async def update_contextual_lhs_from_cycles(
self,
cycles: list[HeatingCycle],
) -> dict[int, float | None]:
"""Recalculate and persist contextual LHS for all hours.
Lifecycle Event (triggered by HeatingCycleLifecycleManager):
This is called when:
- HeatingCycleLifecycleManager.startup(): Initial cycles extracted
- HeatingCycleLifecycleManager.on_retention_change(): Cycles re-extracted
- HeatingCycleLifecycleManager.on_24h_timer(): Cycles refreshed
Cache Strategy (write operation):
- **Receives cycles from**: HeatingCycleLifecycleManager
- **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, lhs, timestamp) for each hour
- **Invalidates memory**: Sets _cached_contextual_lhs = {}
- **Next read**: get_contextual_lhs() will reload from storage into memory per hour
Args:
cycles: Heating cycles used to compute contextual LHS by hour.
Returns:
Mapping of hour (0-23) to LHS in C/hour, or None when no data exists.
"""
_LOGGER.debug("Entering LhsLifecycleManager.update_contextual_lhs_from_cycles")
_LOGGER.debug("Updating contextual LHS from %d cycles", len(cycles))
contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
cycles
)
# Persist non-None values with timestamp
updated_at = dt_util.now() if dt_util is not None else datetime.now()
persisted_count = 0
for hour, lhs_value in contextual_lhs_by_hour.items():
if lhs_value is not None:
await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
persisted_count += 1
# Update in-memory cache with new values for fast subsequent access
# Only cache non-None values
self._cached_contextual_lhs = {
hour: lhs for hour, lhs in contextual_lhs_by_hour.items() if lhs is not None
}
_LOGGER.debug(
"Updated contextual LHS in-memory cache for %d hours", len(self._cached_contextual_lhs)
)
_LOGGER.info("Updated contextual LHS for %d hours", persisted_count)
_LOGGER.debug("Exiting LhsLifecycleManager.update_contextual_lhs_from_cycles")
return contextual_lhs_by_hour
async def ensure_contextual_lhs_populated(
self,
target_hour: int,
cycles: list[HeatingCycle],
force_recalculate: bool = False,
) -> float:
"""Ensure contextual LHS is populated for a specific hour.
Lazy Population Strategy:
This method is called when anticipation calculation needs LHS for a specific hour
but it's not in the cache (memory or storage).
Cache Strategy (write operation with lazy loading):
- **If force_recalculate=True**: Skip cache checks, recompute immediately
- **Reads from memory first**: Checks _cached_contextual_lhs[target_hour]
- **On memory cache hit**: Returns immediately (fast path)
- **Reads from storage**: Tries model_storage.get_cached_contextual_lhs(hour)
- **On storage cache hit**: Loads into memory, returns
- **On cache miss**: Computes from provided cycles
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value, timestamp)
- **Writes to memory**: Updates _cached_contextual_lhs[hour]
- **Fallback**: Returns global LHS if no contextual data exists
Use Case:
Called during anticipation calculations when:
- Cache is cold (first run after startup)
- Hour-specific LHS was never calculated
- Explicit refresh requested (force_recalculate=True)
Args:
target_hour: Hour to ensure LHS is populated for (0-23).
cycles: Heating cycles to use if computation is needed.
force_recalculate: If True, bypass cache and recompute from cycles.
Returns:
Contextual LHS for the target hour in C/hour, or global LHS fallback.
"""
_LOGGER.debug("Entering LhsLifecycleManager.ensure_contextual_lhs_populated")
_LOGGER.debug(
"Ensuring contextual LHS populated for hour %d (force_recalculate=%s)",
target_hour,
force_recalculate,
)
# If force_recalculate, skip cache checks and recompute THIS HOUR ONLY
if force_recalculate:
_LOGGER.debug("Force recalculate enabled, bypassing cache for hour %d", target_hour)
computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
cycles, target_hour
)
# Persist if value exists
if computed_lhs is not None:
updated_at = dt_util.now() if dt_util is not None else datetime.now()
await self._model_storage.set_cached_contextual_lhs(
target_hour, computed_lhs, updated_at
)
# Update memory cache
self._cached_contextual_lhs[target_hour] = computed_lhs
_LOGGER.debug(
"Forced recalculation: contextual LHS for hour %d: %.2f °C/h",
target_hour,
computed_lhs,
)
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return computed_lhs
# No contextual data, fallback to global LHS
_LOGGER.debug("No contextual LHS for hour %d, falling back to global", target_hour)
global_lhs = await self.get_global_lhs()
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return global_lhs
# Check memory cache first (fast path)
if target_hour in self._cached_contextual_lhs:
cached_value = self._cached_contextual_lhs[target_hour]
_LOGGER.debug(
"Contextual LHS already in memory cache for hour %d: %.2f °C/h",
target_hour,
cached_value,
)
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return cached_value
# Check storage cache
cached_entry = await self._model_storage.get_cached_contextual_lhs(target_hour)
if cached_entry is not None:
cached_contextual = cached_entry.value
# Load into memory cache for subsequent calls
self._cached_contextual_lhs[target_hour] = cached_contextual
_LOGGER.debug(
"Loaded contextual LHS from storage for hour %d: %.2f °C/h",
target_hour,
cached_contextual,
)
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return cached_contextual
# No cache, compute contextual LHS for THIS HOUR ONLY (not all 24)
_LOGGER.debug("No cached contextual LHS for hour %d, computing from cycles", target_hour)
computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
cycles, target_hour
)
# If contextual LHS exists, persist and cache
if computed_lhs is not None:
updated_at = dt_util.now() if dt_util is not None else datetime.now()
await self._model_storage.set_cached_contextual_lhs(
target_hour, computed_lhs, updated_at
)
# Cache in memory for subsequent calls
self._cached_contextual_lhs[target_hour] = computed_lhs
_LOGGER.debug(
"Computed and cached contextual LHS for hour %d: %.2f °C/h",
target_hour,
computed_lhs,
)
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return computed_lhs
# No contextual data, fallback to global LHS
_LOGGER.debug("No contextual LHS for hour %d, falling back to global LHS", target_hour)
global_lhs = await self.get_global_lhs()
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return global_lhs
@@ -0,0 +1,113 @@
"""Factory for LhsLifecycleManager - wires dependencies with DDD compliance."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .lhs_lifecycle_manager import LhsLifecycleManager
if TYPE_CHECKING:
from ..domain.interfaces import ILhsStorage, ITimerScheduler
from ..domain.services.contextual_lhs_calculator_service import ContextualLHSCalculatorService
from ..domain.services.global_lhs_calculator_service import GlobalLHSCalculatorService
_LOGGER = logging.getLogger(__name__)
class LhsLifecycleManagerFactory:
"""Factory for creating LhsLifecycleManager with singleton pattern.
Singleton Pattern:
- **One instance per device_id**: Each IHP device gets its own manager instance
- **Shared across requests**: Multiple calls with same device_id return same instance
- **Thread-safe**: Factory maintains internal registry to track instances
Note on device_id:
Since LhsLifecycleManager doesn't directly hold device_id, the singleton key
is derived from the model_storage instance (assuming one storage per device).
For proper device-level isolation, ensure each device has its own ILhsStorage.
Dependency Wiring:
- Injects all required dependencies (calculators, storage, scheduler)
- Ensures DDD compliance (domain services, infrastructure adapters)
Usage:
```python
# First call creates instance
manager1 = factory.create(storage, global_calc, contextual_calc, ...)
# Second call with same storage returns same instance
manager2 = factory.create(storage, global_calc, contextual_calc, ...)
assert manager1 is manager2 # True
```
"""
# Class-level registry: storage_id -> LhsLifecycleManager instance
# Using id(model_storage) as key since LHS manager doesn't have device_id directly
_instances: dict[int, LhsLifecycleManager] = {}
@classmethod
def create(
cls,
model_storage: ILhsStorage,
global_lhs_calculator: GlobalLHSCalculatorService,
contextual_lhs_calculator: ContextualLHSCalculatorService,
timer_scheduler: ITimerScheduler | None = None,
) -> LhsLifecycleManager:
"""Create or return existing LhsLifecycleManager for model_storage.
Singleton Behavior:
- Uses id(model_storage) as singleton key (assumes one storage per device)
- If instance exists for this storage, returns existing instance
- If no instance exists, creates new one and stores in registry
- Registry is class-level, shared across all factory instances
Dependency Injection:
- model_storage: Infrastructure adapter for persistent LHS storage
- global_lhs_calculator: Domain service for computing global LHS from cycles
- contextual_lhs_calculator: Domain service for computing contextual LHS by hour
- timer_scheduler: Infrastructure adapter for scheduling 24h refresh
Args:
model_storage: Persistent storage adapter for cached LHS values.
global_lhs_calculator: Service for computing global LHS.
contextual_lhs_calculator: Service for computing contextual LHS by hour.
timer_scheduler: Optional scheduler for periodic 24h refresh.
Returns:
Singleton LhsLifecycleManager instance for the model_storage.
"""
storage_id = id(model_storage)
# Check if instance already exists
if storage_id in cls._instances:
_LOGGER.debug("Returning existing LhsLifecycleManager for storage_id=%s", storage_id)
return cls._instances[storage_id]
# Create new instance
_LOGGER.debug("Creating new LhsLifecycleManager for storage_id=%s", storage_id)
manager = LhsLifecycleManager(
model_storage=model_storage,
global_lhs_calculator=global_lhs_calculator,
contextual_lhs_calculator=contextual_lhs_calculator,
timer_scheduler=timer_scheduler,
)
# Store in registry
cls._instances[storage_id] = manager
_LOGGER.debug("Registered LhsLifecycleManager for storage_id=%s", storage_id)
return manager
@classmethod
def reset_instances(cls) -> None:
"""Clear singleton registry (for testing only).
Use Case:
Called in test teardown to ensure clean state between tests.
Should NOT be used in production code.
"""
cls._instances = {}
_LOGGER.debug("Reset LhsLifecycleManager singleton registry")
@@ -0,0 +1,185 @@
"""Heating Orchestrator.
The orchestrator composes use cases to implement complex workflows.
It coordinates multiple use cases but contains NO business logic itself.
This is the main entry point for heating operations from the infrastructure layer.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .use_cases import (
CalculateAnticipationUseCase,
CheckOvershootRiskUseCase,
ControlPreheatingUseCase,
ScheduleAnticipationActionUseCase,
UpdateCacheDataUseCase,
)
if TYPE_CHECKING:
from datetime import datetime
from typing import Any
_LOGGER = logging.getLogger(__name__)
class HeatingOrchestrator:
"""Orchestrates heating use cases.
The orchestrator is responsible for:
1. Composing use cases to implement workflows
2. Coordinating the execution order of use cases
3. Managing cross-use-case dependencies
NO business logic - pure composition and coordination.
"""
def __init__(
self,
calculate_anticipation: CalculateAnticipationUseCase,
control_preheating: ControlPreheatingUseCase,
schedule_anticipation_action: ScheduleAnticipationActionUseCase,
check_overshoot_risk: CheckOvershootRiskUseCase,
update_cache: UpdateCacheDataUseCase,
) -> None:
"""Initialize the orchestrator with use cases.
Args:
calculate_anticipation: Use case for calculating anticipation data
control_preheating: Use case for controlling preheating
schedule_anticipation_action: Use case for scheduling anticipation actions
check_overshoot_risk: Use case for checking overshoot risk
update_cache: Use case for updating cache
"""
_LOGGER.debug("Initializing HeatingOrchestrator")
self._calculate_anticipation = calculate_anticipation
self._control_preheating = control_preheating
self._schedule_anticipation_action = schedule_anticipation_action
self._check_overshoot_risk = check_overshoot_risk
self._update_cache = update_cache
async def calculate_anticipation_only(
self,
target_time: datetime | None = None,
target_temp: float | None = None,
) -> dict:
"""Calculate anticipation data only (no scheduling).
For users who use the service without a scheduler (via REST API).
Args:
target_time: Target time for heating
target_temp: Target temperature
Returns:
Dict with anticipation data
"""
_LOGGER.debug("Entering HeatingOrchestrator.calculate_anticipation_only()")
result = await self._calculate_anticipation.calculate_anticipation_datas(
target_time=target_time,
target_temp=target_temp,
)
_LOGGER.debug("Exiting calculate_anticipation_only() -> data")
return result
async def disable_preheating(self, scheduler_entity_id: str) -> None:
"""Disable preheating (cancel timer and active preheating).
Args:
scheduler_entity_id: Scheduler entity to cancel
"""
_LOGGER.debug("Entering HeatingOrchestrator.disable_preheating()")
# Delegate to schedule_anticipation_action to cancel timer
await self._schedule_anticipation_action.cancel_action()
# Cancel active preheating if any
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.info("Preheating disabled")
_LOGGER.debug("Exiting HeatingOrchestrator.disable_preheating()")
async def reset_all_learning_data(self, device_id: str) -> None:
"""Reset all learned data (LHS + cycles).
Args:
device_id: Device identifier
"""
_LOGGER.debug("Entering HeatingOrchestrator.reset_all_learning_data()")
await self._update_cache.reset_cache(device_id)
_LOGGER.debug("Exiting HeatingOrchestrator.reset_all_learning_data()")
def is_preheating_active(self) -> bool:
"""Check if preheating is currently active.
Returns:
True if preheating is active, False otherwise
"""
return self._control_preheating.is_preheating_active()
async def calculate_and_schedule_anticipation(self, ihp_enabled: bool = True) -> dict[str, Any]:
"""Calculate anticipation and handle scheduling based on IHP status.
This orchestrator method:
1. Calculates anticipation data
2. Delegates scheduling decisions to ScheduleAnticipationActionUseCase
3. Always returns anticipation data structure
Args:
ihp_enabled: Whether IHP preheating is enabled
Returns:
Dict with anticipation data for sensors (always returns structure)
"""
_LOGGER.debug(
"Entering HeatingOrchestrator.calculate_and_schedule_anticipation(ihp_enabled=%s)",
ihp_enabled,
)
# Step 1: Always calculate anticipation data regardless of IHP state.
# The switch only controls whether preheating is scheduled, not whether
# calculations happen. This ensures sensors and LHS caches stay up-to-date.
anticipation_data = await self._calculate_anticipation.calculate_anticipation_datas()
# Step 2: Delegate scheduling decisions to use case (respects ihp_enabled)
await self._schedule_anticipation_action.handle_anticipation_scheduling(
anticipation_data,
ihp_enabled,
)
_LOGGER.debug("Exiting HeatingOrchestrator.calculate_and_schedule_anticipation() -> data")
return anticipation_data
async def check_and_prevent_overshoot(self, scheduler_entity_id: str) -> bool:
"""Check overshoot risk and cancel preheating if needed.
Args:
scheduler_entity_id: Scheduler entity to cancel if overshoot detected
Returns:
True if overshoot detected and cancellation triggered, False otherwise
"""
_LOGGER.debug(
"Entering HeatingOrchestrator.check_and_prevent_overshoot(scheduler=%s)",
scheduler_entity_id,
)
overshoot_detected = await self._check_overshoot_risk.check_and_prevent_overshoot(
scheduler_entity_id=scheduler_entity_id
)
if overshoot_detected:
await self._schedule_anticipation_action.cancel_action()
_LOGGER.debug(
"Exiting HeatingOrchestrator.check_and_prevent_overshoot() -> %s",
overshoot_detected,
)
return overshoot_detected
@@ -0,0 +1,24 @@
"""Use cases for Intelligent Heating Pilot.
This package contains use cases that encapsulate single business operations.
Each use case is a focused, testable unit of business logic.
Use cases follow the Single Responsibility Principle and are composed by
the HeatingOrchestrator to implement complex workflows.
"""
from __future__ import annotations
from .calculate_anticipation_use_case import CalculateAnticipationUseCase
from .check_overshoot_risk_use_case import CheckOvershootRiskUseCase
from .control_preheating_use_case import ControlPreheatingUseCase
from .schedule_anticipation_action_use_case import ScheduleAnticipationActionUseCase
from .update_cache_data_use_case import UpdateCacheDataUseCase
__all__ = [
"CalculateAnticipationUseCase",
"CheckOvershootRiskUseCase",
"ControlPreheatingUseCase",
"ScheduleAnticipationActionUseCase",
"UpdateCacheDataUseCase",
]
@@ -0,0 +1,314 @@
"""Calculate Anticipation Data Use Case.
This use case calculates the anticipated start time for preheating
based on current conditions and learned heating slopes.
This is a PURE CALCULATION use case - it does NOT schedule or trigger preheating.
For scheduling, use SchedulePreheatingUseCase.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import (
IClimateDataReader,
IEnvironmentReader,
ILhsStorage,
ISchedulerReader,
)
from ...domain.services import DeadTimeCalculationService, PredictionService
from ..heating_cycle_lifecycle_manager import HeatingCycleLifecycleManager
from ..lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class CalculateAnticipationUseCase:
"""Use case for calculating anticipation data (NO preheating scheduling).
This use case encapsulates the pure calculation logic for:
1. Reading the next scheduled timeslot (or using provided target_time)
2. Getting heating cycles for LHS calculation
3. Calculating the anticipated start time based on learned heating slope
4. Returning anticipation data for display/sensors
This does NOT schedule or trigger any preheating action.
"""
def __init__(
self,
scheduler_reader: ISchedulerReader | None,
environment_reader: IEnvironmentReader,
climate_data_reader: IClimateDataReader,
heating_cycle_manager: HeatingCycleLifecycleManager,
lhs_lifecycle_manager: LhsLifecycleManager,
prediction_service: PredictionService,
dead_time_calculator: DeadTimeCalculationService,
auto_learning: bool = True,
default_dead_time_minutes: float = 0.0,
lhs_storage: ILhsStorage | None = None,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduled timeslots (optional for API-only usage)
environment_reader: Reads current environment conditions
climate_data_reader: Unified reader for VTherm metadata, slope and heating state
heating_cycle_manager: Manages heating cycle lifecycle
lhs_lifecycle_manager: Manages learned heating slopes
prediction_service: Predicts heating time
dead_time_calculator: Calculates dead time from cycles
auto_learning: Whether auto-learning is enabled
default_dead_time_minutes: Default dead time when not learned
lhs_storage: Optional persistent storage for learned values. When provided
and auto_learning is True, the stored learned dead time is used as a
fallback before the configured default, so that the persisted value
is restored immediately after a Home Assistant restart.
"""
_LOGGER.debug("Initializing CalculateAnticipationUseCase")
self._scheduler_reader = scheduler_reader
self._environment_reader = environment_reader
self._climate_data_reader = climate_data_reader
self._heating_cycle_manager = heating_cycle_manager
self._lhs_manager = lhs_lifecycle_manager
self._prediction_service = prediction_service
self._dead_time_calculator = dead_time_calculator
self._auto_learning = auto_learning
self._default_dead_time_minutes = default_dead_time_minutes
self._lhs_storage = lhs_storage
async def calculate_anticipation_datas(
self,
target_time: datetime | None = None,
target_temp: float | None = None,
) -> dict:
"""Calculate anticipation data without scheduling preheating.
Args:
target_time: Target time for heating. If None, uses next scheduled timeslot.
target_temp: Target temperature. If None, uses value from timeslot.
Returns:
Dict with anticipation data. Returns structure with None values for fields
that cannot be calculated.
"""
_LOGGER.debug(
"Entering CalculateAnticipationUseCase.calculate_anticipation_datas(target_time=%s, target_temp=%s)",
target_time.isoformat() if target_time else "None",
target_temp,
)
# Import default constant for LHS validation
from ...domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
# Determine target time and temp
timeslot = None
scheduler_entity = None
timeslot_id = None
# Always get environment data (for minimal return structure)
environment = await self._environment_reader.get_current_environment()
current_temp = environment.indoor_temperature if environment else None
# Always get global LHS (for minimal return structure)
global_lhs = await self._lhs_manager.get_global_lhs()
# Validate global LHS: must be realistically positive (>= 0.5°C/h)
if global_lhs is None or global_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Invalid global LHS (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
global_lhs or 0,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
global_lhs = DEFAULT_LEARNED_SLOPE
if target_time is None:
# Use scheduler to get next timeslot
if self._scheduler_reader is None:
_LOGGER.debug("No scheduler reader configured and no target_time provided")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
timeslot = await self._scheduler_reader.get_next_timeslot()
if not timeslot:
_LOGGER.debug("No scheduled timeslot found")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
target_time = timeslot.target_time
target_temp = timeslot.target_temp
scheduler_entity = timeslot.scheduler_entity
timeslot_id = timeslot.timeslot_id
else:
# Using provided target_time (API/REST usage without scheduler)
if target_temp is None:
_LOGGER.warning("target_time provided but target_temp is None")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
# Get remaining environment data
outdoor_temp = environment.outdoor_temp if environment else None
humidity = environment.indoor_humidity if environment else None
cloud_coverage = environment.cloud_coverage if environment else None
# Get device ID
vtherm_id = self._climate_data_reader.get_vtherm_entity_id()
# Get heating cycles for LHS calculation
heating_cycles = await self._heating_cycle_manager.get_cycles_for_target_time(
device_id=vtherm_id,
target_time=target_time,
)
# Get contextual LHS
lhs = await self._lhs_manager.get_contextual_lhs(
target_time=target_time,
cycles=heating_cycles,
)
# Validate LHS: must be realistically positive (>= 0.5°C/h)
if lhs is None or lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Invalid contextual LHS (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
lhs or 0,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
lhs = DEFAULT_LEARNED_SLOPE
# Calculate effective dead_time
if self._auto_learning and heating_cycles:
avg_dead_time = self._dead_time_calculator.calculate_average_dead_time(heating_cycles)
if avg_dead_time is not None and avg_dead_time > 0:
dead_time = avg_dead_time
_LOGGER.info(
"Learned dead_time from %d cycles: %.1f minutes",
len(heating_cycles),
dead_time,
)
else:
dead_time = await self._get_persisted_dead_time_or_default()
elif self._auto_learning:
# No cycles available yet (e.g. immediately after restart before extraction).
# Fall back to the persisted learned dead time so the correct value is used
# before the first cycle extraction completes.
dead_time = await self._get_persisted_dead_time_or_default()
else:
dead_time = self._default_dead_time_minutes
# Calculate prediction - let prediction_service handle None values
prediction = self._prediction_service.predict_heating_time(
current_temp=current_temp, # Pass None if unavailable
target_temp=target_temp,
outdoor_temp=outdoor_temp,
humidity=humidity,
learned_slope=lhs,
target_time=target_time,
cloud_coverage=cloud_coverage,
dead_time_minutes=dead_time,
)
_LOGGER.info(
"Calculated anticipation: start at %s (%.1f min) for target %.1f°C at %s (LHS: %.2f°C/h)",
prediction.anticipated_start_time.isoformat(),
prediction.estimated_duration_minutes,
target_temp,
target_time.isoformat(),
prediction.learned_heating_slope,
)
result = self._create_data_structure(
anticipated_start_time=prediction.anticipated_start_time,
next_schedule_time=target_time,
next_target_temperature=target_temp,
anticipation_minutes=prediction.estimated_duration_minutes,
current_temp=current_temp,
learned_heating_slope=prediction.learned_heating_slope,
confidence_level=prediction.confidence_level,
timeslot_id=timeslot_id,
scheduler_entity=scheduler_entity,
dead_time=dead_time,
)
_LOGGER.debug("Exiting calculate_anticipation_datas() -> %s", "data")
return result
def _create_data_structure(
self,
anticipated_start_time: datetime | None = None,
next_schedule_time: datetime | None = None,
next_target_temperature: float | None = None,
anticipation_minutes: float | None = None,
current_temp: float | None = None,
learned_heating_slope: float | None = None,
confidence_level: float | None = None,
timeslot_id: str | None = None,
scheduler_entity: str | None = None,
dead_time: float | None = None,
) -> dict:
"""Create data structure with provided values or None defaults.
Returns consistent structure with each field having a value or None.
"""
return {
"anticipated_start_time": anticipated_start_time,
"next_schedule_time": next_schedule_time,
"next_target_temperature": next_target_temperature,
"anticipation_minutes": anticipation_minutes,
"current_temp": current_temp,
"learned_heating_slope": learned_heating_slope,
"confidence_level": confidence_level,
"timeslot_id": timeslot_id,
"scheduler_entity": scheduler_entity,
"dead_time": dead_time,
}
async def _get_persisted_dead_time_or_default(self) -> float:
"""Return the persisted learned dead time or the configured default.
When auto_learning is enabled, this method tries to restore the last
learned dead time from persistent storage. This is the correct fallback
during startup (before cycle extraction completes) or when cycles do not
yield a valid dead time.
Returns:
Persisted learned dead time if available and positive, otherwise the
configured default dead time.
"""
if self._lhs_storage is not None:
try:
stored = await self._lhs_storage.get_learned_dead_time()
if stored is not None and stored > 0:
_LOGGER.debug(
"Using persisted learned dead_time: %.1f minutes", stored
)
return stored
if stored is not None and stored <= 0:
_LOGGER.debug(
"Ignoring non-positive persisted dead_time %.1f minutes; "
"falling back to configured default",
stored,
)
except Exception: # noqa: BLE001
_LOGGER.warning("Failed to read persisted dead time", exc_info=True)
_LOGGER.debug(
"No persisted dead_time found, using configured default: %.1f minutes",
self._default_dead_time_minutes,
)
return self._default_dead_time_minutes
@@ -0,0 +1,133 @@
"""Check Overshoot Risk Use Case.
This use case detects when heating will overshoot the target temperature
and cancels preheating to avoid overheating.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ...domain.interfaces import (
IClimateDataReader,
IEnvironmentReader,
ISchedulerReader,
)
from .control_preheating_use_case import ControlPreheatingUseCase
_LOGGER = logging.getLogger(__name__)
class CheckOvershootRiskUseCase:
"""Use case for detecting overshoot risk.
This use case encapsulates the logic for:
1. Reading current environment and slope
2. Estimating temperature at target time
3. Canceling preheating if overshoot risk is detected
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
environment_reader: IEnvironmentReader,
climate_data_reader: IClimateDataReader,
control_preheating: ControlPreheatingUseCase,
overshoot_threshold_celsius: float = 0.5,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduled timeslots
environment_reader: Reads current environment conditions
climate_data_reader: Reads current heating slope
control_preheating: Cancels preheating when risk detected
overshoot_threshold_celsius: Temperature margin above target to consider as overshoot (°C)
"""
_LOGGER.debug(
"Initializing CheckOvershootRiskUseCase with threshold %.1f°C",
overshoot_threshold_celsius,
)
self._scheduler_reader = scheduler_reader
self._environment_reader = environment_reader
self._climate_data_reader = climate_data_reader
self._control_preheating = control_preheating
self._overshoot_threshold = overshoot_threshold_celsius
async def check_and_prevent_overshoot(self, scheduler_entity_id: str) -> bool:
"""Check for overshoot risk and cancel preheating if needed.
Args:
scheduler_entity_id: Scheduler entity tied to preheating
Returns:
True if overshoot detected and preheating canceled, False otherwise
"""
_LOGGER.debug(
"Entering CheckOvershootRiskUseCase.check_and_prevent_overshoot(scheduler=%s)",
scheduler_entity_id,
)
if not self._control_preheating.is_preheating_active():
_LOGGER.debug("Skipping overshoot check - preheating not active")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
timeslot = await self._scheduler_reader.get_next_timeslot()
if not timeslot:
_LOGGER.debug("Skipping overshoot check - no timeslot available")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
environment = await self._environment_reader.get_current_environment()
if not environment:
# Safety first: If we can't read temperature, assume overshoot risk
_LOGGER.warning("No environment data available - assuming overshoot risk for safety")
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.info("Cancelled preheating due to missing environment data")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> True")
return True
current_slope = self._climate_data_reader.get_current_slope()
if current_slope is None or current_slope <= 0.0:
# Cannot check overshoot without valid slope data
_LOGGER.debug(
"Current slope unavailable (%.2f°C/h) - skipping overshoot check",
current_slope or 0.0,
)
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
now = environment.timestamp
if now >= timeslot.target_time:
_LOGGER.debug("Skipping overshoot check - target time already passed")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
time_to_target_hours = (timeslot.target_time - now).total_seconds() / 3600.0
projected_temp = environment.indoor_temperature + (current_slope * time_to_target_hours)
overshoot_limit = timeslot.target_temp + self._overshoot_threshold
_LOGGER.debug(
"Overshoot check: current=%.1f°C projected=%.1f°C target=%.1f°C limit=%.1f°C",
environment.indoor_temperature,
projected_temp,
timeslot.target_temp,
overshoot_limit,
)
if projected_temp >= overshoot_limit:
_LOGGER.warning(
"Overshoot risk detected: projected %.1f°C exceeds limit %.1f°C",
projected_temp,
overshoot_limit,
)
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> True")
return True
_LOGGER.debug("No overshoot risk detected")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
@@ -0,0 +1,137 @@
"""Control Preheating Use Case.
This use case controls the preheating state (start/cancel).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import ISchedulerCommander
_LOGGER = logging.getLogger(__name__)
class ControlPreheatingUseCase:
"""Use case for controlling preheating state.
This use case encapsulates operations for:
1. Starting preheating (trigger scheduler action)
2. Canceling preheating (revert to current scheduled state)
3. Tracking preheating state
"""
def __init__(
self,
scheduler_commander: ISchedulerCommander,
) -> None:
"""Initialize the use case.
Args:
scheduler_commander: Commands scheduler actions
"""
_LOGGER.debug("Initializing ControlPreheatingUseCase")
self._scheduler_commander = scheduler_commander
self._is_preheating_active = False
self._preheating_target_time: datetime | None = None
self._active_scheduler_entity: str | None = None
async def cancel_preheating(self, scheduler_entity_id: str | None = None) -> None:
"""Cancel active preheating and revert to current scheduled state.
Args:
scheduler_entity_id: Scheduler entity to cancel action on.
If None, uses the active scheduler entity.
"""
_LOGGER.debug(
"Entering ControlPreheatingUseCase.cancel_preheating(scheduler=%s)",
scheduler_entity_id,
)
effective_scheduler = scheduler_entity_id or self._active_scheduler_entity
if self._is_preheating_active:
if not effective_scheduler:
_LOGGER.warning("Cannot cancel preheating: no scheduler entity available")
_LOGGER.debug("Exiting ControlPreheatingUseCase.cancel_preheating() -> no-op")
return
_LOGGER.info(
"Canceling preheating for scheduler %s - reverting to current scheduled state",
effective_scheduler,
)
# Call cancel_action to revert thermostat to current time's preset/temperature
# Validation is handled by scheduler_commander
await self._scheduler_commander.cancel_action(effective_scheduler)
else:
_LOGGER.debug("No active preheating to cancel")
# Mark preheating as inactive (but keep target_time for state tracking)
# Target time may still be in future (e.g., cancelled due to overshoot)
self._is_preheating_active = False
# Note: _preheating_target_time and _active_scheduler_entity are NOT cleared
# They remain for state tracking and potential restart
_LOGGER.debug("Exiting ControlPreheatingUseCase.cancel_preheating()")
async def start_preheating(
self,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Start preheating by triggering scheduler action.
Args:
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity to trigger
"""
_LOGGER.debug(
"Entering ControlPreheatingUseCase.start_preheating(target_time=%s, temp=%.1f, scheduler=%s)",
target_time.isoformat(),
target_temp,
scheduler_entity_id,
)
_LOGGER.info(
"Starting preheating for target %s (%.1f°C)",
target_time.isoformat(),
target_temp,
)
# Use scheduler's run_action to trigger the action
await self._scheduler_commander.run_action(target_time, scheduler_entity_id)
# Mark pre-heating as active
self._is_preheating_active = True
self._preheating_target_time = target_time
self._active_scheduler_entity = scheduler_entity_id
_LOGGER.debug("Exiting ControlPreheatingUseCase.start_preheating()")
def is_preheating_active(self) -> bool:
"""Check if preheating is currently active.
Returns:
True if preheating is active, False otherwise
"""
return self._is_preheating_active
def get_preheating_target_time(self) -> datetime | None:
"""Get the target time for active preheating.
Returns:
Target time if preheating is active, None otherwise
"""
return self._preheating_target_time
def get_active_scheduler_entity(self) -> str | None:
"""Get the active scheduler entity.
Returns:
Scheduler entity ID if active, None otherwise
"""
return self._active_scheduler_entity
@@ -0,0 +1,381 @@
"""Schedule Anticipation Action Use Case.
This use case encapsulates the complex logic for scheduling preheating based on
anticipation calculations, including revert logic when conditions change.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Callable
from homeassistant.util import dt as dt_util
from ...const import DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import ISchedulerCommander, ISchedulerReader, ITimerScheduler
_LOGGER = logging.getLogger(__name__)
class ScheduleAnticipationActionUseCase:
"""Use case for scheduling anticipation actions.
This use case encapsulates the complex logic for:
1. Checking scheduler state
2. Handling revert logic when anticipated start is postponed significantly
3. Scheduling preheating timers
4. Triggering immediate preheating when needed
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
scheduler_commander: ISchedulerCommander,
timer_scheduler: ITimerScheduler,
control_preheating_use_case, # ControlPreheatingUseCase (avoid circular import)
anticipation_recalc_tolerance_minutes: int = DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduler state
scheduler_commander: Triggers scheduler actions
timer_scheduler: Schedules timer callbacks
control_preheating_use_case: Use case for managing preheating state
anticipation_recalc_tolerance_minutes: Min absolute delta in anticipated
start time required to cancel active preheating and reschedule
"""
_LOGGER.debug("Initializing ScheduleAnticipationActionUseCase")
self._scheduler_reader = scheduler_reader
self._scheduler_commander = scheduler_commander
self._timer_scheduler = timer_scheduler
self._control_preheating = control_preheating_use_case
# Scheduling-specific state (not preheating state - delegated to ControlPreheatingUseCase)
self._last_scheduled_time: datetime | None = None
self._last_scheduled_lhs: float | None = None
self._anticipation_timer_cancel: Callable[[], None] | None = None
self._preheating_target_temp: float | None = None # Temp only (time/active delegated)
self._anticipation_recalc_tolerance_seconds = anticipation_recalc_tolerance_minutes * 60
def set_preheating_temp(self, target_temp: float | None) -> None:
"""Update preheating target temperature.
Note: Preheating state (active, target_time) is managed by ControlPreheatingUseCase.
Args:
target_temp: Target temperature
"""
_LOGGER.debug("Setting preheating target temp: %.1f", target_temp or 0.0)
self._preheating_target_temp = target_temp
async def handle_anticipation_scheduling(
self,
anticipation_data: dict,
ihp_enabled: bool,
) -> None:
"""Handle the complete scheduling workflow based on anticipation data and IHP status.
This method contains all the business logic for deciding when to schedule/cancel
preheating based on data availability, IHP status, and current state.
Args:
anticipation_data: Calculated anticipation data
ihp_enabled: Whether IHP is enabled
"""
_LOGGER.debug(
"Entering handle_anticipation_scheduling(ihp_enabled=%s, has_data=%s)",
ihp_enabled,
anticipation_data.get("anticipated_start_time") is not None,
)
# Decision 1: No valid data - cancel everything
if anticipation_data.get("anticipated_start_time") is None:
_LOGGER.debug("No valid anticipation data - cancelling any active scheduling")
await self.cancel_action()
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
return
# Decision 2: IHP disabled - cancel but don't schedule
if not ihp_enabled:
_LOGGER.debug("IHP disabled - cancelling preheating if active")
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
await self.cancel_action()
return
# Decision 3: Target already reached (anticipation_minutes == 0) - clear state
if anticipation_data.get("anticipation_minutes") == 0:
_LOGGER.debug("Target reached - clearing anticipation state")
await self.cancel_action()
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
return
# Decision 4: No scheduler entity - skip scheduling
scheduler_entity = anticipation_data.get("scheduler_entity")
if not scheduler_entity:
_LOGGER.debug("No scheduler entity - skipping scheduling")
return
# Decision 5: Valid data + IHP enabled + scheduler available - schedule
await self.schedule_action(
anticipated_start=anticipation_data["anticipated_start_time"],
target_time=anticipation_data["next_schedule_time"],
target_temp=anticipation_data["next_target_temperature"],
scheduler_entity_id=scheduler_entity,
lhs=float(anticipation_data.get("learned_heating_slope") or 0.0),
)
async def schedule_action(
self,
anticipated_start: datetime,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
lhs: float,
) -> None:
"""Schedule heating anticipation action.
This method handles all the scheduling logic including:
- Checking scheduler state
- Handling revert when anticipated time changes significantly
- Scheduling timers for future starts
- Triggering immediate preheating if start is in the past
Args:
anticipated_start: When to start preheating
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity to trigger
lhs: Learned heating slope
"""
_LOGGER.debug(
"Entering ScheduleAnticipationActionUseCase.schedule_action("
"anticipated_start=%s, scheduler=%s)",
anticipated_start.isoformat(),
scheduler_entity_id,
)
now = dt_util.now()
# Check if scheduler is enabled
if not await self._scheduler_reader.is_scheduler_enabled(scheduler_entity_id):
_LOGGER.warning(
"Scheduler %s is disabled. Skipping anticipation scheduling.",
scheduler_entity_id,
)
active_scheduler = self._control_preheating.get_active_scheduler_entity()
if active_scheduler == scheduler_entity_id:
await self._clear_state()
return
# Handle revert logic: cancel active preheating only when anticipated start is pushed later
# by at least the configured tolerance.
if self._control_preheating.is_preheating_active():
preheating_target = self._control_preheating.get_preheating_target_time()
postponed_seconds = 0.0
if self._last_scheduled_time is not None:
postponed_seconds = (anticipated_start - self._last_scheduled_time).total_seconds()
if (
preheating_target == target_time
and self._last_scheduled_time is not None
and postponed_seconds >= self._anticipation_recalc_tolerance_seconds
):
delta_minutes = postponed_seconds / 60.0
_LOGGER.info(
"Anticipated start moved later significantly (delta: %.1f min, "
"threshold: %.1f min). Reverting active preheating and rescheduling.",
delta_minutes,
self._anticipation_recalc_tolerance_seconds / 60.0,
)
# Delegate cancellation to ControlPreheatingUseCase
await self._control_preheating.cancel_preheating(scheduler_entity_id)
# If target time reached, mark complete
if now >= target_time:
_LOGGER.info("Target time reached, preheating complete")
await self._clear_state()
return
# Update tracking
self._last_scheduled_time = anticipated_start
self._last_scheduled_lhs = lhs
# If anticipated start is in past but target is future, trigger now
if anticipated_start <= now < target_time:
if not self._control_preheating.is_preheating_active():
_LOGGER.info(
"Anticipated start %s is past, triggering preheating immediately",
anticipated_start.isoformat(),
)
# Delegate to ControlPreheatingUseCase
await self._control_preheating.start_preheating(
target_time, target_temp, scheduler_entity_id
)
else:
_LOGGER.debug(
"Already preheating, continuing through target time %s", target_time.isoformat()
)
_LOGGER.debug("Exiting schedule_action() -> immediate trigger")
return
# Both times in past - skip
if anticipated_start <= now and target_time <= now:
_LOGGER.debug("Both times are in past, skipping")
await self._cancel_timer()
return
# Schedule timer for future start only if preheating is not already active.
if not self._control_preheating.is_preheating_active():
await self._schedule_timer(
anticipated_start,
target_time,
target_temp,
scheduler_entity_id,
)
_LOGGER.debug("Exiting schedule_action() -> timer scheduled")
async def _schedule_timer(
self,
anticipated_start: datetime,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Schedule a timer to trigger preheating at anticipated start time.
Args:
anticipated_start: When timer should fire
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler to trigger
"""
_LOGGER.debug(
"Scheduling timer: anticipated_start=%s, scheduler=%s",
anticipated_start.isoformat(),
scheduler_entity_id,
)
# Cancel existing timer
await self._cancel_timer()
# Create callback
async def _timer_callback() -> None:
"""Execute when timer fires."""
_LOGGER.info(
"Anticipation timer fired at %s for target %s (%.1f°C)",
dt_util.now().isoformat(),
target_time.isoformat(),
target_temp,
)
# Clear timer reference
self._anticipation_timer_cancel = None
# Trigger the action
await self._trigger_action(target_time, target_temp, scheduler_entity_id)
# Schedule the timer
self._anticipation_timer_cancel = self._timer_scheduler.schedule_timer(
anticipated_start,
_timer_callback,
)
now = dt_util.now()
wait_minutes = (anticipated_start - now).total_seconds() / 60.0
_LOGGER.info(
"Anticipation timer scheduled: will trigger at %s (in %.1f minutes)",
anticipated_start.isoformat(),
wait_minutes,
)
async def _trigger_action(
self,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Trigger the preheating action via scheduler.
Args:
target_time: Target time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity
"""
_LOGGER.debug(
"Triggering anticipation action: target_time=%s, temp=%.1f°C",
target_time.isoformat(),
target_temp,
)
# Verify scheduler is still enabled
if not await self._scheduler_reader.is_scheduler_enabled(scheduler_entity_id):
_LOGGER.warning("Scheduler %s is disabled, cannot trigger action", scheduler_entity_id)
await self._clear_state()
return
# Delegate to ControlPreheatingUseCase
await self._control_preheating.start_preheating(
target_time, target_temp, scheduler_entity_id
)
self._preheating_target_temp = target_temp
_LOGGER.debug("Action triggered successfully")
async def _cancel_timer(self) -> None:
"""Cancel any active timer.
Note: Does NOT clear _preheating_target_time - that's preserved for state tracking.
"""
_LOGGER.debug("Entering cancel_timer")
if self._anticipation_timer_cancel:
_LOGGER.debug("Canceling active timer")
self._anticipation_timer_cancel()
self._anticipation_timer_cancel = None
# Note: Timer cancelled but target_time preserved (as per review feedback)
_LOGGER.debug("Exiting cancel_timer")
async def _clear_state(self) -> None:
"""Clear all tracking state."""
_LOGGER.debug("Clearing anticipation state")
await self._cancel_timer()
# Clear scheduling-specific state
self._preheating_target_temp = None
self._last_scheduled_time = None
self._last_scheduled_lhs = None
# Note: Preheating active/target_time managed by ControlPreheatingUseCase
async def cancel_action(self) -> None:
"""Cancel any active timer and clear state.
Called by orchestrator when disabling preheating or when conditions change.
"""
_LOGGER.debug("Canceling anticipation action")
await self._cancel_timer()
def get_preheating_state(self) -> tuple[bool, datetime | None, float | None]:
"""Get current preheating state.
Delegates to ControlPreheatingUseCase for state queries.
Returns:
Tuple of (is_active, target_time, target_temp)
"""
return (
self._control_preheating.is_preheating_active(),
self._control_preheating.get_preheating_target_time(),
self._preheating_target_temp,
)
@@ -0,0 +1,156 @@
"""Update Cache Data Use Case.
This use case manages the heating cycle cache:
- Get cache data
- Update/prune cache
- Reset cache completely
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import IHeatingCycleStorage, ILhsStorage
from ...domain.value_objects import HeatingCycle, HeatingCycleCacheData
from ..lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class UpdateCacheDataUseCase:
"""Use case for managing heating cycle cache.
This use case encapsulates the logic for:
1. Getting cycles from cache
2. Updating cache (append new cycles, then prune old ones)
3. Recalculating LHS when cycles change
4. Resetting cache completely
"""
def __init__(
self,
cycle_storage: IHeatingCycleStorage,
lhs_storage: ILhsStorage,
lhs_lifecycle_manager: LhsLifecycleManager,
) -> None:
"""Initialize the use case.
Args:
cycle_storage: Heating cycle cache storage
lhs_storage: LHS storage for clearing
lhs_lifecycle_manager: Manages LHS recalculation
"""
_LOGGER.debug("Initializing UpdateCacheDataUseCase")
self._cycle_storage = cycle_storage
self._lhs_storage = lhs_storage
self._lhs_manager = lhs_lifecycle_manager
async def get_cache_data(self, device_id: str) -> HeatingCycleCacheData | None:
"""Get cache data for a device.
Args:
device_id: Device identifier
Returns:
Cache data if exists, None otherwise
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.get_cache_data(device_id=%s)",
device_id,
)
cache_data = await self._cycle_storage.get_cache_data(device_id)
_LOGGER.debug(
"Exiting UpdateCacheDataUseCase.get_cache_data() -> %s",
"data" if cache_data else "None",
)
return cache_data
async def append_cycles(
self,
device_id: str,
cycles: list[HeatingCycle],
reference_time: datetime,
) -> None:
"""Append new cycles to cache and prune old ones.
Args:
device_id: Device identifier
cycles: New heating cycles to append
reference_time: Reference time for pruning old cycles
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.append_cycles(device_id=%s, cycles=%d)",
device_id,
len(cycles),
)
# Append cycles to storage (storage handles deduplication)
await self._cycle_storage.append_cycles(device_id, cycles, reference_time)
_LOGGER.info(
"Appended %d cycles to cache for device %s",
len(cycles),
device_id,
)
# Prune old cycles based on retention
await self.prune_old_cycles(device_id, reference_time)
_LOGGER.debug("Exiting UpdateCacheDataUseCase.append_cycles()")
async def prune_old_cycles(
self,
device_id: str,
reference_time: datetime,
) -> None:
"""Prune cycles older than retention period and recalculate LHS.
Args:
device_id: Device identifier
reference_time: Reference time for retention calculation
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.prune_old_cycles(device_id=%s, reference_time=%s)",
device_id,
reference_time.isoformat(),
)
# Prune old cycles
await self._cycle_storage.prune_old_cycles(device_id, reference_time)
# Note: LHS is automatically updated when cycles change via event listeners
# No need to explicitly recalculate here
_LOGGER.info("Old cycles pruned, LHS will be recalculated automatically")
_LOGGER.debug("Exiting UpdateCacheDataUseCase.prune_old_cycles()")
async def reset_cache(self, device_id: str) -> None:
"""Reset cache completely for a device (both cycles and LHS).
This deletes all cached cycles and LHS data.
Args:
device_id: Device identifier
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.reset_cache(device_id=%s)",
device_id,
)
_LOGGER.info("Resetting heating cycle cache and LHS for device %s", device_id)
# Clear all cycle cache data
await self._cycle_storage.clear_cache(device_id)
_LOGGER.info("Heating cycle cache has been reset")
# Clear LHS data
await self._lhs_storage.clear_slope_history()
_LOGGER.info("LHS data has been reset")
_LOGGER.info("Cache and LHS have been reset for device %s", device_id)
_LOGGER.debug("Exiting UpdateCacheDataUseCase.reset_cache()")
@@ -0,0 +1,583 @@
"""Config flow for Intelligent Heating Pilot integration."""
from __future__ import annotations
import logging
from typing import Any, cast
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from .const import (
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
CONF_AUTO_LEARNING,
CONF_CLOUD_COVER_ENTITY,
CONF_CYCLE_SPLIT_DURATION_MINUTES,
CONF_DEAD_TIME_MINUTES,
CONF_HUMIDITY_IN_ENTITY,
CONF_HUMIDITY_OUT_ENTITY,
CONF_LHS_RETENTION_DAYS,
CONF_MAX_CYCLE_DURATION_MINUTES,
CONF_MIN_CYCLE_DURATION_MINUTES,
CONF_NAME,
CONF_SAFETY_SHUTOFF_GRACE_MINUTES,
CONF_SCHEDULER_ENTITIES,
CONF_TASK_RANGE_DAYS,
CONF_TEMP_DELTA_THRESHOLD,
CONF_VTHERM_ENTITY,
DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
DEFAULT_AUTO_LEARNING,
DEFAULT_CYCLE_SPLIT_DURATION_MINUTES,
DEFAULT_DEAD_TIME_MINUTES,
DEFAULT_LHS_RETENTION_DAYS,
DEFAULT_MAX_CYCLE_DURATION_MINUTES,
DEFAULT_MIN_CYCLE_DURATION_MINUTES,
DEFAULT_NAME,
DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES,
DEFAULT_TASK_RANGE_DAYS,
DEFAULT_TEMP_DELTA_THRESHOLD,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
class IntelligentHeatingPilotConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
"""Handle a config flow for Intelligent Heating Pilot."""
VERSION = 1
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> IntelligentHeatingPilotOptionsFlow:
"""Get the options flow for this handler."""
return IntelligentHeatingPilotOptionsFlow()
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
processed: dict[str, Any] = {**user_input}
_LOGGER.debug("Received user_input in async_step_user: %s", user_input)
# Schedulers already a list from SelectSelector (multiple=True)
# Just ensure it's always a list
sched_in = processed.get(CONF_SCHEDULER_ENTITIES, [])
if not isinstance(sched_in, list):
sched_in = [sched_in] if sched_in else []
processed[CONF_SCHEDULER_ENTITIES] = sched_in
# Note: EntitySelector optional fields are simply not present in user_input if not filled
# No need to remove them as they won't exist in the dict
_LOGGER.debug("Processed data before validation: %s", processed)
# Required validations
vtherm_val = processed.get(CONF_VTHERM_ENTITY)
if not vtherm_val or (isinstance(vtherm_val, str) and vtherm_val.strip() == ""):
errors[CONF_VTHERM_ENTITY] = "required"
# Scheduler is now optional - no validation needed
if not errors:
_LOGGER.info("Creating entry with data: %s", processed)
await self.async_set_unique_id(processed[CONF_NAME])
self._abort_if_unique_id_configured()
return cast(
FlowResult,
self.async_create_entry(
title=processed[CONF_NAME],
data=processed,
),
)
# Get all scheduler entities with their friendly names
scheduler_options = []
for state in self.hass.states.async_all():
if state.domain not in ("switch", "schedule"):
continue
# Filter for scheduler entities (they typically have "schedule_" prefix or scheduler attributes)
if (
"schedule" in state.entity_id.lower()
or state.attributes.get("next_trigger")
or state.attributes.get("next_event")
):
friendly_name = state.attributes.get("friendly_name", state.entity_id)
scheduler_options.append(
{"value": state.entity_id, "label": f"{friendly_name} ({state.entity_id})"}
)
# Sort by label for easier selection
scheduler_options.sort(key=lambda x: x["label"])
# Build the schema for the configuration form using Entity and Select selectors (same as options flow)
# Scheduler selector: use SelectSelector if options available, else EntitySelector
scheduler_selector = (
selector.SelectSelector(
selector.SelectSelectorConfig(
options=scheduler_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
if scheduler_options
else selector.EntitySelector(
selector.EntitySelectorConfig(domain=["switch", "schedule"], multiple=True)
)
)
data_schema = vol.Schema(
{
vol.Required(CONF_NAME, default=DEFAULT_NAME): str,
vol.Required(CONF_VTHERM_ENTITY): selector.EntitySelector(
selector.EntitySelectorConfig(
domain="climate", integration="versatile_thermostat"
)
),
vol.Optional(CONF_SCHEDULER_ENTITIES): scheduler_selector,
vol.Optional(CONF_HUMIDITY_IN_ENTITY): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
),
vol.Optional(CONF_HUMIDITY_OUT_ENTITY): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
),
vol.Optional(CONF_CLOUD_COVER_ENTITY): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor")
),
vol.Optional(
CONF_LHS_RETENTION_DAYS, default=DEFAULT_LHS_RETENTION_DAYS
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=7,
max=90,
step=1,
unit_of_measurement="days",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_DEAD_TIME_MINUTES, default=DEFAULT_DEAD_TIME_MINUTES
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.0,
max=60.0,
step=1.0,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_AUTO_LEARNING, default=DEFAULT_AUTO_LEARNING
): selector.BooleanSelector(),
vol.Optional(
CONF_TEMP_DELTA_THRESHOLD, default=DEFAULT_TEMP_DELTA_THRESHOLD
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.1,
max=1.0,
step=0.1,
unit_of_measurement="°C",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_CYCLE_SPLIT_DURATION_MINUTES, default=DEFAULT_CYCLE_SPLIT_DURATION_MINUTES
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=120,
step=5,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_MIN_CYCLE_DURATION_MINUTES, default=DEFAULT_MIN_CYCLE_DURATION_MINUTES
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=30,
step=1,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_MAX_CYCLE_DURATION_MINUTES, default=DEFAULT_MAX_CYCLE_DURATION_MINUTES
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=15,
max=360,
step=5,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_TASK_RANGE_DAYS, default=DEFAULT_TASK_RANGE_DAYS
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=30,
step=1,
unit_of_measurement="days",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
default=DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=60,
step=1,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_SAFETY_SHUTOFF_GRACE_MINUTES,
default=DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES,
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=30,
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
}
)
return cast(
FlowResult,
self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
),
)
class IntelligentHeatingPilotOptionsFlow(config_entries.OptionsFlow):
"""Handle options flow for Intelligent Heating Pilot."""
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Manage the options."""
errors: dict[str, str] = {}
normalized_input: dict[str, Any] = {}
if user_input is not None:
# Start with user input
normalized_input = {**user_input}
# Schedulers already a list from SelectSelector (multiple=True)
# Just ensure it's always a list
sched_in = normalized_input.get(CONF_SCHEDULER_ENTITIES, [])
if not isinstance(sched_in, list):
sched_in = [sched_in] if sched_in else []
normalized_input[CONF_SCHEDULER_ENTITIES] = sched_in
# For optional fields: if not in user_input OR empty, explicitly mark for deletion
optional_entity_fields = [
CONF_HUMIDITY_IN_ENTITY,
CONF_HUMIDITY_OUT_ENTITY,
CONF_CLOUD_COVER_ENTITY,
]
fields_to_delete = []
for field in optional_entity_fields:
if field not in user_input:
# Field not submitted = user cleared it
fields_to_delete.append(field)
elif not user_input[field] or (
isinstance(user_input[field], str) and user_input[field].strip() == ""
):
# Field submitted but empty
fields_to_delete.append(field)
# Validate required fields
if not normalized_input.get(CONF_VTHERM_ENTITY):
errors[CONF_VTHERM_ENTITY] = "required"
# Scheduler is now optional - no validation needed
if not errors:
# Merge with existing options
merged_options: dict[str, Any] = {**self.config_entry.options}
merged_options.update(normalized_input)
# For optional fields that were cleared: explicitly set to None to override data
for field in fields_to_delete:
merged_options[field] = None
return cast(
FlowResult,
self.async_create_entry(title="", data=merged_options),
)
# Prepare data sources for defaults
current_options = getattr(self.config_entry, "options", {})
if user_input is not None and errors:
# Re-display user's attempted values on validation errors
current_data = {**self.config_entry.data, **current_options, **normalized_input}
else:
current_data = {**self.config_entry.data, **current_options}
def _opt_or_data(key: str, default: Any = None) -> Any:
# First check options (overrides data)
if key in current_options:
val = current_options.get(key)
# None means explicitly deleted, don't fallback to data
if val is None:
return default
return val
# Fallback to data
return current_data.get(key, default)
# Get all scheduler entities for SelectSelector
scheduler_options = []
for state in self.hass.states.async_all():
if state.domain not in ("switch", "schedule"):
continue
if (
"schedule" in state.entity_id.lower()
or state.attributes.get("next_trigger")
or state.attributes.get("next_event")
):
friendly_name = state.attributes.get("friendly_name", state.entity_id)
scheduler_options.append(
{"value": state.entity_id, "label": f"{friendly_name} ({state.entity_id})"}
)
scheduler_options.sort(key=lambda x: x["label"])
# Compute defaults for entity selectors
default_schedulers_list = _opt_or_data(CONF_SCHEDULER_ENTITIES, [])
if isinstance(default_schedulers_list, str):
default_schedulers_list = [default_schedulers_list]
vtherm_val = _opt_or_data(CONF_VTHERM_ENTITY)
hum_in_val = _opt_or_data(CONF_HUMIDITY_IN_ENTITY)
hum_out_val = _opt_or_data(CONF_HUMIDITY_OUT_ENTITY)
cloud_val = _opt_or_data(CONF_CLOUD_COVER_ENTITY)
# Build schema dynamically: only set default if value exists and is non-empty
schema_dict: dict[Any, Any] = {}
# Required: VTherm (only set default if exists and non-empty)
vtherm_field = (
vol.Required(CONF_VTHERM_ENTITY, default=vtherm_val)
if vtherm_val and vtherm_val != ""
else vol.Required(CONF_VTHERM_ENTITY)
)
schema_dict[vtherm_field] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="climate", integration="versatile_thermostat")
)
# Optional: Schedulers (multiple, only set default if non-empty list)
scheduler_selector = (
selector.SelectSelector(
selector.SelectSelectorConfig(
options=scheduler_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
if scheduler_options
else selector.EntitySelector(
selector.EntitySelectorConfig(domain=["switch", "schedule"], multiple=True)
)
)
schedulers_field = (
vol.Optional(CONF_SCHEDULER_ENTITIES, default=default_schedulers_list)
if default_schedulers_list and len(default_schedulers_list) > 0
else vol.Optional(CONF_SCHEDULER_ENTITIES)
)
schema_dict[schedulers_field] = scheduler_selector
# Optional humidity/cloud fields: Use suggested_value to pre-fill while allowing clearing
schema_dict[
vol.Optional(
CONF_HUMIDITY_IN_ENTITY,
description={"suggested_value": hum_in_val}
if hum_in_val and hum_in_val != ""
else {},
)
] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
)
schema_dict[
vol.Optional(
CONF_HUMIDITY_OUT_ENTITY,
description={"suggested_value": hum_out_val}
if hum_out_val and hum_out_val != ""
else {},
)
] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
)
schema_dict[
vol.Optional(
CONF_CLOUD_COVER_ENTITY,
description={"suggested_value": cloud_val} if cloud_val and cloud_val != "" else {},
)
] = selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor"))
# Numeric fields
schema_dict[
vol.Optional(
CONF_LHS_RETENTION_DAYS,
default=_opt_or_data(CONF_LHS_RETENTION_DAYS, DEFAULT_LHS_RETENTION_DAYS),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=7,
max=90,
step=1,
unit_of_measurement="days",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_DEAD_TIME_MINUTES,
default=_opt_or_data(CONF_DEAD_TIME_MINUTES, DEFAULT_DEAD_TIME_MINUTES),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.0,
max=60.0,
step=1.0,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_AUTO_LEARNING, default=_opt_or_data(CONF_AUTO_LEARNING, DEFAULT_AUTO_LEARNING)
)
] = selector.BooleanSelector()
schema_dict[
vol.Optional(
CONF_TEMP_DELTA_THRESHOLD,
default=_opt_or_data(CONF_TEMP_DELTA_THRESHOLD, DEFAULT_TEMP_DELTA_THRESHOLD),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.1,
max=1.0,
step=0.1,
unit_of_measurement="°C",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_CYCLE_SPLIT_DURATION_MINUTES,
default=_opt_or_data(
CONF_CYCLE_SPLIT_DURATION_MINUTES, DEFAULT_CYCLE_SPLIT_DURATION_MINUTES
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=120,
step=5,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_MIN_CYCLE_DURATION_MINUTES,
default=_opt_or_data(
CONF_MIN_CYCLE_DURATION_MINUTES, DEFAULT_MIN_CYCLE_DURATION_MINUTES
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=30,
step=1,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_MAX_CYCLE_DURATION_MINUTES,
default=_opt_or_data(
CONF_MAX_CYCLE_DURATION_MINUTES, DEFAULT_MAX_CYCLE_DURATION_MINUTES
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=15,
max=360,
step=5,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_TASK_RANGE_DAYS,
default=_opt_or_data(CONF_TASK_RANGE_DAYS, DEFAULT_TASK_RANGE_DAYS),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=30,
step=1,
unit_of_measurement="days",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
default=_opt_or_data(
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=60,
step=1,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_SAFETY_SHUTOFF_GRACE_MINUTES,
default=_opt_or_data(
CONF_SAFETY_SHUTOFF_GRACE_MINUTES, DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=30,
step=1,
mode=selector.NumberSelectorMode.BOX,
)
)
data_schema = vol.Schema(schema_dict)
return cast(
FlowResult,
self.async_show_form(
step_id="init",
data_schema=data_schema,
errors=errors,
),
)

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