New apps Added
This commit is contained in:
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
"""Application layer - use case orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .orchestrator import HeatingOrchestrator
|
||||
|
||||
__all__ = ["HeatingOrchestrator"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1458
File diff suppressed because it is too large
Load Diff
+191
@@ -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
|
||||
+113
@@ -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",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+314
@@ -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
|
||||
+133
@@ -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
|
||||
+137
@@ -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
|
||||
+381
@@ -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,
|
||||
)
|
||||
+156
@@ -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
Reference in New Issue
Block a user